feat(calendar): add agenda view components

Add AgendaFilters and AgendaItem components for enhanced agenda view.

🤖 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-10 15:55:48 +01:00
parent 3fc89f565d
commit c6b5c2e89a
2 changed files with 368 additions and 0 deletions

View file

@ -0,0 +1,151 @@
<script lang="ts">
import { Calendar, CheckSquare, Filter } from 'lucide-svelte';
interface Props {
showEvents: boolean;
showTodos: boolean;
timeRange: '7' | '30' | 'all';
onToggleEvents?: () => void;
onToggleTodos?: () => void;
onRangeChange?: (range: '7' | '30' | 'all') => void;
}
let {
showEvents = true,
showTodos = true,
timeRange = '30',
onToggleEvents,
onToggleTodos,
onRangeChange,
}: Props = $props();
const rangeOptions = [
{ value: '7' as const, label: '7 Tage' },
{ value: '30' as const, label: '30 Tage' },
{ value: 'all' as const, label: 'Alle' },
];
</script>
<div class="agenda-filters">
<div class="filter-group type-toggles">
<button
type="button"
class="filter-toggle"
class:active={showEvents}
onclick={onToggleEvents}
aria-pressed={showEvents}
>
<Calendar size={14} />
<span>Events</span>
</button>
<button
type="button"
class="filter-toggle"
class:active={showTodos}
onclick={onToggleTodos}
aria-pressed={showTodos}
>
<CheckSquare size={14} />
<span>Aufgaben</span>
</button>
</div>
<div class="filter-group">
<div class="range-selector">
<Filter size={14} />
<select
value={timeRange}
onchange={(e) =>
onRangeChange?.((e.target as HTMLSelectElement).value as '7' | '30' | 'all')}
>
{#each rangeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
</div>
<style>
.agenda-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.type-toggles {
display: flex;
gap: 0.375rem;
}
.filter-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.filter-toggle:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.filter-toggle.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.range-selector {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--color-muted-foreground));
}
.range-selector select {
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.range-selector select:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
@media (max-width: 480px) {
.agenda-filters {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.filter-group {
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,217 @@
<script lang="ts">
import type { CalendarEvent } from '@calendar/shared';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS, PRIORITY_LABELS } from '$lib/api/todos';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import PriorityBadge from '$lib/components/todo/PriorityBadge.svelte';
import { Calendar, MapPin, Clock } from 'lucide-svelte';
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
type ItemType = 'event' | 'todo';
interface Props {
type: ItemType;
event?: CalendarEvent;
todo?: Task;
onclick?: () => void;
}
let { type, event, todo, onclick }: Props = $props();
let isToggling = $state(false);
// Event helpers
const eventColor = $derived(event ? calendarsStore.getColor(event.calendarId) : undefined);
const eventTimeLabel = $derived.by(() => {
if (!event) return '';
if (event.isAllDay) return 'Ganztägig';
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
return `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`;
});
// Todo helpers
const todoTimeLabel = $derived.by(() => {
if (!todo) return '';
if (todo.dueTime) return `Fällig: ${todo.dueTime}`;
return 'Heute fällig';
});
async function handleToggleTodo() {
if (!todo) return;
isToggling = true;
await todosStore.toggleComplete(todo.id);
isToggling = false;
}
</script>
{#if type === 'event' && event}
<button type="button" class="agenda-item event" style="--item-color: {eventColor};" {onclick}>
<div class="item-indicator">
<Calendar size={14} />
</div>
<div class="item-content">
<div class="item-header">
<span class="item-time">{eventTimeLabel}</span>
</div>
<span class="item-title">{event.title}</span>
{#if event.location}
<div class="item-meta">
<MapPin size={12} />
<span>{event.location}</span>
</div>
{/if}
</div>
</button>
{:else if type === 'todo' && todo}
<div
class="agenda-item todo"
class:completed={todo.isCompleted}
style="--item-color: {PRIORITY_COLORS[todo.priority]};"
>
<div class="item-checkbox">
<TodoCheckbox
checked={todo.isCompleted}
loading={isToggling}
size="md"
onchange={handleToggleTodo}
/>
</div>
<button type="button" class="item-content" {onclick}>
<div class="item-header">
<PriorityBadge priority={todo.priority} variant="dot" size="sm" />
<span class="item-time">{todoTimeLabel}</span>
</div>
<span class="item-title">{todo.title}</span>
{#if todo.project}
<div class="item-meta">
<span class="project-tag" style="color: {todo.project.color};">
{todo.project.name}
</span>
</div>
{/if}
</button>
</div>
{/if}
<style>
.agenda-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
background: hsl(var(--color-surface));
transition: all 150ms ease;
}
.agenda-item.event {
border: none;
cursor: pointer;
text-align: left;
width: 100%;
border-left: 4px solid var(--item-color);
}
.agenda-item.event:hover {
background: hsl(var(--color-muted) / 0.5);
transform: translateX(4px);
}
.agenda-item.todo {
border-left: 3px solid var(--item-color);
}
.agenda-item.todo.completed {
opacity: 0.6;
}
.agenda-item.todo.completed .item-title {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.item-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
background: var(--item-color);
color: white;
flex-shrink: 0;
}
.item-checkbox {
flex-shrink: 0;
padding-top: 2px;
}
.item-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.todo .item-content {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
text-align: left;
}
.todo .item-content:hover .item-title {
color: hsl(var(--color-primary));
}
.item-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-time {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.item-title {
font-size: 0.9375rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 150ms ease;
}
.item-meta {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.item-meta :global(svg) {
flex-shrink: 0;
}
.project-tag {
font-size: 0.6875rem;
font-weight: 500;
background: color-mix(in srgb, currentColor 15%, transparent);
padding: 1px 6px;
border-radius: 4px;
}
</style>