feat(calendar): integrate todo tasks into calendar views

Add todo items display in Day/Week/Month views and sidebar section.

🤖 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:53:56 +01:00 committed by Wuesteon
parent a898160423
commit f3c567f56e
7 changed files with 640 additions and 0 deletions

View file

@ -3,6 +3,8 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoRow from './TodoRow.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -405,6 +407,16 @@
</div>
{/if}
<!-- Todos section -->
{#if todosStore.serviceAvailable && todosStore.getTodosForDay(viewStore.currentDate).length > 0}
<div class="todos-section">
<div class="time-gutter"></div>
<div class="todos-content">
<TodoRow date={viewStore.currentDate} maxVisible={4} />
</div>
</div>
{/if}
<!-- Time grid -->
<div class="time-grid scrollbar-thin">
<div class="time-column">
@ -533,6 +545,16 @@
cursor: pointer;
}
/* Todos section */
.todos-section {
display: flex;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.todos-content {
flex: 1;
}
/* Block-style all-day events (displayed as full-day blocks in the grid) */
.all-day-block-event {
position: absolute;

View file

@ -3,6 +3,8 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoDayCell from './TodoDayCell.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -265,6 +267,11 @@
{format(day, 'd')}
</span>
<!-- Todos for this day -->
{#if todosStore.serviceAvailable}
<TodoDayCell date={day} maxVisible={2} />
{/if}
<div class="day-events">
{#each getEventsForDay(day) as event}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}

View file

@ -0,0 +1,121 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS } from '$lib/api/todos';
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
interface Props {
date: Date;
maxVisible?: number;
}
let { date, maxVisible = 2 }: Props = $props();
let selectedTask = $state<Task | null>(null);
let togglingIds = $state<Set<string>>(new Set());
const todosForDay = $derived(todosStore.getTodosForDay(date));
const visibleTodos = $derived(todosForDay.slice(0, maxVisible));
const overflowCount = $derived(Math.max(0, todosForDay.length - maxVisible));
async function handleToggle(task: Task, e: MouseEvent) {
e.stopPropagation();
togglingIds = new Set([...togglingIds, task.id]);
await todosStore.toggleComplete(task.id);
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
}
function handleTaskClick(task: Task) {
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
</script>
{#if todosForDay.length > 0}
<div class="todo-day-cell">
{#each visibleTodos as task (task.id)}
<button
type="button"
class="todo-cell-item"
class:completed={task.isCompleted}
style="--priority-color: {PRIORITY_COLORS[task.priority]};"
onclick={() => handleTaskClick(task)}
>
<span class="priority-dot"></span>
<span class="todo-cell-title">{task.title}</span>
</button>
{/each}
{#if overflowCount > 0}
<span class="overflow-text">+{overflowCount} Aufgaben</span>
{/if}
</div>
{/if}
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.todo-day-cell {
display: flex;
flex-direction: column;
gap: 1px;
margin-bottom: 2px;
}
.todo-cell-item {
display: flex;
align-items: center;
gap: 4px;
padding: 1px 4px;
border-radius: 3px;
border: none;
background: hsl(var(--color-muted) / 0.3);
cursor: pointer;
transition: background 150ms ease;
text-align: left;
width: 100%;
}
.todo-cell-item:hover {
background: hsl(var(--color-muted) / 0.5);
}
.todo-cell-item.completed {
opacity: 0.5;
}
.todo-cell-item.completed .todo-cell-title {
text-decoration: line-through;
}
.priority-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--priority-color);
flex-shrink: 0;
}
.todo-cell-title {
font-size: 0.625rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.overflow-text {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
padding: 0 4px;
}
</style>

View file

@ -0,0 +1,169 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS } from '$lib/api/todos';
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import { Check } from 'lucide-svelte';
interface Props {
date: Date;
maxVisible?: number;
}
let { date, maxVisible = 3 }: Props = $props();
let selectedTask = $state<Task | null>(null);
let togglingIds = $state<Set<string>>(new Set());
const todosForDay = $derived(todosStore.getTodosForDay(date));
const visibleTodos = $derived(todosForDay.slice(0, maxVisible));
const overflowCount = $derived(Math.max(0, todosForDay.length - maxVisible));
async function handleToggle(task: Task) {
togglingIds = new Set([...togglingIds, task.id]);
await todosStore.toggleComplete(task.id);
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
}
function handleTaskClick(task: Task, e: MouseEvent) {
// Don't open modal if clicking checkbox
if ((e.target as HTMLElement).closest('.todo-checkbox')) return;
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
function handleShowAll() {
// Show first todo's modal, or navigate to tasks page
if (todosForDay.length > 0) {
selectedTask = todosForDay[0];
}
}
</script>
{#if todosForDay.length > 0}
<div class="todo-row">
<span class="todo-row-label">Aufgaben:</span>
<div class="todo-pills">
{#each visibleTodos as task (task.id)}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<button
type="button"
class="todo-pill"
class:completed={task.isCompleted}
style="--priority-color: {PRIORITY_COLORS[task.priority]};"
onclick={(e) => handleTaskClick(task, e)}
>
<TodoCheckbox
checked={task.isCompleted}
loading={togglingIds.has(task.id)}
size="sm"
onchange={() => handleToggle(task)}
/>
<span class="todo-pill-title">{task.title}</span>
</button>
{/each}
{#if overflowCount > 0}
<button type="button" class="overflow-badge" onclick={handleShowAll}>
+{overflowCount} mehr
</button>
{/if}
</div>
</div>
{/if}
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.todo-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: hsl(var(--color-muted) / 0.2);
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.todo-row-label {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.todo-pills {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.todo-pills::-webkit-scrollbar {
display: none;
}
.todo-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
border: none;
background: hsl(var(--color-surface));
border-left: 2px solid var(--priority-color);
cursor: pointer;
transition: all 150ms ease;
flex-shrink: 0;
max-width: 150px;
}
.todo-pill:hover {
background: hsl(var(--color-muted) / 0.5);
}
.todo-pill.completed {
opacity: 0.6;
}
.todo-pill.completed .todo-pill-title {
text-decoration: line-through;
}
.todo-pill-title {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overflow-badge {
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
border: none;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
transition: background 150ms ease;
}
.overflow-badge:hover {
background: hsl(var(--color-primary) / 0.2);
}
</style>

View file

@ -0,0 +1,292 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import TodoItem from '$lib/components/todo/TodoItem.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
import { ChevronDown, ChevronRight, Plus, CheckSquare, AlertTriangle } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
interface Props {
maxItems?: number;
}
let { maxItems = 5 }: Props = $props();
let isExpanded = $state(true);
let showQuickAdd = $state(false);
let selectedTask = $state<Task | null>(null);
// Derived: combined overdue + today todos
const displayTodos = $derived(todosStore.getSidebarTodos(maxItems));
const overdueCount = $derived(todosStore.overdueTodos.length);
const totalActiveCount = $derived(todosStore.activeTodosCount);
onMount(async () => {
// Fetch todos on mount
await todosStore.fetchTodayTodos();
await todosStore.fetchUpcomingTodos();
});
function toggleExpanded() {
isExpanded = !isExpanded;
}
function handleAddClick(e: MouseEvent) {
e.stopPropagation();
showQuickAdd = true;
}
function handleTaskClick(task: Task) {
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
function handleQuickAddSubmit() {
// Keep quick add open for successive adds
}
function handleQuickAddCancel() {
showQuickAdd = false;
}
function goToAllTasks() {
goto('/tasks');
}
</script>
<div class="todo-sidebar-section">
<!-- Header -->
<button type="button" class="section-header" onclick={toggleExpanded}>
<div class="header-left">
{#if isExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
<CheckSquare size={16} class="section-icon" />
<span class="section-title">Aufgaben</span>
{#if totalActiveCount > 0}
<span class="count-badge">{totalActiveCount}</span>
{/if}
{#if overdueCount > 0}
<span class="overdue-badge" title="{overdueCount} überfällig">
<AlertTriangle size={12} />
</span>
{/if}
</div>
<button
type="button"
class="add-button"
onclick={handleAddClick}
aria-label="Aufgabe hinzufügen"
>
<Plus size={16} />
</button>
</button>
<!-- Content -->
{#if isExpanded}
<div class="section-content">
{#if !todosStore.serviceAvailable}
<div class="service-unavailable">
<AlertTriangle size={16} />
<span>Todo-Service nicht erreichbar</span>
</div>
{:else if todosStore.loading}
<div class="loading">
<div class="loading-spinner"></div>
<span>Laden...</span>
</div>
{:else if displayTodos.length === 0}
<div class="empty-state">
<CheckSquare size={20} />
<span>Keine offenen Aufgaben</span>
</div>
{:else}
<div class="todo-list">
{#each displayTodos as task (task.id)}
<TodoItem
{task}
variant="compact"
showProject={false}
onclick={() => handleTaskClick(task)}
/>
{/each}
</div>
{#if totalActiveCount > maxItems}
<button type="button" class="show-all-button" onclick={goToAllTasks}>
Alle {totalActiveCount} anzeigen
</button>
{/if}
{/if}
<!-- Quick Add -->
{#if showQuickAdd}
<div class="quick-add-wrapper">
<QuickAddTodo
placeholder="Neue Aufgabe..."
autofocus
showButton={false}
onsubmit={handleQuickAddSubmit}
oncancel={handleQuickAddCancel}
/>
</div>
{/if}
</div>
{/if}
</div>
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.todo-sidebar-section {
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: transparent;
cursor: pointer;
transition: background 150ms ease;
}
.section-header:hover {
background: hsl(var(--color-muted) / 0.3);
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--color-foreground));
}
.header-left :global(svg) {
color: hsl(var(--color-muted-foreground));
}
.header-left :global(.section-icon) {
color: hsl(var(--color-primary));
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
}
.count-badge {
font-size: 0.6875rem;
font-weight: 600;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
padding: 1px 6px;
border-radius: 9999px;
}
.overdue-badge {
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--color-danger));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 150ms ease;
}
.add-button:hover {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
.section-content {
padding: 0 0.5rem 0.5rem;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.service-unavailable,
.loading,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
}
.service-unavailable {
color: hsl(var(--color-danger));
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.show-all-button {
width: 100%;
padding: 0.5rem;
margin-top: 0.5rem;
border: none;
background: transparent;
color: hsl(var(--color-primary));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border-radius: var(--radius-md);
transition: background 150ms ease;
}
.show-all-button:hover {
background: hsl(var(--color-primary) / 0.1);
}
.quick-add-wrapper {
margin-top: 0.5rem;
padding: 0 0.25rem;
}
</style>

View file

@ -3,6 +3,8 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoRow from './TodoRow.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -499,6 +501,18 @@
</div>
{/if}
<!-- Todos row (shown per day, below all-day events) -->
{#if todosStore.serviceAvailable}
<div class="todos-row">
<div class="time-gutter"></div>
{#each days as day}
<div class="todos-cell">
<TodoRow date={day} maxVisible={2} />
</div>
{/each}
</div>
{/if}
<!-- Day headers -->
<div class="day-headers">
<div class="time-gutter"></div>
@ -651,6 +665,18 @@
cursor: pointer;
}
/* Todos row */
.todos-row {
display: flex;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.todos-cell {
flex: 1;
border-left: 1px solid hsl(var(--color-border));
min-height: 0;
}
/* Block-style all-day events (displayed as full-day blocks in the grid) */
.all-day-block-event {
position: absolute;

View file

@ -16,6 +16,7 @@
import YearView from '$lib/components/calendar/YearView.svelte';
import MiniCalendar from '$lib/components/calendar/MiniCalendar.svelte';
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import EventDetailModal from '$lib/components/event/EventDetailModal.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
@ -130,6 +131,8 @@
<MiniCalendar selectedDate={viewStore.currentDate} onDateSelect={handleDateSelect} />
<CalendarSidebar />
<TodoSidebarSection maxItems={5} />
</aside>
<!-- FAB when sidebar is collapsed -->