From cdc3cd3ec85852318f667e8de89bdf7d09f53efb Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:49:08 +0100 Subject: [PATCH] feat(calendar): add birthday integration from contacts service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../apps/web/src/lib/api/birthdays.ts | 100 +++++++ .../birthday/BirthdayPopover.svelte | 269 ++++++++++++++++++ .../lib/components/calendar/DayView.svelte | 63 +++- .../lib/components/calendar/MonthView.svelte | 64 +++++ .../lib/components/calendar/WeekView.svelte | 68 ++++- .../web/src/lib/stores/birthdays.svelte.ts | 219 ++++++++++++++ .../web/src/lib/stores/settings.svelte.ts | 14 + .../apps/web/src/routes/(app)/+layout.svelte | 11 + .../src/routes/(app)/settings/+page.svelte | 218 ++++++++------ .../backend/src/contact/contact.controller.ts | 10 + .../backend/src/contact/contact.service.ts | 41 ++- scripts/generate-env.mjs | 3 + 12 files changed, 995 insertions(+), 85 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/api/birthdays.ts create mode 100644 apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte create mode 100644 apps/calendar/apps/web/src/lib/stores/birthdays.svelte.ts diff --git a/apps/calendar/apps/web/src/lib/api/birthdays.ts b/apps/calendar/apps/web/src/lib/api/birthdays.ts new file mode 100644 index 000000000..10e4ed9b4 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/birthdays.ts @@ -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('/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; diff --git a/apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte b/apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte new file mode 100644 index 000000000..5dde69d6a --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte @@ -0,0 +1,269 @@ + + + + + +
e.key === 'Escape' && onClose()} + role="button" + tabindex="-1" +> + + +
+ + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte index 9de0aae29..765b26413 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -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(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 @@
- - {#if headerAllDayEvents.length > 0} + + {#if headerAllDayEvents.length > 0 || birthdays.length > 0}
Ganztägig @@ -747,6 +771,18 @@ {event.title} {/each} + + {#each birthdays as birthday} + + {/each}
{/if} @@ -909,6 +945,15 @@ + +{#if selectedBirthday} + +{/if} + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte index 6ce614ec6..22a3cc3ae 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -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(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; + }
@@ -338,6 +361,23 @@
{/each} + + {#each getBirthdaysForDay(day) as birthday} + +
handleBirthdayClick(birthday, e)} + role="button" + tabindex="0" + > + 🎂 + {birthday.displayName} + {#if settingsStore.showBirthdayAge && birthday.age > 0} + ({birthday.age}) + {/if} +
+ {/each} + {#if getAllEventsForDay(day).length > 3}
+ +{#if selectedBirthday} + +{/if} + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte index 0fe825e00..0ee5a07cb 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -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(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} - - {#if hasAnyHeaderAllDayEvents} + + {#if hasAnyHeaderAllDayEvents || hasAnyBirthdays}
{#if settingsStore.showWeekNumbers} @@ -909,6 +938,18 @@ {event.title} {/each} + + {#each getBirthdaysForDay(day) as birthday} + + {/each}
{/each}
@@ -1107,6 +1148,15 @@ + +{#if selectedBirthday} + +{/if} + diff --git a/apps/contacts/apps/backend/src/contact/contact.controller.ts b/apps/contacts/apps/backend/src/contact/contact.controller.ts index d896e036d..04c38177b 100644 --- a/apps/contacts/apps/backend/src/contact/contact.controller.ts +++ b/apps/contacts/apps/backend/src/contact/contact.controller.ts @@ -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); diff --git a/apps/contacts/apps/backend/src/contact/contact.service.ts b/apps/contacts/apps/backend/src/contact/contact.service.ts index f6cbd9092..3f528efef 100644 --- a/apps/contacts/apps/backend/src/contact/contact.service.ts +++ b/apps/contacts/apps/backend/src/contact/contact.service.ts @@ -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 { + 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 || '', + })); + } } diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index 6f66d295b..1b6d93249 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -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', }, },