mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20: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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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
|
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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 !== '/') {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue