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:
Till-JS 2025-12-14 20:49:08 +01:00
parent 4b6a4c73ae
commit cdc3cd3ec8
12 changed files with 995 additions and 85 deletions

View 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;

View file

@ -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>

View file

@ -6,6 +6,8 @@
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.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 { import {
useVisibleHours, useVisibleHours,
useCurrentTimeIndicator, useCurrentTimeIndicator,
@ -131,6 +133,28 @@
let blockAllDayEvents = $derived(allDayEvents.filter((e) => getEventDisplayMode(e) === 'block')); 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 // Drag & Drop State
// ============================================================================ // ============================================================================
@ -729,8 +753,8 @@
</script> </script>
<div class="day-view"> <div class="day-view">
<!-- Header-style all-day events --> <!-- Header-style all-day events and birthdays -->
{#if headerAllDayEvents.length > 0} {#if headerAllDayEvents.length > 0 || birthdays.length > 0}
<div class="all-day-section"> <div class="all-day-section">
<div class="time-gutter"> <div class="time-gutter">
<span class="all-day-label">Ganztägig</span> <span class="all-day-label">Ganztägig</span>
@ -747,6 +771,18 @@
{event.title} {event.title}
</button> </button>
{/each} {/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>
</div> </div>
{/if} {/if}
@ -909,6 +945,15 @@
<!-- Event Context Menu --> <!-- Event Context Menu -->
<EventContextMenu onEdit={handleContextMenuEdit} /> <EventContextMenu onEdit={handleContextMenuEdit} />
<!-- Birthday Popover -->
{#if selectedBirthday}
<BirthdayPopover
birthday={selectedBirthday}
position={birthdayPopoverPosition}
onClose={closeBirthdayPopover}
/>
{/if}
<style> <style>
.day-view { .day-view {
display: flex; display: flex;
@ -1238,4 +1283,18 @@
opacity: 1; opacity: 1;
height: 5px; 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> </style>

View file

@ -5,8 +5,10 @@
import { settingsStore } from '$lib/stores/settings.svelte'; import { settingsStore } from '$lib/stores/settings.svelte';
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
import { todosStore } from '$lib/stores/todos.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 { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import TodoDayCell from './TodoDayCell.svelte'; import TodoDayCell from './TodoDayCell.svelte';
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { import {
format, format,
@ -259,6 +261,27 @@
if (eventsStore.isDraftEvent(event.id)) return; if (eventsStore.isDraftEvent(event.id)) return;
eventContextMenuStore.show(event, e.clientX, e.clientY); 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> </script>
<div class="month-view" style="--column-count: {columnCount}" bind:this={monthViewRef}> <div class="month-view" style="--column-count: {columnCount}" bind:this={monthViewRef}>
@ -338,6 +361,23 @@
</div> </div>
{/each} {/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} {#if getAllEventsForDay(day).length > 3}
<button class="more-events" onclick={(e) => handleMoreClick(day, e)}> <button class="more-events" onclick={(e) => handleMoreClick(day, e)}>
{$_('views.moreEvents', { {$_('views.moreEvents', {
@ -353,6 +393,15 @@
</div> </div>
</div> </div>
<!-- Birthday Popover -->
{#if selectedBirthday}
<BirthdayPopover
birthday={selectedBirthday}
position={birthdayPopoverPosition}
onClose={closeBirthdayPopover}
/>
{/if}
<style> <style>
.month-view { .month-view {
display: flex; display: flex;
@ -537,4 +586,19 @@
outline: 2px dashed hsl(var(--color-primary)); outline: 2px dashed hsl(var(--color-primary));
outline-offset: -2px; 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> </style>

View file

@ -5,7 +5,9 @@
import { settingsStore } from '$lib/stores/settings.svelte'; import { settingsStore } from '$lib/stores/settings.svelte';
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.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 { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
import { import {
useVisibleHours, useVisibleHours,
useCurrentTimeIndicator, useCurrentTimeIndicator,
@ -118,6 +120,33 @@
// Reference to the days container for position calculations // Reference to the days container for position calculations
let daysContainerEl: HTMLDivElement; 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) { function getEventsForDay(day: Date) {
// Filter by visible calendars first // Filter by visible calendars first
const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id));
@ -888,8 +917,8 @@
{/each} {/each}
</div> </div>
<!-- All-day events row (only shown when there are header-mode all-day events) --> <!-- All-day events row (shown when there are header-mode all-day events or birthdays) -->
{#if hasAnyHeaderAllDayEvents} {#if hasAnyHeaderAllDayEvents || hasAnyBirthdays}
<div class="all-day-row"> <div class="all-day-row">
<div class="time-gutter"> <div class="time-gutter">
{#if settingsStore.showWeekNumbers} {#if settingsStore.showWeekNumbers}
@ -909,6 +938,18 @@
{event.title} {event.title}
</button> </button>
{/each} {/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> </div>
{/each} {/each}
</div> </div>
@ -1107,6 +1148,15 @@
<EventContextMenu onEdit={handleContextMenuEdit} /> <EventContextMenu onEdit={handleContextMenuEdit} />
<!-- Birthday Popover -->
{#if selectedBirthday}
<BirthdayPopover
birthday={selectedBirthday}
position={birthdayPopoverPosition}
onClose={closeBirthdayPopover}
/>
{/if}
<style> <style>
.week-view { .week-view {
display: flex; display: flex;
@ -1165,6 +1215,20 @@
filter: grayscale(0.3); 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) */ /* Block-style all-day events (displayed as full-day blocks in the grid) */
.all-day-block-event { .all-day-block-event {
position: absolute; position: absolute;

View 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;
},
};

View file

@ -35,6 +35,10 @@ export interface CalendarAppSettings {
dateStripCompact: boolean; // Use compact/smaller DateStrip dateStripCompact: boolean; // Use compact/smaller DateStrip
dateStripShowWeekNumbers: boolean; // Show week numbers at start of week 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 // UI settings
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
@ -61,6 +65,9 @@ const DEFAULT_SETTINGS: CalendarAppSettings = {
dateStripShowMonthDividers: true, dateStripShowMonthDividers: true,
dateStripCompact: false, dateStripCompact: false,
dateStripShowWeekNumbers: false, dateStripShowWeekNumbers: false,
// Birthday defaults
showBirthdays: true,
showBirthdayAge: true,
// UI defaults // UI defaults
sidebarCollapsed: false, sidebarCollapsed: false,
defaultEventDuration: 60, defaultEventDuration: 60,
@ -182,6 +189,13 @@ export const settingsStore = {
get dateStripShowWeekNumbers() { get dateStripShowWeekNumbers() {
return settings.dateStripShowWeekNumbers; return settings.dateStripShowWeekNumbers;
}, },
// Birthday settings
get showBirthdays() {
return settings.showBirthdays;
},
get showBirthdayAge() {
return settings.showBirthdayAge;
},
get defaultEventDuration() { get defaultEventDuration() {
return settings.defaultEventDuration; return settings.defaultEventDuration;
}, },

View file

@ -23,6 +23,8 @@
import { eventsStore } from '$lib/stores/events.svelte'; import { eventsStore } from '$lib/stores/events.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte'; import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { settingsStore } from '$lib/stores/settings.svelte'; import { settingsStore } from '$lib/stores/settings.svelte';
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
import { browser } from '$app/environment';
import { import {
THEME_DEFINITIONS, THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS, DEFAULT_THEME_VARIANTS,
@ -282,6 +284,13 @@
goto(`/?event=${event.id}`); goto(`/?event=${event.id}`);
} }
// Reactive effect: load birthdays when setting is enabled
$effect(() => {
if (browser && settingsStore.showBirthdays && authStore.isAuthenticated) {
birthdaysStore.fetchBirthdays();
}
});
onMount(async () => { onMount(async () => {
// Redirect to login if not authenticated // Redirect to login if not authenticated
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
@ -300,6 +309,8 @@
await eventTagsStore.fetchTags(); await eventTagsStore.fetchTags();
await userSettings.load(); 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 // Redirect to start page if on root and a custom start page is set
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') { if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {

View file

@ -7,7 +7,13 @@
import type { TimeFormat, AllDayDisplayMode } from '$lib/stores/settings.svelte'; import type { TimeFormat, AllDayDisplayMode } from '$lib/stores/settings.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte';
import { toast } from '$lib/stores/toast.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'; import type { CalendarViewType, Calendar } from '@calendar/shared';
// Calendar management state // Calendar management state
@ -144,6 +150,47 @@
{ value: 60, label: '1 Stunde' }, { value: 60, label: '1 Stunde' },
{ value: 1440, label: '1 Tag' }, { 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> </script>
<svelte:head> <svelte:head>
@ -348,15 +395,12 @@
<span class="setting-label">Standard-Ansicht</span> <span class="setting-label">Standard-Ansicht</span>
<span class="setting-description">Ansicht beim Öffnen des Kalenders</span> <span class="setting-description">Ansicht beim Öffnen des Kalenders</span>
</div> </div>
<select <FilterDropdown
class="select-input" options={viewOptions}
value={settingsStore.defaultView} value={settingsStore.defaultView}
onchange={(e) => handleViewChange(e.currentTarget.value as CalendarViewType)} onChange={(v) => handleViewChange(v as CalendarViewType)}
> placeholder="Ansicht wählen"
{#each Object.entries(viewLabels) as [value, label]} />
<option {value}>{label}</option>
{/each}
</select>
</div> </div>
<div class="setting-item"> <div class="setting-item">
@ -438,35 +482,23 @@
</div> </div>
<div class="hour-range-inputs"> <div class="hour-range-inputs">
<div class="hour-input-group"> <div class="hour-input-group">
<label for="start-hour">Von</label> <span class="hour-label">Von</span>
<select <FilterDropdown
id="start-hour" options={hourStartOptions}
class="select-input hour-select" value={String(settingsStore.dayStartHour)}
value={settingsStore.dayStartHour} onChange={(v) => settingsStore.set('dayStartHour', Number(v))}
onchange={(e) => settingsStore.set('dayStartHour', Number(e.currentTarget.value))} placeholder="Start"
> />
{#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>
</div> </div>
<span class="hour-separator"></span> <span class="hour-separator"></span>
<div class="hour-input-group"> <div class="hour-input-group">
<label for="end-hour">Bis</label> <span class="hour-label">Bis</span>
<select <FilterDropdown
id="end-hour" options={hourEndOptions}
class="select-input hour-select" value={String(settingsStore.dayEndHour)}
value={settingsStore.dayEndHour} onChange={(v) => settingsStore.set('dayEndHour', Number(v))}
onchange={(e) => settingsStore.set('dayEndHour', Number(e.currentTarget.value))} placeholder="Ende"
> />
{#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>
</div> </div>
</div> </div>
</div> </div>
@ -517,19 +549,12 @@
<span class="setting-label">Standard-Dauer</span> <span class="setting-label">Standard-Dauer</span>
<span class="setting-description">Voreingestellte Dauer für neue Termine</span> <span class="setting-description">Voreingestellte Dauer für neue Termine</span>
</div> </div>
<select <FilterDropdown
class="select-input" options={durationDropdownOptions}
value={settingsStore.defaultEventDuration} value={String(settingsStore.defaultEventDuration)}
onchange={(e) => handleEventDurationChange(Number(e.currentTarget.value))} onChange={(v) => handleEventDurationChange(Number(v))}
> placeholder="Dauer wählen"
{#each durationOptions as duration} />
<option value={duration}>
{duration >= 60
? `${duration / 60} Stunde${duration > 60 ? 'n' : ''}`
: `${duration} Minuten`}
</option>
{/each}
</select>
</div> </div>
<div class="setting-item"> <div class="setting-item">
@ -537,20 +562,68 @@
<span class="setting-label">Standard-Erinnerung</span> <span class="setting-label">Standard-Erinnerung</span>
<span class="setting-description">Voreingestellte Erinnerung für neue Termine</span> <span class="setting-description">Voreingestellte Erinnerung für neue Termine</span>
</div> </div>
<select <FilterDropdown
class="select-input" options={reminderDropdownOptions}
value={settingsStore.defaultReminder} value={String(settingsStore.defaultReminder)}
onchange={(e) => handleReminderChange(Number(e.currentTarget.value))} onChange={(v) => handleReminderChange(Number(v))}
> placeholder="Erinnerung wählen"
{#each reminderOptions as option} />
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div> </div>
</div> </div>
</SettingsCard> </SettingsCard>
</SettingsSection> </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 --> <!-- Konto -->
<SettingsSection title="Konto"> <SettingsSection title="Konto">
{#snippet icon()} {#snippet icon()}
@ -643,24 +716,6 @@
color: hsl(var(--color-muted-foreground)); 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 */
.button-group { .button-group {
display: flex; display: flex;
@ -745,15 +800,11 @@
gap: 0.25rem; gap: 0.25rem;
} }
.hour-input-group label { .hour-label {
font-size: 0.75rem; font-size: 0.75rem;
color: hsl(var(--color-muted-foreground)); color: hsl(var(--color-muted-foreground));
} }
.hour-select {
width: 100px;
}
.hour-separator { .hour-separator {
font-size: 1.25rem; font-size: 1.25rem;
color: hsl(var(--color-muted-foreground)); color: hsl(var(--color-muted-foreground));
@ -1015,4 +1066,11 @@
padding: 1.5rem; padding: 1.5rem;
color: hsl(var(--color-muted-foreground)); 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> </style>

View file

@ -234,6 +234,16 @@ export class ContactController {
return { contacts, total }; 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') @Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const contact = await this.contactService.findById(id, user.userId); const contact = await this.contactService.findById(id, user.userId);

View file

@ -1,10 +1,19 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common'; 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_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection'; import { Database } from '../db/connection';
import { contacts } from '../db/schema'; import { contacts } from '../db/schema';
import type { Contact, NewContact } 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 { export interface ContactFilters {
search?: string; search?: string;
isFavorite?: boolean; isFavorite?: boolean;
@ -148,4 +157,34 @@ export class ContactService {
return Number(result[0]?.count || 0); 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 || '',
}));
}
} }

View file

@ -428,6 +428,9 @@ const APP_CONFIGS = [
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
PUBLIC_TODO_BACKEND_URL: (env) => PUBLIC_TODO_BACKEND_URL: (env) =>
env.TODO_BACKEND_URL || `http://localhost:${env.TODO_BACKEND_PORT || '3018'}`, 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',
}, },
}, },