mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
feat(calendar): add birthday integration from contacts service
- Add birthdaysStore to fetch and manage birthdays from contacts API - Add BirthdayPopover component with contact details and link to contacts app - Integrate birthdays into WeekView, MonthView, and DayView as all-day events - Add settings for showBirthdays and showBirthdayAge toggles - Add reactive $effect in layout to load birthdays when setting is enabled - Add /contacts/birthdays endpoint to contacts backend - Configure PUBLIC_CONTACTS_API_URL env variable for calendar app 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b6a4c73ae
commit
cdc3cd3ec8
12 changed files with 995 additions and 85 deletions
100
apps/calendar/apps/web/src/lib/api/birthdays.ts
Normal file
100
apps/calendar/apps/web/src/lib/api/birthdays.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Cross-App API Client for Contacts Backend - Birthday Data
|
||||
* Allows Calendar app to fetch contact birthdays for display
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { createApiClient } from './base-client';
|
||||
|
||||
const CONTACTS_API_BASE = env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
|
||||
|
||||
const contactsClient = createApiClient({
|
||||
baseUrl: CONTACTS_API_BASE,
|
||||
apiPrefix: '/api/v1',
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Types for Birthday Integration
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Lightweight contact data for birthday display
|
||||
* Only essential fields from Contacts API
|
||||
*/
|
||||
export interface ContactBirthdaySummary {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
birthday: string; // YYYY-MM-DD format
|
||||
photoUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Birthday event for calendar display
|
||||
* Generated from ContactBirthdaySummary with display date
|
||||
*/
|
||||
export interface BirthdayEvent {
|
||||
id: string; // Format: birthday-{contactId}-{date}
|
||||
contactId: string;
|
||||
title: string; // "{Name}'s Geburtstag"
|
||||
displayName: string;
|
||||
photoUrl: string | null;
|
||||
birthday: string; // Original birthday date
|
||||
age: number; // Age on this birthday (0 if birth year unknown)
|
||||
startTime: string; // ISO date of the birthday occurrence
|
||||
endTime: string; // Same as startTime (all-day event)
|
||||
isAllDay: true;
|
||||
isBirthday: true; // Type discriminator
|
||||
calendarId: string; // Virtual calendar ID
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Response Types
|
||||
// ============================================
|
||||
|
||||
interface BirthdaysResponse {
|
||||
contacts: ContactBirthdaySummary[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Functions
|
||||
// ============================================
|
||||
|
||||
const fetchContactsApi = contactsClient.fetchApi;
|
||||
|
||||
/**
|
||||
* Fetch all contacts with birthdays from Contacts service
|
||||
*/
|
||||
export async function getBirthdays(): Promise<{
|
||||
data: ContactBirthdaySummary[] | null;
|
||||
error: Error | null;
|
||||
}> {
|
||||
const result = await fetchContactsApi<BirthdaysResponse>('/contacts/birthdays');
|
||||
return {
|
||||
data: result.data?.contacts || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get display name from contact, with fallback
|
||||
*/
|
||||
export function getContactDisplayName(contact: ContactBirthdaySummary): string {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
const fullName = [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
return fullName || 'Unbekannt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Birthday calendar constants
|
||||
*/
|
||||
export const BIRTHDAY_CALENDAR = {
|
||||
id: '__birthdays__',
|
||||
name: 'Geburtstage',
|
||||
color: '#EC4899', // Pink
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import type { BirthdayEvent } from '$lib/api/birthdays';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { X, User, ExternalLink, Cake } from 'lucide-svelte';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
birthday: BirthdayEvent;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { birthday, position, onClose }: Props = $props();
|
||||
|
||||
const CONTACTS_WEB_URL = env.PUBLIC_CONTACTS_WEB_URL || 'http://localhost:5184';
|
||||
const contactUrl = `${CONTACTS_WEB_URL}/contacts/${birthday.contactId}`;
|
||||
|
||||
// Format the original birthday date
|
||||
let birthdayDateFormatted = $derived(() => {
|
||||
try {
|
||||
const date = parseISO(birthday.birthday);
|
||||
return format(date, 'd. MMMM', { locale: de });
|
||||
} catch {
|
||||
return birthday.birthday;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate popover position to stay within viewport
|
||||
let adjustedPosition = $derived(() => {
|
||||
const popoverWidth = 280;
|
||||
const popoverHeight = 200;
|
||||
const padding = 16;
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
|
||||
// Check right boundary
|
||||
if (x + popoverWidth + padding > window.innerWidth) {
|
||||
x = window.innerWidth - popoverWidth - padding;
|
||||
}
|
||||
|
||||
// Check bottom boundary
|
||||
if (y + popoverHeight + padding > window.innerHeight) {
|
||||
y = position.y - popoverHeight - 8; // Show above
|
||||
}
|
||||
|
||||
// Check left boundary
|
||||
if (x < padding) {
|
||||
x = padding;
|
||||
}
|
||||
|
||||
// Check top boundary
|
||||
if (y < padding) {
|
||||
y = padding;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Popover -->
|
||||
<div
|
||||
class="birthday-popover"
|
||||
style="left: {adjustedPosition().x}px; top: {adjustedPosition().y}px;"
|
||||
role="dialog"
|
||||
aria-label="Geburtstag Details"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="popover-header">
|
||||
<div class="header-content">
|
||||
{#if birthday.photoUrl}
|
||||
<img src={birthday.photoUrl} alt={birthday.displayName} class="contact-avatar" />
|
||||
{:else}
|
||||
<div class="contact-avatar-placeholder">
|
||||
<Cake size={24} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="header-info">
|
||||
<h3 class="contact-name">{birthday.displayName}</h3>
|
||||
{#if settingsStore.showBirthdayAge && birthday.age > 0}
|
||||
<p class="contact-age">wird {birthday.age} Jahre alt</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="popover-content">
|
||||
<div class="info-row">
|
||||
<Cake size={16} class="info-icon" />
|
||||
<span>Geburtstag: {birthdayDateFormatted()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="popover-actions">
|
||||
<a href={contactUrl} target="_blank" rel="noopener noreferrer" class="action-btn primary">
|
||||
<User size={16} />
|
||||
<span>Kontakt öffnen</span>
|
||||
<ExternalLink size={14} class="external-icon" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.birthday-popover {
|
||||
position: fixed;
|
||||
width: 280px;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px hsl(var(--color-foreground) / 0.15);
|
||||
overflow: hidden;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.popover-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.contact-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.contact-avatar-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-surface) / 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid white;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contact-age {
|
||||
font-size: 0.875rem;
|
||||
margin: 0.125rem 0 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: hsl(var(--color-surface) / 0.2);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: hsl(var(--color-surface) / 0.3);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.info-row :global(.info-icon) {
|
||||
color: #ec4899;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popover-actions {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #ec4899;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: #db2777;
|
||||
}
|
||||
|
||||
.action-btn :global(.external-icon) {
|
||||
opacity: 0.7;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { todosStore, type Task } from '$lib/stores/todos.svelte';
|
||||
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
|
||||
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
|
||||
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
|
||||
import {
|
||||
useVisibleHours,
|
||||
useCurrentTimeIndicator,
|
||||
|
|
@ -131,6 +133,28 @@
|
|||
|
||||
let blockAllDayEvents = $derived(allDayEvents.filter((e) => getEventDisplayMode(e) === 'block'));
|
||||
|
||||
// Birthday Popover State
|
||||
let selectedBirthday = $state<BirthdayEvent | null>(null);
|
||||
let birthdayPopoverPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
|
||||
// Get birthdays for current day (if enabled in settings)
|
||||
let birthdays = $derived.by(() => {
|
||||
if (!settingsStore.showBirthdays) return [];
|
||||
return birthdaysStore.getBirthdaysForDay(viewStore.currentDate);
|
||||
});
|
||||
|
||||
// Handle birthday click - show popover
|
||||
function handleBirthdayClick(birthday: BirthdayEvent, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
selectedBirthday = birthday;
|
||||
birthdayPopoverPosition = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
// Close birthday popover
|
||||
function closeBirthdayPopover() {
|
||||
selectedBirthday = null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Drag & Drop State
|
||||
// ============================================================================
|
||||
|
|
@ -729,8 +753,8 @@
|
|||
</script>
|
||||
|
||||
<div class="day-view">
|
||||
<!-- Header-style all-day events -->
|
||||
{#if headerAllDayEvents.length > 0}
|
||||
<!-- Header-style all-day events and birthdays -->
|
||||
{#if headerAllDayEvents.length > 0 || birthdays.length > 0}
|
||||
<div class="all-day-section">
|
||||
<div class="time-gutter">
|
||||
<span class="all-day-label">Ganztägig</span>
|
||||
|
|
@ -747,6 +771,18 @@
|
|||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Birthdays -->
|
||||
{#each birthdays as birthday}
|
||||
<button
|
||||
class="all-day-event birthday-event"
|
||||
onclick={(e) => handleBirthdayClick(birthday, e)}
|
||||
>
|
||||
🎂 {birthday.displayName}
|
||||
{#if settingsStore.showBirthdayAge && birthday.age > 0}
|
||||
<span class="birthday-age">({birthday.age})</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -909,6 +945,15 @@
|
|||
<!-- Event Context Menu -->
|
||||
<EventContextMenu onEdit={handleContextMenuEdit} />
|
||||
|
||||
<!-- Birthday Popover -->
|
||||
{#if selectedBirthday}
|
||||
<BirthdayPopover
|
||||
birthday={selectedBirthday}
|
||||
position={birthdayPopoverPosition}
|
||||
onClose={closeBirthdayPopover}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.day-view {
|
||||
display: flex;
|
||||
|
|
@ -1238,4 +1283,18 @@
|
|||
opacity: 1;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
/* Birthday events in all-day row */
|
||||
.all-day-event.birthday-event {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
|
||||
}
|
||||
|
||||
.all-day-event.birthday-event:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.birthday-age {
|
||||
opacity: 0.85;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@
|
|||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
|
||||
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
|
||||
import TodoDayCell from './TodoDayCell.svelte';
|
||||
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
format,
|
||||
|
|
@ -259,6 +261,27 @@
|
|||
if (eventsStore.isDraftEvent(event.id)) return;
|
||||
eventContextMenuStore.show(event, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Birthday Functions
|
||||
// ============================================================================
|
||||
let selectedBirthday = $state<BirthdayEvent | null>(null);
|
||||
let birthdayPopoverPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
|
||||
function getBirthdaysForDay(day: Date): BirthdayEvent[] {
|
||||
if (!settingsStore.showBirthdays) return [];
|
||||
return birthdaysStore.getBirthdaysForDay(day);
|
||||
}
|
||||
|
||||
function handleBirthdayClick(birthday: BirthdayEvent, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
selectedBirthday = birthday;
|
||||
birthdayPopoverPosition = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
function closeBirthdayPopover() {
|
||||
selectedBirthday = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="month-view" style="--column-count: {columnCount}" bind:this={monthViewRef}>
|
||||
|
|
@ -338,6 +361,23 @@
|
|||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Birthdays -->
|
||||
{#each getBirthdaysForDay(day) as birthday}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="event-pill birthday-pill"
|
||||
onclick={(e) => handleBirthdayClick(birthday, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
🎂
|
||||
<span class="event-title">{birthday.displayName}</span>
|
||||
{#if settingsStore.showBirthdayAge && birthday.age > 0}
|
||||
<span class="birthday-age">({birthday.age})</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if getAllEventsForDay(day).length > 3}
|
||||
<button class="more-events" onclick={(e) => handleMoreClick(day, e)}>
|
||||
{$_('views.moreEvents', {
|
||||
|
|
@ -353,6 +393,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Birthday Popover -->
|
||||
{#if selectedBirthday}
|
||||
<BirthdayPopover
|
||||
birthday={selectedBirthday}
|
||||
position={birthdayPopoverPosition}
|
||||
onClose={closeBirthdayPopover}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.month-view {
|
||||
display: flex;
|
||||
|
|
@ -537,4 +586,19 @@
|
|||
outline: 2px dashed hsl(var(--color-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Birthday pills */
|
||||
.event-pill.birthday-pill {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-pill.birthday-pill:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.birthday-age {
|
||||
opacity: 0.85;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { todosStore, type Task } from '$lib/stores/todos.svelte';
|
||||
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
|
||||
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
|
||||
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
|
||||
import {
|
||||
useVisibleHours,
|
||||
useCurrentTimeIndicator,
|
||||
|
|
@ -118,6 +120,33 @@
|
|||
// Reference to the days container for position calculations
|
||||
let daysContainerEl: HTMLDivElement;
|
||||
|
||||
// Birthday Popover State
|
||||
let selectedBirthday = $state<BirthdayEvent | null>(null);
|
||||
let birthdayPopoverPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
|
||||
// Get birthdays for a day (if enabled in settings)
|
||||
function getBirthdaysForDay(day: Date): BirthdayEvent[] {
|
||||
if (!settingsStore.showBirthdays) return [];
|
||||
return birthdaysStore.getBirthdaysForDay(day);
|
||||
}
|
||||
|
||||
// Check if there are any birthdays to show in the all-day row
|
||||
let hasAnyBirthdays = $derived(
|
||||
settingsStore.showBirthdays && days.some((day) => getBirthdaysForDay(day).length > 0)
|
||||
);
|
||||
|
||||
// Handle birthday click - show popover
|
||||
function handleBirthdayClick(birthday: BirthdayEvent, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
selectedBirthday = birthday;
|
||||
birthdayPopoverPosition = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
// Close birthday popover
|
||||
function closeBirthdayPopover() {
|
||||
selectedBirthday = null;
|
||||
}
|
||||
|
||||
function getEventsForDay(day: Date) {
|
||||
// Filter by visible calendars first
|
||||
const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id));
|
||||
|
|
@ -888,8 +917,8 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- All-day events row (only shown when there are header-mode all-day events) -->
|
||||
{#if hasAnyHeaderAllDayEvents}
|
||||
<!-- All-day events row (shown when there are header-mode all-day events or birthdays) -->
|
||||
{#if hasAnyHeaderAllDayEvents || hasAnyBirthdays}
|
||||
<div class="all-day-row">
|
||||
<div class="time-gutter">
|
||||
{#if settingsStore.showWeekNumbers}
|
||||
|
|
@ -909,6 +938,18 @@
|
|||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Birthdays -->
|
||||
{#each getBirthdaysForDay(day) as birthday}
|
||||
<button
|
||||
class="all-day-event birthday-event"
|
||||
onclick={(e) => handleBirthdayClick(birthday, e)}
|
||||
>
|
||||
🎂 {birthday.displayName}
|
||||
{#if settingsStore.showBirthdayAge && birthday.age > 0}
|
||||
<span class="birthday-age">({birthday.age})</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -1107,6 +1148,15 @@
|
|||
|
||||
<EventContextMenu onEdit={handleContextMenuEdit} />
|
||||
|
||||
<!-- Birthday Popover -->
|
||||
{#if selectedBirthday}
|
||||
<BirthdayPopover
|
||||
birthday={selectedBirthday}
|
||||
position={birthdayPopoverPosition}
|
||||
onClose={closeBirthdayPopover}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.week-view {
|
||||
display: flex;
|
||||
|
|
@ -1165,6 +1215,20 @@
|
|||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
/* Birthday events in all-day row */
|
||||
.all-day-event.birthday-event {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
|
||||
}
|
||||
|
||||
.all-day-event.birthday-event:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.birthday-age {
|
||||
opacity: 0.85;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Block-style all-day events (displayed as full-day blocks in the grid) */
|
||||
.all-day-block-event {
|
||||
position: absolute;
|
||||
|
|
|
|||
219
apps/calendar/apps/web/src/lib/stores/birthdays.svelte.ts
Normal file
219
apps/calendar/apps/web/src/lib/stores/birthdays.svelte.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Birthdays Store - Manages contact birthdays for calendar display
|
||||
* Cross-app integration with Contacts Backend (similar to todosStore)
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import * as api from '$lib/api/birthdays';
|
||||
import type { ContactBirthdaySummary, BirthdayEvent } from '$lib/api/birthdays';
|
||||
import { getContactDisplayName, BIRTHDAY_CALENDAR } from '$lib/api/birthdays';
|
||||
import { differenceInYears, isSameDay, isWithinInterval, parseISO, format } from 'date-fns';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { ContactBirthdaySummary, BirthdayEvent };
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
|
||||
let birthdays = $state<ContactBirthdaySummary[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let serviceAvailable = $state(true);
|
||||
let lastFetchTime = $state<number>(0);
|
||||
|
||||
// Cache settings
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// ============================================
|
||||
// Store Export
|
||||
// ============================================
|
||||
|
||||
export const birthdaysStore = {
|
||||
// ========== Getters ==========
|
||||
get birthdays() {
|
||||
return birthdays ?? [];
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get serviceAvailable() {
|
||||
return serviceAvailable;
|
||||
},
|
||||
get calendarId() {
|
||||
return BIRTHDAY_CALENDAR.id;
|
||||
},
|
||||
get calendarColor() {
|
||||
return BIRTHDAY_CALENDAR.color;
|
||||
},
|
||||
get calendarName() {
|
||||
return BIRTHDAY_CALENDAR.name;
|
||||
},
|
||||
|
||||
// ========== Birthday Getters ==========
|
||||
|
||||
/**
|
||||
* Get birthday events for a specific day
|
||||
* Matches by month and day (ignores year)
|
||||
*/
|
||||
getBirthdaysForDay(date: Date): BirthdayEvent[] {
|
||||
const currentBirthdays = birthdays ?? [];
|
||||
if (!Array.isArray(currentBirthdays) || currentBirthdays.length === 0) return [];
|
||||
|
||||
return currentBirthdays
|
||||
.filter((contact) => {
|
||||
if (!contact.birthday) return false;
|
||||
const birthdayDate = parseISO(contact.birthday);
|
||||
// Compare month and day only
|
||||
return (
|
||||
birthdayDate.getMonth() === date.getMonth() && birthdayDate.getDate() === date.getDate()
|
||||
);
|
||||
})
|
||||
.map((contact) => this.toBirthdayEvent(contact, date));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get birthday events within a date range
|
||||
*/
|
||||
getBirthdaysInRange(start: Date, end: Date): BirthdayEvent[] {
|
||||
const currentBirthdays = birthdays ?? [];
|
||||
if (!Array.isArray(currentBirthdays) || currentBirthdays.length === 0) return [];
|
||||
|
||||
const events: BirthdayEvent[] = [];
|
||||
const current = new Date(start);
|
||||
|
||||
// Iterate through each day in range
|
||||
while (current <= end) {
|
||||
const dayBirthdays = this.getBirthdaysForDay(current);
|
||||
events.push(...dayBirthdays);
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return events;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a specific day has any birthdays
|
||||
*/
|
||||
hasBirthdaysOnDay(date: Date): boolean {
|
||||
const currentBirthdays = birthdays ?? [];
|
||||
if (!Array.isArray(currentBirthdays)) return false;
|
||||
|
||||
return currentBirthdays.some((contact) => {
|
||||
if (!contact.birthday) return false;
|
||||
const birthdayDate = parseISO(contact.birthday);
|
||||
return (
|
||||
birthdayDate.getMonth() === date.getMonth() && birthdayDate.getDate() === date.getDate()
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get upcoming birthdays (next N days)
|
||||
*/
|
||||
getUpcomingBirthdays(days: number = 30): BirthdayEvent[] {
|
||||
const start = new Date();
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() + days);
|
||||
return this.getBirthdaysInRange(start, end);
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert contact to birthday event
|
||||
*/
|
||||
toBirthdayEvent(contact: ContactBirthdaySummary, displayDate: Date): BirthdayEvent {
|
||||
const displayName = getContactDisplayName(contact);
|
||||
const birthdayDate = parseISO(contact.birthday);
|
||||
const birthYear = birthdayDate.getFullYear();
|
||||
|
||||
// Calculate age (0 if year seems invalid, e.g., 1900 default)
|
||||
let age = differenceInYears(displayDate, birthdayDate);
|
||||
if (birthYear < 1900 || birthYear > new Date().getFullYear()) {
|
||||
age = 0; // Unknown birth year
|
||||
}
|
||||
|
||||
const dateStr = format(displayDate, 'yyyy-MM-dd');
|
||||
|
||||
return {
|
||||
id: `birthday-${contact.id}-${dateStr}`,
|
||||
contactId: contact.id,
|
||||
title: `${displayName}`,
|
||||
displayName,
|
||||
photoUrl: contact.photoUrl,
|
||||
birthday: contact.birthday,
|
||||
age,
|
||||
startTime: displayDate.toISOString(),
|
||||
endTime: displayDate.toISOString(),
|
||||
isAllDay: true,
|
||||
isBirthday: true,
|
||||
calendarId: BIRTHDAY_CALENDAR.id,
|
||||
};
|
||||
},
|
||||
|
||||
// ========== API Methods ==========
|
||||
|
||||
/**
|
||||
* Fetch birthdays from Contacts service
|
||||
* Uses cache to avoid frequent refetches
|
||||
*/
|
||||
async fetchBirthdays(force = false) {
|
||||
if (!browser) return;
|
||||
|
||||
// Use cache if still valid
|
||||
if (!force && Date.now() - lastFetchTime < CACHE_TTL && birthdays.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await api.getBirthdays();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
serviceAvailable = false;
|
||||
} else {
|
||||
birthdays = result.data || [];
|
||||
serviceAvailable = true;
|
||||
lastFetchTime = Date.now();
|
||||
}
|
||||
|
||||
loading = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if Contacts service is available
|
||||
*/
|
||||
async checkServiceHealth(): Promise<boolean> {
|
||||
const result = await api.getBirthdays();
|
||||
serviceAvailable = !result.error;
|
||||
return serviceAvailable;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear birthdays cache
|
||||
*/
|
||||
clear() {
|
||||
birthdays = [];
|
||||
lastFetchTime = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get contact by ID from cached birthdays
|
||||
*/
|
||||
getContactById(id: string): ContactBirthdaySummary | undefined {
|
||||
const currentBirthdays = birthdays ?? [];
|
||||
if (!Array.isArray(currentBirthdays)) return undefined;
|
||||
return currentBirthdays.find((c) => c.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Count of contacts with birthdays
|
||||
*/
|
||||
get count(): number {
|
||||
return birthdays?.length ?? 0;
|
||||
},
|
||||
};
|
||||
|
|
@ -35,6 +35,10 @@ export interface CalendarAppSettings {
|
|||
dateStripCompact: boolean; // Use compact/smaller DateStrip
|
||||
dateStripShowWeekNumbers: boolean; // Show week numbers at start of week
|
||||
|
||||
// Birthday settings (cross-app integration with Contacts)
|
||||
showBirthdays: boolean; // Show contact birthdays in calendar
|
||||
showBirthdayAge: boolean; // Show age in birthday events
|
||||
|
||||
// UI settings
|
||||
sidebarCollapsed: boolean;
|
||||
|
||||
|
|
@ -61,6 +65,9 @@ const DEFAULT_SETTINGS: CalendarAppSettings = {
|
|||
dateStripShowMonthDividers: true,
|
||||
dateStripCompact: false,
|
||||
dateStripShowWeekNumbers: false,
|
||||
// Birthday defaults
|
||||
showBirthdays: true,
|
||||
showBirthdayAge: true,
|
||||
// UI defaults
|
||||
sidebarCollapsed: false,
|
||||
defaultEventDuration: 60,
|
||||
|
|
@ -182,6 +189,13 @@ export const settingsStore = {
|
|||
get dateStripShowWeekNumbers() {
|
||||
return settings.dateStripShowWeekNumbers;
|
||||
},
|
||||
// Birthday settings
|
||||
get showBirthdays() {
|
||||
return settings.showBirthdays;
|
||||
},
|
||||
get showBirthdayAge() {
|
||||
return settings.showBirthdayAge;
|
||||
},
|
||||
get defaultEventDuration() {
|
||||
return settings.defaultEventDuration;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@
|
|||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
|
|
@ -282,6 +284,13 @@
|
|||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
|
||||
// Reactive effect: load birthdays when setting is enabled
|
||||
$effect(() => {
|
||||
if (browser && settingsStore.showBirthdays && authStore.isAuthenticated) {
|
||||
birthdaysStore.fetchBirthdays();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// Redirect to login if not authenticated
|
||||
if (!authStore.isAuthenticated) {
|
||||
|
|
@ -300,6 +309,8 @@
|
|||
await eventTagsStore.fetchTags();
|
||||
await userSettings.load();
|
||||
|
||||
// Note: Birthdays are loaded via reactive $effect when showBirthdays is enabled
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@
|
|||
import type { TimeFormat, AllDayDisplayMode } from '$lib/stores/settings.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
import { GlobalSettingsSection, SettingsSection, SettingsCard } from '@manacore/shared-ui';
|
||||
import {
|
||||
GlobalSettingsSection,
|
||||
SettingsSection,
|
||||
SettingsCard,
|
||||
FilterDropdown,
|
||||
type FilterDropdownOption,
|
||||
} from '@manacore/shared-ui';
|
||||
import type { CalendarViewType, Calendar } from '@calendar/shared';
|
||||
|
||||
// Calendar management state
|
||||
|
|
@ -144,6 +150,47 @@
|
|||
{ value: 60, label: '1 Stunde' },
|
||||
{ value: 1440, label: '1 Tag' },
|
||||
];
|
||||
|
||||
// FilterDropdown options
|
||||
let viewOptions = $derived<FilterDropdownOption[]>(
|
||||
Object.entries(viewLabels).map(([value, label]) => ({ value, label }))
|
||||
);
|
||||
|
||||
let durationDropdownOptions = $derived<FilterDropdownOption[]>(
|
||||
durationOptions.map((duration) => ({
|
||||
value: String(duration),
|
||||
label:
|
||||
duration >= 60
|
||||
? `${duration / 60} Stunde${duration > 60 ? 'n' : ''}`
|
||||
: `${duration} Minuten`,
|
||||
}))
|
||||
);
|
||||
|
||||
let reminderDropdownOptions = $derived<FilterDropdownOption[]>(
|
||||
reminderOptions.map((opt) => ({
|
||||
value: String(opt.value),
|
||||
label: opt.label,
|
||||
}))
|
||||
);
|
||||
|
||||
// Dynamic hour options (filtered by the other value)
|
||||
let hourStartOptions = $derived<FilterDropdownOption[]>(
|
||||
Array.from({ length: 24 }, (_, i) => i)
|
||||
.filter((hour) => hour < settingsStore.dayEndHour)
|
||||
.map((hour) => ({
|
||||
value: String(hour),
|
||||
label: `${hour.toString().padStart(2, '0')}:00`,
|
||||
}))
|
||||
);
|
||||
|
||||
let hourEndOptions = $derived<FilterDropdownOption[]>(
|
||||
Array.from({ length: 24 }, (_, i) => i + 1)
|
||||
.filter((hour) => hour > settingsStore.dayStartHour)
|
||||
.map((hour) => ({
|
||||
value: String(hour),
|
||||
label: `${hour.toString().padStart(2, '0')}:00`,
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -348,15 +395,12 @@
|
|||
<span class="setting-label">Standard-Ansicht</span>
|
||||
<span class="setting-description">Ansicht beim Öffnen des Kalenders</span>
|
||||
</div>
|
||||
<select
|
||||
class="select-input"
|
||||
<FilterDropdown
|
||||
options={viewOptions}
|
||||
value={settingsStore.defaultView}
|
||||
onchange={(e) => handleViewChange(e.currentTarget.value as CalendarViewType)}
|
||||
>
|
||||
{#each Object.entries(viewLabels) as [value, label]}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
onChange={(v) => handleViewChange(v as CalendarViewType)}
|
||||
placeholder="Ansicht wählen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
|
|
@ -438,35 +482,23 @@
|
|||
</div>
|
||||
<div class="hour-range-inputs">
|
||||
<div class="hour-input-group">
|
||||
<label for="start-hour">Von</label>
|
||||
<select
|
||||
id="start-hour"
|
||||
class="select-input hour-select"
|
||||
value={settingsStore.dayStartHour}
|
||||
onchange={(e) => settingsStore.set('dayStartHour', Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each Array.from({ length: 24 }, (_, i) => i) as hour}
|
||||
{#if hour < settingsStore.dayEndHour}
|
||||
<option value={hour}>{hour.toString().padStart(2, '0')}:00</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
<span class="hour-label">Von</span>
|
||||
<FilterDropdown
|
||||
options={hourStartOptions}
|
||||
value={String(settingsStore.dayStartHour)}
|
||||
onChange={(v) => settingsStore.set('dayStartHour', Number(v))}
|
||||
placeholder="Start"
|
||||
/>
|
||||
</div>
|
||||
<span class="hour-separator">–</span>
|
||||
<div class="hour-input-group">
|
||||
<label for="end-hour">Bis</label>
|
||||
<select
|
||||
id="end-hour"
|
||||
class="select-input hour-select"
|
||||
value={settingsStore.dayEndHour}
|
||||
onchange={(e) => settingsStore.set('dayEndHour', Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each Array.from({ length: 24 }, (_, i) => i + 1) as hour}
|
||||
{#if hour > settingsStore.dayStartHour}
|
||||
<option value={hour}>{hour.toString().padStart(2, '0')}:00</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
<span class="hour-label">Bis</span>
|
||||
<FilterDropdown
|
||||
options={hourEndOptions}
|
||||
value={String(settingsStore.dayEndHour)}
|
||||
onChange={(v) => settingsStore.set('dayEndHour', Number(v))}
|
||||
placeholder="Ende"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -517,19 +549,12 @@
|
|||
<span class="setting-label">Standard-Dauer</span>
|
||||
<span class="setting-description">Voreingestellte Dauer für neue Termine</span>
|
||||
</div>
|
||||
<select
|
||||
class="select-input"
|
||||
value={settingsStore.defaultEventDuration}
|
||||
onchange={(e) => handleEventDurationChange(Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each durationOptions as duration}
|
||||
<option value={duration}>
|
||||
{duration >= 60
|
||||
? `${duration / 60} Stunde${duration > 60 ? 'n' : ''}`
|
||||
: `${duration} Minuten`}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<FilterDropdown
|
||||
options={durationDropdownOptions}
|
||||
value={String(settingsStore.defaultEventDuration)}
|
||||
onChange={(v) => handleEventDurationChange(Number(v))}
|
||||
placeholder="Dauer wählen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
|
|
@ -537,20 +562,68 @@
|
|||
<span class="setting-label">Standard-Erinnerung</span>
|
||||
<span class="setting-description">Voreingestellte Erinnerung für neue Termine</span>
|
||||
</div>
|
||||
<select
|
||||
class="select-input"
|
||||
value={settingsStore.defaultReminder}
|
||||
onchange={(e) => handleReminderChange(Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each reminderOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<FilterDropdown
|
||||
options={reminderDropdownOptions}
|
||||
value={String(settingsStore.defaultReminder)}
|
||||
onChange={(v) => handleReminderChange(Number(v))}
|
||||
placeholder="Erinnerung wählen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Geburtstage -->
|
||||
<SettingsSection title="Geburtstage">
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 15.546c-.523 0-1.046.151-1.5.454a2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0 2.701 2.701 0 00-1.5-.454M9 6v2m3-2v2m3-2v2M9 3h.01M12 3h.01M15 3h.01M21 21v-7a2 2 0 00-2-2H5a2 2 0 00-2 2v7h18zm-3-9v-2a2 2 0 00-2-2H8a2 2 0 00-2 2v2h12z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<SettingsCard>
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="setting-item">
|
||||
<label class="toggle-setting">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settingsStore.showBirthdays}
|
||||
onchange={() => settingsStore.set('showBirthdays', !settingsStore.showBirthdays)}
|
||||
/>
|
||||
<div class="toggle-info">
|
||||
<span class="setting-label">Geburtstage anzeigen</span>
|
||||
<span class="setting-description">Geburtstage aus Kontakten im Kalender anzeigen</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if settingsStore.showBirthdays}
|
||||
<div class="setting-item birthday-age-setting">
|
||||
<label class="toggle-setting">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settingsStore.showBirthdayAge}
|
||||
onchange={() =>
|
||||
settingsStore.set('showBirthdayAge', !settingsStore.showBirthdayAge)}
|
||||
/>
|
||||
<div class="toggle-info">
|
||||
<span class="setting-label">Alter anzeigen</span>
|
||||
<span class="setting-description"
|
||||
>Das Alter der Person bei Geburtstagen anzeigen</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Konto -->
|
||||
<SettingsSection title="Konto">
|
||||
{#snippet icon()}
|
||||
|
|
@ -643,24 +716,6 @@
|
|||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Select input */
|
||||
.select-input {
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
background: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
/* Button group */
|
||||
.button-group {
|
||||
display: flex;
|
||||
|
|
@ -745,15 +800,11 @@
|
|||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.hour-input-group label {
|
||||
.hour-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.hour-select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.hour-separator {
|
||||
font-size: 1.25rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
|
|
@ -1015,4 +1066,11 @@
|
|||
padding: 1.5rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Birthday age setting (indented sub-setting) */
|
||||
.birthday-age-setting {
|
||||
padding-left: 2rem;
|
||||
border-left: 2px solid hsl(var(--color-primary) / 0.3);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -234,6 +234,16 @@ export class ContactController {
|
|||
return { contacts, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contacts with birthdays (for calendar integration)
|
||||
* Returns lightweight data: id, displayName, firstName, lastName, birthday, photoUrl
|
||||
*/
|
||||
@Get('birthdays')
|
||||
async getBirthdays(@CurrentUser() user: CurrentUserData) {
|
||||
const contacts = await this.contactService.findWithBirthdays(user.userId);
|
||||
return { contacts };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const contact = await this.contactService.findById(id, user.userId);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, or, ilike, desc, sql } from 'drizzle-orm';
|
||||
import { eq, and, or, ilike, desc, sql, isNotNull } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { contacts } from '../db/schema';
|
||||
import type { Contact, NewContact } from '../db/schema';
|
||||
|
||||
export interface ContactBirthdaySummary {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
birthday: string;
|
||||
photoUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ContactFilters {
|
||||
search?: string;
|
||||
isFavorite?: boolean;
|
||||
|
|
@ -148,4 +157,34 @@ export class ContactService {
|
|||
|
||||
return Number(result[0]?.count || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all contacts with birthdays (for calendar integration)
|
||||
* Returns only essential fields for lightweight transfer
|
||||
*/
|
||||
async findWithBirthdays(userId: string): Promise<ContactBirthdaySummary[]> {
|
||||
const result = await this.db
|
||||
.select({
|
||||
id: contacts.id,
|
||||
displayName: contacts.displayName,
|
||||
firstName: contacts.firstName,
|
||||
lastName: contacts.lastName,
|
||||
birthday: contacts.birthday,
|
||||
photoUrl: contacts.photoUrl,
|
||||
})
|
||||
.from(contacts)
|
||||
.where(
|
||||
and(
|
||||
eq(contacts.userId, userId),
|
||||
eq(contacts.isArchived, false),
|
||||
isNotNull(contacts.birthday)
|
||||
)
|
||||
)
|
||||
.orderBy(contacts.lastName, contacts.firstName);
|
||||
|
||||
return result.map((c) => ({
|
||||
...c,
|
||||
birthday: c.birthday || '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -428,6 +428,9 @@ const APP_CONFIGS = [
|
|||
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
PUBLIC_TODO_BACKEND_URL: (env) =>
|
||||
env.TODO_BACKEND_URL || `http://localhost:${env.TODO_BACKEND_PORT || '3018'}`,
|
||||
// Cross-app integration: Contacts service for birthdays
|
||||
PUBLIC_CONTACTS_API_URL: (env) => `http://localhost:${env.CONTACTS_BACKEND_PORT || '3015'}`,
|
||||
PUBLIC_CONTACTS_WEB_URL: () => 'http://localhost:5184',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue