♻️ refactor(calendar): integrate agenda as view type and improve DayView layout

- Add AgendaView component as integrated calendar view type
- Add 'agenda' option to view type selector in toolbar
- Remove separate /agenda route (now accessible via view switcher)
- Redesign DayView for better wide-screen display:
  - Center content with max-width constraints
  - Narrower events (max-width 400px)
  - Better positioned time labels
- Fix overflow handling in all calendar views
This commit is contained in:
Till-JS 2025-12-12 21:51:05 +01:00
parent b7057ed8fc
commit 502ba0c6b9
10 changed files with 505 additions and 352 deletions

View file

@ -0,0 +1,303 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { format, parseISO, isToday, isTomorrow, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onEventClick?: (event: CalendarEvent) => void;
}
let { onEventClick }: Props = $props();
// Group events by date
let groupedEvents = $derived.by(() => {
const currentEvents = eventsStore.events ?? [];
if (!Array.isArray(currentEvents)) return [];
// Filter events that start from current date onwards
const startDate = startOfDay(viewStore.currentDate);
const groups: Map<string, CalendarEvent[]> = new Map();
for (const event of currentEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
// Skip events before the start date
if (start < startDate) continue;
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push(event);
}
// Sort groups by date
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, events]) => ({
date: parseISO(dateKey),
events: events.sort((a, b) => {
const aStart = typeof a.startTime === 'string' ? parseISO(a.startTime) : a.startTime;
const bStart = typeof b.startTime === 'string' ? parseISO(b.startTime) : b.startTime;
return aStart.getTime() - bStart.getTime();
}),
}));
});
function formatDateHeader(date: Date) {
if (isToday(date)) {
return 'Heute';
}
if (isTomorrow(date)) {
return 'Morgen';
}
return format(date, 'EEEE, d. MMMM', { locale: de });
}
function handleEventClick(event: CalendarEvent) {
if (onEventClick) {
onEventClick(event);
}
}
</script>
<div class="agenda-view">
{#if groupedEvents.length === 0}
<div class="empty-state">
<svg class="empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p>Keine Termine in diesem Zeitraum</p>
</div>
{:else}
<div class="event-list">
{#each groupedEvents as group}
<div class="date-group">
<h2 class="date-header" class:today={isToday(group.date)}>
{formatDateHeader(group.date)}
</h2>
<div class="events-for-date">
{#each group.events as event}
<button class="event-item" onclick={() => handleEventClick(event)}>
<div
class="color-bar"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
></div>
<div class="event-content">
<div class="event-time">
{#if event.isAllDay}
Ganztägig
{:else}
{format(
typeof event.startTime === 'string'
? parseISO(event.startTime)
: event.startTime,
'HH:mm'
)} - {format(
typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime,
'HH:mm'
)}
{/if}
</div>
<div class="event-title">{event.title}</div>
{#if event.location}
<div class="event-location">
<svg
class="location-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{event.location}
</div>
{/if}
</div>
<svg class="chevron-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.agenda-view {
padding: 1rem;
max-width: 700px;
margin: 0 auto;
height: 100%;
overflow-y: auto;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
}
.empty-icon {
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state p {
font-size: 1rem;
margin: 0;
}
.event-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.date-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date-header {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
padding-left: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.date-header.today {
color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary) / 0.3);
}
.events-for-date {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.event-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md);
text-align: left;
width: 100%;
background: hsl(var(--color-surface));
transition:
transform 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
}
.event-item:hover {
transform: translateX(4px);
border-color: hsl(var(--color-border-hover, var(--color-border)));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.color-bar {
width: 4px;
align-self: stretch;
border-radius: 2px;
flex-shrink: 0;
min-height: 2.5rem;
}
.event-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.event-time {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-weight: 500;
}
.event-title {
font-weight: 500;
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-location {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.location-icon {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.chevron-icon {
width: 1rem;
height: 1rem;
color: hsl(var(--color-muted-foreground));
opacity: 0.5;
flex-shrink: 0;
}
.event-item:hover .chevron-icon {
opacity: 1;
}
</style>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { PillToolbar, PillToolbarDivider } from '@manacore/shared-ui';
import { slide } from 'svelte/transition';
import CalendarToolbarContent from './CalendarToolbarContent.svelte';
interface Props {
@ -11,7 +11,7 @@
let {
isSidebarMode = false,
isCollapsed = false,
isCollapsed = true, // Default to collapsed
onModeChange,
onCollapsedChange,
}: Props = $props();
@ -27,28 +27,56 @@
function expandToolbar() {
onCollapsedChange?.(false);
}
function toggleToolbar() {
onCollapsedChange?.(!isCollapsed);
}
</script>
{#if !isCollapsed}
<PillToolbar position="bottom" bottomOffset={isSidebarMode ? '0px' : '70px'}>
<CalendarToolbarContent />
<!-- Toolbar Container - positioned next to InputBar -->
<div class="toolbar-container" class:sidebar-mode={isSidebarMode}>
<!-- FAB Button (always visible) -->
<button
onclick={toggleToolbar}
class="toolbar-fab glass-pill"
class:active={!isCollapsed}
title={isCollapsed ? 'Kalender-Optionen' : 'Schließen'}
>
<svg class="fab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isCollapsed}
<!-- Settings/sliders icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
{:else}
<!-- Close icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
{/if}
</svg>
</button>
<PillToolbarDivider />
<!-- Expanded Toolbar Panel (opens above) -->
{#if !isCollapsed}
<div class="toolbar-panel glass-panel" transition:slide={{ duration: 200 }}>
<CalendarToolbarContent />
<!-- Layout Control -->
<div class="segmented-control glass-pill">
<button onclick={collapseToolbar} class="segment-btn" title="Toolbar minimieren">
<svg class="segment-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<div class="segment-divider"></div>
<div class="toolbar-divider"></div>
<!-- Layout Control -->
<button
onclick={toggleSidebarMode}
class="segment-btn"
class="layout-btn"
title={isSidebarMode ? 'Zur Bottom-Navigation' : 'Zur Sidebar-Navigation'}
>
<svg class="segment-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="layout-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isSidebarMode}
<!-- Bottom bar layout icon -->
<path
@ -69,42 +97,41 @@
</svg>
</button>
</div>
</PillToolbar>
{/if}
<!-- FAB for collapsed state -->
{#if isCollapsed}
<button
onclick={expandToolbar}
class="toolbar-fab glass-pill"
class:sidebar-mode={isSidebarMode}
title="Toolbar anzeigen"
>
<svg class="fab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
</svg>
</button>
{/if}
{/if}
</div>
<style>
.segmented-control {
/* Container positioned next to InputBar */
.toolbar-container {
position: fixed;
bottom: calc(70px + env(safe-area-inset-bottom, 0px));
right: calc(50% - 350px - 60px); /* Right of InputBar (max-width 700px / 2 + gap) */
z-index: 91; /* Above InputBar (90) */
display: flex;
align-items: center;
padding: 0.125rem;
gap: 0;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
pointer-events: none;
}
.toolbar-container.sidebar-mode {
bottom: calc(0px + env(safe-area-inset-bottom, 0px));
}
/* Responsive positioning */
@media (max-width: 900px) {
.toolbar-container {
right: 1rem;
}
}
/* Glass styling */
.glass-pill {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-radius: 9999px;
}
@ -113,11 +140,85 @@
border: 1px solid rgba(255, 255, 255, 0.15);
}
.segment-btn {
.glass-panel {
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
border-radius: 1rem;
}
:global(.dark) .glass-panel {
background: rgba(30, 30, 30, 0.92);
border: 1px solid rgba(255, 255, 255, 0.15);
}
/* FAB Button */
.toolbar-fab {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
width: 2.75rem;
height: 2.75rem;
cursor: pointer;
border: none;
transition: all 0.2s ease;
pointer-events: auto;
}
.toolbar-fab:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.toolbar-fab.active {
background: #3b82f6;
border-color: #3b82f6;
}
.toolbar-fab.active .fab-icon {
color: white;
}
.fab-icon {
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--color-muted-foreground));
transition: color 0.2s ease;
}
.toolbar-fab:hover .fab-icon {
color: hsl(var(--color-foreground));
}
/* Toolbar Panel (opens above FAB) */
.toolbar-panel {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
pointer-events: auto;
white-space: nowrap;
}
.toolbar-divider {
width: 1px;
height: 1.5rem;
background: rgba(0, 0, 0, 0.1);
margin: 0 0.25rem;
}
:global(.dark) .toolbar-divider {
background: rgba(255, 255, 255, 0.15);
}
/* Layout toggle button */
.layout-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
@ -126,64 +227,17 @@
transition: all 0.15s ease;
}
.segment-btn:hover {
.layout-btn:hover {
background: rgba(0, 0, 0, 0.05);
color: hsl(var(--color-foreground));
}
:global(.dark) .segment-btn:hover {
:global(.dark) .layout-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.segment-divider {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.1);
margin: 0 0.125rem;
}
:global(.dark) .segment-divider {
background: rgba(255, 255, 255, 0.15);
}
.segment-icon {
.layout-icon {
width: 1rem;
height: 1rem;
}
/* FAB for collapsed state - positioned right, above PillNav FAB */
.toolbar-fab {
position: fixed;
bottom: calc(56px + env(safe-area-inset-bottom, 0px)); /* Above PillNav FAB */
right: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
cursor: pointer;
border: none;
border-radius: 9999px 0 0 9999px;
transition: all 0.3s ease;
}
.toolbar-fab.sidebar-mode {
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
.toolbar-fab:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.fab-icon {
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--color-muted-foreground));
}
.toolbar-fab:hover .fab-icon {
color: hsl(var(--color-foreground));
}
</style>

View file

@ -37,6 +37,7 @@
'14day',
'month',
'year',
'agenda',
];
// Convert to ViewOptions for PillViewSwitcher

View file

@ -914,12 +914,17 @@
.day-view {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
overflow: hidden;
}
.all-day-section {
display: flex;
border-bottom: 1px solid hsl(var(--color-border));
padding: 0.5rem 0;
width: 100%;
max-width: 800px;
}
.all-day-label {
@ -960,13 +965,14 @@
.all-day-block-event {
position: absolute;
top: 0;
left: 4px;
right: 4px;
left: 8px;
width: calc(100% - 16px);
max-width: 400px;
bottom: 0;
padding: 8px 12px;
color: white;
border: none;
border-radius: var(--radius-sm);
border-radius: var(--radius-md);
text-align: left;
cursor: pointer;
z-index: 0;
@ -1002,16 +1008,19 @@
.time-grid {
flex: 1;
display: flex;
width: 100%;
max-width: 800px;
overflow-y: auto;
}
.time-column {
width: var(--time-column-width);
width: 50px;
flex-shrink: 0;
}
.time-label {
height: var(--hour-height);
padding-right: 0.5rem;
padding-right: 0.75rem;
text-align: right;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
@ -1020,9 +1029,9 @@
}
.time-gutter {
width: var(--time-column-width);
width: 50px;
flex-shrink: 0;
padding-right: 0.5rem;
padding-right: 0.75rem;
text-align: right;
}
@ -1030,6 +1039,7 @@
flex: 1;
position: relative;
border-left: 1px solid hsl(var(--color-border));
max-width: 600px;
}
.day-column.today {
@ -1044,8 +1054,9 @@
.event-card {
position: absolute;
left: 4px;
right: 4px;
left: 8px;
width: calc(100% - 16px);
max-width: 400px;
color: white;
border: none;
text-align: left;
@ -1054,11 +1065,12 @@
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 8px;
border-radius: var(--radius-sm);
padding: 6px 10px;
border-radius: var(--radius-md);
overflow: hidden;
touch-action: none;
user-select: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition:
box-shadow 150ms ease,
opacity 150ms ease;

View file

@ -338,12 +338,15 @@
.month-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.weekday-headers {
display: grid;
grid-template-columns: repeat(var(--column-count, 7), 1fr);
border-bottom: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
}
.weekday-header {
@ -359,6 +362,7 @@
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.week-row {

View file

@ -1168,6 +1168,10 @@
.day-headers {
display: flex;
border-bottom: 1px solid hsl(var(--color-border));
position: sticky;
top: 0;
z-index: 10;
background: hsl(var(--color-background));
}
.time-gutter {

View file

@ -1181,6 +1181,10 @@
.day-headers {
display: flex;
border-bottom: 1px solid hsl(var(--color-border));
position: sticky;
top: 0;
z-index: 10;
background: hsl(var(--color-background));
}
.time-gutter {

View file

@ -143,7 +143,7 @@
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
let isToolbarCollapsed = $state(false);
let isToolbarCollapsed = $state(true); // Default to collapsed - FAB next to InputBar
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
@ -198,7 +198,6 @@
// Base navigation items for Calendar
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Kalender', icon: 'calendar' },
{ href: '/agenda', label: 'Agenda', icon: 'list' },
{ href: '/tasks', label: 'Aufgaben', icon: 'check-square' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
@ -411,13 +410,7 @@
appIcon="calendar"
primaryColor="#3b82f6"
autoFocus={true}
bottomOffset={showCalendarToolbar
? isSidebarMode
? '0px'
: '130px'
: isSidebarMode
? '0px'
: '70px'}
bottomOffset={isSidebarMode ? '0px' : '70px'}
/>
</div>
</SplitPaneContainer>
@ -430,6 +423,10 @@
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
transition: all 300ms ease;
position: relative;
/* Space for QuickInputBar at bottom */
@ -468,6 +465,8 @@
}
.content-wrapper {
flex: 1;
min-height: 0;
max-width: 100%;
margin-left: auto;
margin-right: auto;
@ -496,5 +495,7 @@
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
</style>

View file

@ -13,6 +13,7 @@
import MonthView from '$lib/components/calendar/MonthView.svelte';
import MultiDayView from '$lib/components/calendar/MultiDayView.svelte';
import YearView from '$lib/components/calendar/YearView.svelte';
import AgendaView from '$lib/components/calendar/AgendaView.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
@ -175,6 +176,8 @@
<MonthView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === 'year'}
<YearView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === 'agenda'}
<AgendaView onEventClick={handleEventClick} />
{:else}
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{/if}
@ -201,6 +204,7 @@
display: flex;
gap: 1.5rem;
width: 100%;
height: 100%;
position: relative;
}
@ -305,10 +309,12 @@
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.calendar-main.expanded {
@ -318,6 +324,8 @@
.calendar-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
@media (max-width: 1024px) {

View file

@ -1,238 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { format, parseISO, isToday, isTomorrow, addDays, startOfDay, endOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
import { AgendaSkeleton } from '$lib/components/skeletons';
let loading = $state(true);
// Group events by date
let groupedEvents = $derived.by(() => {
// Safety check: ensure events is an array
const currentEvents = eventsStore.events ?? [];
if (!Array.isArray(currentEvents)) return [];
const groups: Map<string, typeof currentEvents> = new Map();
for (const event of currentEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push(event);
}
// Sort groups by date
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, events]) => ({
date: parseISO(dateKey),
events: events.sort((a, b) => {
const aStart = typeof a.startTime === 'string' ? parseISO(a.startTime) : a.startTime;
const bStart = typeof b.startTime === 'string' ? parseISO(b.startTime) : b.startTime;
return aStart.getTime() - bStart.getTime();
}),
}));
});
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Fetch events for next 30 days
const start = startOfDay(new Date());
const end = endOfDay(addDays(start, 30));
await eventsStore.fetchEvents(start, end);
loading = false;
});
function formatDateHeader(date: Date) {
if (isToday(date)) {
return 'Heute';
}
if (isTomorrow(date)) {
return 'Morgen';
}
return format(date, 'EEEE, d. MMMM', { locale: de });
}
function handleEventClick(eventId: string) {
// Navigate to calendar with event modal
goto(`/?event=${eventId}`);
}
</script>
<svelte:head>
<title>Agenda | Kalender</title>
</svelte:head>
<div class="agenda-page">
<header class="page-header">
<h1>Agenda</h1>
<p class="subtitle">Ihre kommenden Termine</p>
</header>
{#if loading}
<AgendaSkeleton />
{:else if groupedEvents.length === 0}
<div class="empty-state card">
<p>Keine Termine in den nächsten 30 Tagen</p>
<button class="btn btn-primary" onclick={() => goto('/event/new')}> Termin erstellen </button>
</div>
{:else}
<div class="event-list">
{#each groupedEvents as group}
<div class="date-group">
<h2 class="date-header" class:today={isToday(group.date)}>
{formatDateHeader(group.date)}
</h2>
{#each group.events as event}
<button class="event-item card" onclick={() => handleEventClick(event.id)}>
<div
class="color-bar"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
></div>
<div class="event-content">
<div class="event-time">
{#if event.isAllDay}
Ganztägig
{:else}
{format(
typeof event.startTime === 'string'
? parseISO(event.startTime)
: event.startTime,
'HH:mm'
)} -
{format(
typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime,
'HH:mm'
)}
{/if}
</div>
<div class="event-title">{event.title}</div>
{#if event.location}
<div class="event-location">{event.location}</div>
{/if}
</div>
</button>
{/each}
</div>
{/each}
</div>
{/if}
</div>
<style>
.agenda-page {
max-width: 600px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin: 0 0 0.25rem 0;
}
.subtitle {
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.empty-state {
text-align: center;
padding: 3rem;
}
.empty-state p {
color: hsl(var(--color-muted-foreground));
margin-bottom: 1rem;
}
.event-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.date-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date-header {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
padding-left: 0.5rem;
}
.date-header.today {
color: hsl(var(--color-primary));
}
.event-item {
display: flex;
gap: 1rem;
padding: 1rem;
cursor: pointer;
border: none;
text-align: left;
width: 100%;
transition: transform var(--transition-fast);
}
.event-item:hover {
transform: translateX(4px);
}
.color-bar {
width: 4px;
border-radius: 2px;
flex-shrink: 0;
}
.event-content {
flex: 1;
min-width: 0;
}
.event-time {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-bottom: 0.25rem;
}
.event-title {
font-weight: 500;
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-location {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
</style>