feat(calendar): add birthday calendar and collapsible date strip

- Add virtual birthday calendar to calendars store
- Create DateStripFab component for collapsed date strip mode
- Add dateStripCollapsed setting support in layout
- Adjust InputBar positioning for FAB visibility

🤖 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 21:29:58 +01:00
parent 0badca21ae
commit cc28cc739e
3 changed files with 197 additions and 1 deletions

View file

@ -0,0 +1,113 @@
<script lang="ts">
import { settingsStore } from '$lib/stores/settings.svelte';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import DateStripContextMenu from './DateStripContextMenu.svelte';
interface Props {
isSidebarMode?: boolean;
isToolbarExpanded?: boolean;
}
let { isSidebarMode = false, isToolbarExpanded = false }: Props = $props();
let contextMenu: DateStripContextMenu;
function handleClick() {
settingsStore.set('dateStripCollapsed', false);
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu?.show(e.clientX, e.clientY);
}
// Format current date for FAB display: "Dez 14"
let fabLabel = $derived(format(new Date(), 'MMM d', { locale: de }));
</script>
<div
class="datestrip-fab-container"
class:sidebar-mode={isSidebarMode}
class:toolbar-expanded={isToolbarExpanded}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<button
onclick={handleClick}
oncontextmenu={handleContextMenu}
class="datestrip-fab"
title="Datumsleiste erweitern (Rechtsklick für Optionen)"
>
<span class="fab-label">{fabLabel}</span>
</button>
</div>
<DateStripContextMenu bind:this={contextMenu} />
<style>
.datestrip-fab-container {
position: fixed;
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
/* Position left of InputBar: center (50%) minus half of InputBar (225px) minus gap (8px) minus fab width (54px) */
left: calc(50% - 225px - 8px - 54px);
z-index: 49;
pointer-events: none;
transition:
bottom 0.2s ease,
left 0.2s ease;
}
.datestrip-fab-container.sidebar-mode {
bottom: calc(9px + env(safe-area-inset-bottom, 0px));
/* In sidebar mode, InputBar is 700px wide, so position accordingly */
left: calc(50% - 350px - 8px - 54px);
}
.datestrip-fab-container.toolbar-expanded {
bottom: calc(140px + 9px + env(safe-area-inset-bottom, 0px));
}
.datestrip-fab-container.sidebar-mode.toolbar-expanded {
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
/* In sidebar mode, InputBar is 700px wide */
left: calc(50% - 350px - 8px - 54px);
}
@media (max-width: 900px) {
.datestrip-fab-container {
left: 1rem;
}
}
.datestrip-fab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 54px;
height: 54px;
padding: 0 0.75rem;
cursor: pointer;
border: none;
transition: all 0.2s ease;
pointer-events: auto;
background: hsl(var(--color-surface) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border));
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
border-radius: 9999px;
}
.datestrip-fab:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.15);
}
.fab-label {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
white-space: nowrap;
}
</style>

View file

@ -4,18 +4,42 @@
import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared';
import * as api from '$lib/api/calendars';
import { BIRTHDAY_CALENDAR } from '$lib/api/birthdays';
import { settingsStore } from './settings.svelte';
// State
let calendars = $state<Calendar[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
// Virtual birthday calendar (created dynamically based on settings)
const birthdayCalendar: Calendar = {
id: BIRTHDAY_CALENDAR.id,
userId: '',
name: BIRTHDAY_CALENDAR.name,
color: BIRTHDAY_CALENDAR.color,
isDefault: false,
isVisible: true, // Visibility controlled by settingsStore.showBirthdays
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Helper to safely get calendars array (Svelte 5 runes safety)
function getCalendarsArray(): Calendar[] {
const arr = calendars ?? [];
return Array.isArray(arr) ? arr : [];
}
// Derived: all calendars including virtual birthday calendar
const allCalendars = $derived.by(() => {
const userCalendars = getCalendarsArray();
// Add virtual birthday calendar if birthdays are enabled in settings
if (settingsStore.showBirthdays) {
return [...userCalendars, { ...birthdayCalendar, isVisible: true }];
}
return userCalendars;
});
// Derived: visible calendars
const visibleCalendars = $derived(getCalendarsArray().filter((c) => c.isVisible));
@ -30,6 +54,9 @@ export const calendarsStore = {
get calendars() {
return calendars;
},
get allCalendars() {
return allCalendars;
},
get visibleCalendars() {
return visibleCalendars;
},
@ -42,6 +69,9 @@ export const calendarsStore = {
get error() {
return error;
},
get birthdayCalendarId() {
return BIRTHDAY_CALENDAR.id;
},
/**
* Fetch all calendars
@ -143,7 +173,26 @@ export const calendarsStore = {
* Get calendar color by ID (with fallback)
*/
getColor(id: string) {
// Handle virtual birthday calendar
if (id === BIRTHDAY_CALENDAR.id) {
return BIRTHDAY_CALENDAR.color;
}
const calendar = getCalendarsArray().find((c) => c.id === id);
return calendar?.color || '#3b82f6';
},
/**
* Toggle birthday calendar visibility
* (This updates the settings store, not the calendar itself)
*/
toggleBirthdaysVisibility() {
settingsStore.set('showBirthdays', !settingsStore.showBirthdays);
},
/**
* Check if a calendar ID is the virtual birthday calendar
*/
isBirthdayCalendar(id: string) {
return id === BIRTHDAY_CALENDAR.id;
},
};

View file

@ -52,6 +52,7 @@
import CalendarToolbar from '$lib/components/calendar/CalendarToolbar.svelte';
import CalendarToolbarContent from '$lib/components/calendar/CalendarToolbarContent.svelte';
import DateStrip from '$lib/components/calendar/DateStrip.svelte';
import DateStripFab from '$lib/components/calendar/DateStripFab.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
@ -387,8 +388,12 @@
<!-- Date strip (only on main calendar page) -->
{#if showCalendarToolbar}
{#if settingsStore.dateStripCollapsed}
<DateStripFab {isSidebarMode} isToolbarExpanded={!isToolbarCollapsed} />
{:else}
<DateStrip {isSidebarMode} isToolbarExpanded={!isToolbarCollapsed} />
{/if}
{/if}
<!-- Calendar toolbar (only on main calendar page, not in sidebar mode) -->
{#if showCalendarToolbar && !isSidebarMode}
@ -433,6 +438,7 @@
? '140px'
: '70px'}
hasFabRight={showCalendarToolbar && !isSidebarMode}
hasFabLeft={showCalendarToolbar && !isSidebarMode && settingsStore.dateStripCollapsed}
/>
</div>
</SplitPaneContainer>
@ -517,4 +523,32 @@
flex: 1;
min-height: 0;
}
/* Adjust InputBar when FABs are visible (toolbar FAB on right, DateStripFab on left) */
/* For a centered InputBar with max-width 450px, left edge is at 50% - 225px */
/* DateStripFab is positioned at: 50% - 225px - 8px gap - 54px fab width */
/* Note: In sidebar mode, InputBar uses default 700px max-width */
:global(.quick-input-bar.has-fab-right .input-container),
:global(.quick-input-bar.has-fab-left .input-container) {
max-width: 450px;
}
/* On smaller screens (<900px), FABs move to fixed positions (left: 1rem, right: 1rem) */
@media (max-width: 900px) {
:global(.quick-input-bar.has-fab-right .input-container) {
max-width: calc(100% - 140px); /* 54px FAB + padding */
margin-left: auto;
margin-right: 0;
}
:global(.quick-input-bar.has-fab-left .input-container) {
max-width: calc(100% - 140px); /* 54px FAB + padding */
margin-left: 0;
margin-right: auto;
}
:global(.quick-input-bar.has-fab-right.has-fab-left .input-container) {
max-width: calc(100% - 200px); /* Both FABs */
margin-left: auto;
margin-right: auto;
}
}
</style>