feat(calendar): combine calendar/tasks pill with sidebar toggle and mobile splitscreen

- Combine separate Kalender and Aufgaben pills into single tab group
- Add prependElements prop to PillNavigation for tab groups at start
- Clicking Aufgaben tab toggles todo sidebar instead of navigating
- Remove separate /tasks route (no longer needed)
- Implement 50/50 splitscreen layout on mobile (calendar top, todos bottom)
- Add proper flex container hierarchy for mobile layout
- Make TodoSidebarSection fill container on mobile with clean edges
- Add calendar and check-square icons to PillTabGroup
- Export PillTabGroupConfig type from shared-ui

🤖 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-15 02:35:35 +01:00
parent c712cc7995
commit 1395291b49
9 changed files with 420 additions and 511 deletions

View file

@ -158,6 +158,18 @@
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
}
/* Mobile: Full-bleed ohne Rundungen */
@media (max-width: 768px) {
.todo-sidebar-section {
border-radius: 0;
border: none;
border-top: 1px solid hsl(var(--color-border));
}
}
.section-header {
@ -240,12 +252,18 @@
.section-content {
padding: 0 0.5rem 0.5rem;
flex: 1;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.service-unavailable,
@ -258,6 +276,7 @@
padding: 1.5rem 1rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
flex: 1;
}
.service-unavailable {

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { browser } from '$app/environment';
import { viewStore } from '$lib/stores/view.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { getOffsetDate } from '$lib/utils/dateNavigation';
import WeekView from './WeekView.svelte';
import DayView from './DayView.svelte';
@ -300,6 +301,8 @@
<div class="carousel-page" class:inactive={!isSwiping && offsetX <= 0}>
{#if viewStore.viewType === 'day'}
<DayView date={prevDate} />
{:else if viewStore.viewType === '3day'}
<MultiDayView dayCount={3} date={prevDate} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} date={prevDate} />
{:else if viewStore.viewType === 'week'}
@ -308,6 +311,8 @@
<MultiDayView dayCount={10} date={prevDate} />
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} date={prevDate} />
{:else if viewStore.viewType === 'custom'}
<MultiDayView dayCount={settingsStore.customDayCount} date={prevDate} />
{:else if viewStore.viewType === 'month'}
<MonthView date={prevDate} />
{:else if viewStore.viewType === 'year'}
@ -321,6 +326,8 @@
<div class="carousel-page current">
{#if viewStore.viewType === 'day'}
<DayView {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '3day'}
<MultiDayView dayCount={3} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'week'}
@ -329,6 +336,8 @@
<MultiDayView dayCount={10} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'custom'}
<MultiDayView dayCount={settingsStore.customDayCount} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'month'}
<MonthView {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'year'}
@ -344,6 +353,8 @@
<div class="carousel-page" class:inactive={!isSwiping && offsetX >= 0}>
{#if viewStore.viewType === 'day'}
<DayView date={nextDate} />
{:else if viewStore.viewType === '3day'}
<MultiDayView dayCount={3} date={nextDate} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} date={nextDate} />
{:else if viewStore.viewType === 'week'}
@ -352,6 +363,8 @@
<MultiDayView dayCount={10} date={nextDate} />
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} date={nextDate} />
{:else if viewStore.viewType === 'custom'}
<MultiDayView dayCount={settingsStore.customDayCount} date={nextDate} />
{:else if viewStore.viewType === 'month'}
<MonthView date={nextDate} />
{:else if viewStore.viewType === 'year'}
@ -386,6 +399,15 @@
overflow: hidden;
}
/* Mobile: Allow vertical scrolling within calendar page */
@media (max-width: 768px) {
.carousel-page {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
}
/* Inactive pages have reduced interactivity for performance */
.carousel-page.inactive {
pointer-events: none;

View file

@ -14,6 +14,8 @@
PillDropdownItem,
QuickInputItem,
CreatePreview,
PillTabGroupConfig,
PillNavElement,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
@ -54,7 +56,9 @@
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 ViewModePillContextMenu from '$lib/components/calendar/ViewModePillContextMenu.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import type { CalendarViewType } from '@calendar/shared';
// App switcher items
const appItems = getPillAppItems('calendar');
@ -150,6 +154,15 @@
let isCollapsed = $state(false);
let isToolbarCollapsed = $state(true); // Default to collapsed - FAB next to InputBar
// Mobile detection for responsive layout
let isMobile = $state(false);
function updateMobileState() {
if (browser) {
isMobile = window.innerWidth <= 640;
}
}
// InputBar help modal state
let helpModalOpen = $state(false);
let helpModalMode = $state<'shortcuts' | 'syntax'>('shortcuts');
@ -236,10 +249,8 @@
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Base navigation items for Calendar
// Base navigation items for Calendar (without Kalender/Aufgaben - handled by tab group)
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Kalender', icon: 'calendar' },
{ href: '/tasks', label: 'Aufgaben', icon: 'check-square' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
@ -252,9 +263,103 @@
filterHiddenNavItems('calendar', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-4) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
// Active tab based on sidebar state: 'tasks' when sidebar is open, 'calendar' when closed
let activeTab = $derived(settingsStore.sidebarCollapsed ? 'calendar' : 'tasks');
// Tab group for Kalender/Aufgaben
let calendarTasksTabGroup = $derived<PillTabGroupConfig>({
type: 'tabs',
options: [
{ id: 'calendar', icon: 'calendar', label: 'Kalender', title: 'Kalender anzeigen' },
{ id: 'tasks', icon: 'check-square', label: 'Aufgaben', title: 'Aufgaben-Sidebar öffnen' },
],
value: activeTab,
onChange: handleTabChange,
});
// View switcher context menu
let viewContextMenu: ViewModePillContextMenu;
function handleViewContextMenu(x: number, y: number) {
viewContextMenu?.show(x, y);
}
// View labels for tabs (numbers for day views, letters for others)
const viewLabels: Record<CalendarViewType, string> = {
day: '1',
'3day': '3',
'5day': '5',
week: '7',
'10day': '10',
'14day': '14',
month: 'M',
year: 'Y',
agenda: 'L',
custom: '', // Will be set dynamically
};
// View titles for tooltips
const viewTitles: Record<CalendarViewType, string> = {
day: 'Tagesansicht',
'3day': '3-Tage-Ansicht',
'5day': '5-Tage-Ansicht',
week: 'Wochenansicht',
'10day': '10-Tage-Ansicht',
'14day': '14-Tage-Ansicht',
month: 'Monatsansicht',
year: 'Jahresansicht',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Get enabled views from settings
let enabledViews = $derived(settingsStore.quickViewPillViews);
// Get label for a view (dynamic for custom)
function getViewLabel(view: CalendarViewType): string {
if (view === 'custom') {
return String(settingsStore.customDayCount);
}
return viewLabels[view];
}
// View switcher tab group (only shown on calendar main page)
let viewSwitcherTabGroup = $derived<PillTabGroupConfig>({
type: 'tabs',
options: enabledViews.map((view) => ({
id: view,
label: getViewLabel(view),
title: view === 'custom' ? `${settingsStore.customDayCount}-Tage-Ansicht` : viewTitles[view],
})),
value: viewStore.viewType,
onChange: (id) => viewStore.setViewType(id as CalendarViewType),
onContextMenu: handleViewContextMenu,
});
// Prepended elements (tab groups at the start of navigation)
let prependElements = $derived<PillNavElement[]>(
showCalendarToolbar ? [calendarTasksTabGroup, viewSwitcherTabGroup] : [calendarTasksTabGroup]
);
// Handle tab change: toggle sidebar for tasks, close for calendar
function handleTabChange(tabId: string) {
// Always navigate to main calendar page if not there
if ($page.url.pathname !== '/') {
goto('/');
}
if (tabId === 'tasks') {
// Toggle behavior: if sidebar is already open, close it
settingsStore.toggleSidebar();
} else if (tabId === 'calendar') {
// Kalender-Tab: close sidebar if open
if (!settingsStore.sidebarCollapsed) {
settingsStore.toggleSidebar();
}
}
}
// Navigation shortcuts (Ctrl+1 = Kalender, Ctrl+2 = Aufgaben toggle, Ctrl+3+ = other nav items)
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
@ -264,9 +369,18 @@
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
if (num === 1) {
// Ctrl+1: Kalender (close sidebar)
event.preventDefault();
const route = navRoutes[num - 1];
handleTabChange('calendar');
} else if (num === 2) {
// Ctrl+2: Aufgaben (toggle sidebar)
event.preventDefault();
handleTabChange('tasks');
} else if (num >= 3 && num <= baseNavItems.length + 2) {
// Ctrl+3+: other nav items (offset by 2 for the tab group)
event.preventDefault();
const route = baseNavItems[num - 3]?.href;
if (route) {
goto(route);
}
@ -374,15 +488,19 @@
isToolbarCollapsed = false;
toolbarCollapsedStore.set(false);
}
// Initialize mobile state
updateMobileState();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<svelte:window onkeydown={handleKeydown} onresize={updateMobileState} />
<SplitPaneContainer>
<div class="layout-container">
<PillNavigation
items={navItems}
{prependElements}
currentPath={$page.url.pathname}
appName="Kalender"
homeRoute="/"
@ -425,7 +543,7 @@
<!-- Date strip (only on main calendar page) -->
{#if showCalendarToolbar}
{#if settingsStore.dateStripCollapsed}
<DateStripFab {isSidebarMode} isToolbarExpanded={!isToolbarCollapsed} />
<DateStripFab {isSidebarMode} isToolbarExpanded={!isToolbarCollapsed} {isMobile} />
{:else}
<DateStrip {isSidebarMode} isToolbarExpanded={!isToolbarCollapsed} />
{/if}
@ -436,6 +554,7 @@
<CalendarToolbar
{isSidebarMode}
isCollapsed={isToolbarCollapsed}
{isMobile}
onModeChange={handleToolbarModeChange}
onCollapsedChange={handleToolbarCollapsedChange}
/>
@ -467,13 +586,18 @@
onParseCreate={handleParseCreate}
createText="Erstellen"
appIcon="calendar"
bottomOffset={isSidebarMode
? '0px'
: showCalendarToolbar && !isToolbarCollapsed
? '140px'
: '70px'}
bottomOffset={isMobile
? '70px'
: isSidebarMode
? '0px'
: showCalendarToolbar && !isToolbarCollapsed
? '140px'
: '70px'}
hasFabRight={showCalendarToolbar && !isSidebarMode}
hasFabLeft={showCalendarToolbar && !isSidebarMode && settingsStore.dateStripCollapsed}
hasFabLeft={!isMobile &&
showCalendarToolbar &&
!isSidebarMode &&
settingsStore.dateStripCollapsed}
defaultOptions={calendarOptions}
selectedDefaultId={selectedDefaultCalendarId}
defaultOptionLabel="Standard-Kalender"
@ -487,6 +611,9 @@
<!-- Global Event Context Menu - rendered at top level for proper z-index -->
<EventContextMenu onEdit={handleContextMenuEdit} />
<!-- View Mode Context Menu -->
<ViewModePillContextMenu bind:this={viewContextMenu} />
<!-- InputBar Help Modal -->
<InputBarHelpModal open={helpModalOpen} onClose={handleCloseHelpModal} mode={helpModalMode} />
@ -497,6 +624,15 @@
min-height: 100vh;
}
/* Mobile: Fixed viewport, no scroll */
@media (max-width: 768px) {
.layout-container {
height: 100vh;
max-height: 100vh;
overflow: hidden;
}
}
.main-content {
transition: all 300ms ease;
position: relative;
@ -517,20 +653,36 @@
}
@media (max-width: 768px) {
/* On mobile, toolbars are at bottom, extra padding at bottom instead */
/* On mobile, fixed height layout - no page scroll */
.main-content {
padding-bottom: calc(150px + env(safe-area-inset-bottom)); /* PillNav + QuickInputBar */
height: calc(100vh - 70px); /* Full height minus bottom nav */
overflow: hidden;
padding-bottom: 0;
display: flex;
flex-direction: column;
}
.main-content.has-toolbar {
padding-bottom: calc(
200px + env(safe-area-inset-bottom)
); /* DateStrip + BottomNav + QuickInputBar */
height: calc(100vh - 70px);
padding-bottom: 0;
}
.main-content.floating-mode {
padding-top: 0; /* No top padding on mobile - everything is at bottom */
}
}
/* Mobile: Fixed height, internal scrolling only */
@media (max-width: 640px) {
.main-content {
height: calc(100vh - 70px);
overflow: hidden;
padding-bottom: 0;
}
.main-content.has-toolbar {
height: calc(100vh - 70px);
padding-bottom: 0;
}
}
.main-content.sidebar-mode {
padding-left: 180px;
}
@ -544,6 +696,17 @@
z-index: 0;
}
/* Mobile: no padding, full height */
@media (max-width: 768px) {
.content-wrapper {
padding: 0;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
@ -595,4 +758,19 @@
margin-right: auto;
}
}
/* Mobile: InputBar in its own row (above PillNav), Settings FAB stays next to InputBar */
@media (max-width: 640px) {
/* InputBar takes all available space up to the FAB */
:global(.quick-input-bar.has-fab-right .input-container) {
max-width: none;
width: 100%;
margin: 0;
}
:global(.quick-input-bar.has-fab-right) {
padding-left: 1rem;
padding-right: calc(54px + 1rem + 8px); /* FAB width + margin + gap */
}
}
</style>

View file

@ -99,8 +99,8 @@
</svelte:head>
<div class="calendar-layout">
<!-- Left Sidebar -->
<aside class="calendar-sidebar" class:collapsed={settingsStore.sidebarCollapsed}>
<!-- Desktop: Left Sidebar -->
<aside class="calendar-sidebar desktop-only" class:collapsed={settingsStore.sidebarCollapsed}>
<!-- Collapse button at top -->
<button
class="sidebar-collapse-btn"
@ -120,9 +120,9 @@
<TodoSidebarSection maxItems={5} />
</aside>
<!-- FAB when sidebar is collapsed -->
<!-- Desktop: FAB when sidebar is collapsed -->
{#if settingsStore.sidebarCollapsed}
<div class="sidebar-fab" class:pill-sidebar={$sidebarModeStore}>
<div class="sidebar-fab desktop-only" class:pill-sidebar={$sidebarModeStore}>
<button
class="fab-expand"
onclick={() => settingsStore.toggleSidebar()}
@ -151,6 +151,14 @@
</div>
</div>
<!-- Mobile: Bottom Todo Section -->
<aside
class="calendar-sidebar-mobile mobile-only"
class:collapsed={settingsStore.sidebarCollapsed}
>
<TodoSidebarSection maxItems={3} />
</aside>
<!-- Quick Event Overlay (for both create and edit) -->
{#if showQuickOverlay}
{#key overlayKey}
@ -174,10 +182,19 @@
position: relative;
}
/* Desktop only elements */
.desktop-only {
display: flex;
}
/* Mobile only elements - hidden by default */
.mobile-only {
display: none;
}
.calendar-sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 1rem;
position: relative;
@ -226,7 +243,6 @@
position: fixed;
left: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 50;
@ -290,13 +306,105 @@
flex: 1;
}
@media (max-width: 1024px) {
.calendar-sidebar {
display: none;
/* Mobile: Bottom Todo Section */
.calendar-sidebar-mobile {
width: 100%;
flex-direction: column;
background: hsl(var(--color-surface));
border-top: 1px solid hsl(var(--color-border));
padding: 0.75rem;
overflow-y: auto;
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.calendar-sidebar-mobile.collapsed {
height: 0;
flex: 0;
padding: 0;
opacity: 0;
overflow: hidden;
border: none;
}
/* Mobile Layout - 50/50 Splitscreen */
@media (max-width: 768px) {
.calendar-layout {
flex-direction: column;
gap: 0;
flex: 1;
height: 100%; /* Fill parent container */
min-height: 0;
overflow: hidden;
}
.sidebar-fab {
/* Hide desktop elements on mobile */
.desktop-only {
display: none !important;
}
/* Show mobile elements */
.mobile-only {
display: flex;
}
/* Calendar container */
.calendar-main {
border-radius: 0;
border: none;
min-height: 0;
overflow: hidden;
}
/* When todos are visible: 50/50 split */
.calendar-layout:has(.calendar-sidebar-mobile:not(.collapsed)) .calendar-main {
flex: 0 0 50%;
height: 50%;
}
/* When todos are collapsed: calendar takes full space */
.calendar-layout:has(.calendar-sidebar-mobile.collapsed) .calendar-main {
flex: 1;
height: 100%;
}
/* Calendar content must scroll internally */
.calendar-content {
height: 100%;
overflow-y: auto;
}
/* Todos section takes other half */
.calendar-sidebar-mobile {
display: flex;
flex-direction: column;
flex: 0 0 50%;
height: 50%;
max-height: none;
border-radius: 0;
margin-bottom: 0;
padding: 0;
border-top: none;
overflow: hidden;
}
/* Make TodoSidebarSection fill the container */
.calendar-sidebar-mobile > :global(*) {
flex: 1;
min-height: 0;
}
.calendar-sidebar-mobile.collapsed {
flex: 0;
height: 0;
padding: 0;
border: none;
}
}
/* Tablet: Keep desktop layout but smaller sidebar */
@media (min-width: 769px) and (max-width: 1024px) {
.calendar-sidebar {
width: 220px;
}
}
</style>

View file

@ -1,480 +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 { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import type { CalendarEvent } from '@calendar/shared';
import AgendaItem from '$lib/components/agenda/AgendaItem.svelte';
import AgendaFilters from '$lib/components/agenda/AgendaFilters.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
import { AgendaSkeleton } from '$lib/components/skeletons';
import {
format,
parseISO,
isToday,
isTomorrow,
addDays,
startOfDay,
endOfDay,
isBefore,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
import { CheckSquare, AlertTriangle, Plus } from 'lucide-svelte';
// State
let loading = $state(true);
let showEvents = $state(true);
let showTodos = $state(true);
let timeRange = $state<'7' | '30' | 'all'>('30');
let selectedTask = $state<Task | null>(null);
let showQuickAdd = $state(false);
// Combined and grouped items
type AgendaGroup = {
date: Date;
items: Array<{ type: 'event' | 'todo'; event?: CalendarEvent; todo?: Task }>;
};
let groupedItems = $derived.by(() => {
const groups = new Map<string, AgendaGroup['items']>();
const today = startOfDay(new Date());
// Add events
if (showEvents) {
const currentEvents = eventsStore.events ?? [];
if (Array.isArray(currentEvents)) {
for (const event of currentEvents) {
const start = toDate(event.startTime);
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push({ type: 'event', event });
}
}
}
// Add todos
if (showTodos) {
const currentTodos = todosStore.todos ?? [];
if (Array.isArray(currentTodos)) {
for (const todo of currentTodos) {
if (todo.isCompleted) continue; // Skip completed todos
let dateKey: string;
if (todo.dueDate) {
const dueDate =
typeof todo.dueDate === 'string' ? parseISO(todo.dueDate) : todo.dueDate;
// Group overdue todos under today
if (isBefore(startOfDay(dueDate), today)) {
dateKey = format(today, 'yyyy-MM-dd');
} else {
dateKey = format(dueDate, 'yyyy-MM-dd');
}
} else {
// Todos without due date go under today
dateKey = format(today, 'yyyy-MM-dd');
}
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push({ type: 'todo', todo });
}
}
}
// Sort groups by date and items within each group
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, items]) => ({
date: parseISO(dateKey),
items: items.sort((a, b) => {
// Todos before events
if (a.type !== b.type) return a.type === 'todo' ? -1 : 1;
// Sort events by time
if (a.type === 'event' && b.type === 'event' && a.event && b.event) {
const aStart = toDate(a.event.startTime);
const bStart = toDate(b.event.startTime);
return aStart.getTime() - bStart.getTime();
}
// Sort todos by priority
if (a.type === 'todo' && b.type === 'todo' && a.todo && b.todo) {
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
return priorityOrder[a.todo.priority] - priorityOrder[b.todo.priority];
}
return 0;
}),
}));
});
// Stats
const overdueCount = $derived(todosStore.overdueTodos.length);
const todayCount = $derived(todosStore.todaysTodos.length);
const totalActiveCount = $derived(todosStore.activeTodosCount);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Fetch data based on time range
await fetchData();
loading = false;
});
async function fetchData() {
const start = startOfDay(new Date());
const days = timeRange === '7' ? 7 : timeRange === '30' ? 30 : 90;
const end = endOfDay(addDays(start, days));
await Promise.all([
eventsStore.fetchEvents(start, end),
todosStore.fetchTodos(start, end),
todosStore.fetchTodayTodos(),
]);
}
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) {
goto(`/?event=${eventId}`);
}
function handleTodoClick(task: Task) {
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
function toggleEvents() {
showEvents = !showEvents;
}
function toggleTodos() {
showTodos = !showTodos;
}
function handleRangeChange(range: '7' | '30' | 'all') {
timeRange = range;
loading = true;
fetchData().then(() => (loading = false));
}
</script>
<svelte:head>
<title>Aufgaben | Kalender</title>
</svelte:head>
<div class="tasks-page">
<header class="page-header">
<div class="header-content">
<div class="header-icon">
<CheckSquare size={24} />
</div>
<div>
<h1>Aufgaben</h1>
<p class="subtitle">Ihre Termine und Aufgaben auf einen Blick</p>
</div>
</div>
<!-- Stats -->
<div class="stats">
{#if overdueCount > 0}
<span class="stat overdue">
<AlertTriangle size={14} />
{overdueCount} überfällig
</span>
{/if}
<span class="stat">{todayCount} heute</span>
<span class="stat">{totalActiveCount} gesamt</span>
</div>
</header>
<!-- Filters -->
<AgendaFilters
{showEvents}
{showTodos}
{timeRange}
onToggleEvents={toggleEvents}
onToggleTodos={toggleTodos}
onRangeChange={handleRangeChange}
/>
<!-- Quick Add -->
<div class="quick-add-section">
{#if showQuickAdd}
<QuickAddTodo
placeholder="Neue Aufgabe hinzufügen..."
autofocus
showButton={false}
onsubmit={() => (showQuickAdd = false)}
oncancel={() => (showQuickAdd = false)}
/>
{:else}
<button type="button" class="quick-add-button" onclick={() => (showQuickAdd = true)}>
<Plus size={16} />
<span>Neue Aufgabe</span>
</button>
{/if}
</div>
<!-- Content -->
{#if loading}
<AgendaSkeleton />
{:else if !todosStore.serviceAvailable}
<div class="error-state card">
<AlertTriangle size={24} />
<p>Todo-Service ist nicht erreichbar</p>
<p class="hint">Bitte versuchen Sie es später erneut</p>
</div>
{:else if groupedItems.length === 0}
<div class="empty-state card">
<CheckSquare size={32} />
<p>Keine Einträge gefunden</p>
<p class="hint">
{#if !showEvents && !showTodos}
Aktivieren Sie mindestens einen Filter
{:else}
Erstellen Sie eine neue Aufgabe oder ändern Sie den Zeitraum
{/if}
</p>
</div>
{:else}
<div class="item-list">
{#each groupedItems as group}
<div class="date-group">
<h2 class="date-header" class:today={isToday(group.date)}>
{formatDateHeader(group.date)}
<span class="item-count">({group.items.length})</span>
</h2>
<div class="items">
{#each group.items as item}
{#if item.type === 'event' && item.event}
<AgendaItem
type="event"
event={item.event}
onclick={() => handleEventClick(item.event!.id)}
/>
{:else if item.type === 'todo' && item.todo}
<AgendaItem
type="todo"
todo={item.todo}
onclick={() => handleTodoClick(item.todo!)}
/>
{/if}
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.tasks-page {
max-width: 700px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.header-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.subtitle {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0.25rem 0 0;
}
.stats {
display: flex;
align-items: center;
gap: 0.75rem;
}
.stat {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
padding: 0.25rem 0.5rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: var(--radius-sm);
}
.stat.overdue {
color: hsl(var(--color-danger));
background: hsl(var(--color-danger) / 0.1);
}
.quick-add-section {
margin-bottom: 0.5rem;
}
.quick-add-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem;
border-radius: var(--radius-lg);
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms ease;
}
.quick-add-button:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.item-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;
display: flex;
align-items: center;
gap: 0.5rem;
}
.date-header.today {
color: hsl(var(--color-primary));
}
.item-count {
font-weight: 400;
font-size: 0.75rem;
}
.items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.empty-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem 1.5rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
}
.empty-state :global(svg),
.error-state :global(svg) {
opacity: 0.5;
}
.error-state {
color: hsl(var(--color-danger));
}
.hint {
font-size: 0.8125rem;
opacity: 0.7;
margin: 0;
}
.card {
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
}
@media (max-width: 640px) {
.tasks-page {
padding: 1rem;
}
.page-header {
flex-direction: column;
align-items: stretch;
}
.stats {
justify-content: flex-start;
}
}
</style>

View file

@ -101,6 +101,7 @@ export type {
PillNavElement,
PillNavigationProps,
PillTabOption,
PillTabGroupConfig,
ExpandableToolbarProps,
} from './navigation';

View file

@ -200,6 +200,8 @@
showThemeToggle?: boolean;
/** Primary color for active state (CSS custom property or hex) */
primaryColor?: string;
/** Elements to prepend before nav items (tab groups, dividers, nav items) */
prependElements?: PillNavElement[];
/** Additional elements (tab groups, dividers) to show after nav items */
elements?: PillNavElement[];
/** Show logout button */
@ -269,6 +271,7 @@
showLanguageSwitcher = false,
showThemeToggle = true,
primaryColor,
prependElements = [],
elements = [],
showLogout = true,
themeVariantItems = [],
@ -495,6 +498,42 @@
</a>
{/if}
<!-- Prepended Elements (Tab Groups, Dividers, Nav Items) -->
{#each prependElements as element}
{#if isTabGroup(element)}
<PillTabGroup
options={element.options}
value={element.value}
onChange={element.onChange}
sectionLabel={element.sectionLabel}
onContextMenu={element.onContextMenu}
{isSidebarMode}
{primaryColor}
/>
{:else if isDivider(element)}
<div class="pill-divider" class:sidebar-divider={isSidebarMode}></div>
{:else if isNavItem(element)}
<a href={element.href} class="pill glass-pill" class:active={isActive(element.href)}>
{#if element.icon}
{#if phosphorIcons[element.icon]}
{@const IconComponent = phosphorIcons[element.icon]}
<IconComponent size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath(element.icon)}
/>
</svg>
{/if}
{/if}
<span class="pill-label">{element.label}</span>
</a>
{/if}
{/each}
<!-- Navigation Items -->
{#each items as item}
<a href={item.href} class="pill glass-pill" class:active={isActive(item.href)}>
@ -533,6 +572,7 @@
value={element.value}
onChange={element.onChange}
sectionLabel={element.sectionLabel}
onContextMenu={element.onContextMenu}
{isSidebarMode}
{primaryColor}
/>

View file

@ -14,6 +14,8 @@
isSidebarMode?: boolean;
/** Primary color for active state */
primaryColor?: string;
/** Called on right-click (context menu) - receives click coordinates */
onContextMenu?: (x: number, y: number) => void;
}
let {
@ -23,8 +25,16 @@
sectionLabel,
isSidebarMode = false,
primaryColor,
onContextMenu,
}: Props = $props();
function handleContextMenu(event: MouseEvent) {
if (onContextMenu) {
event.preventDefault();
onContextMenu(event.clientX, event.clientY);
}
}
// Icon SVG paths (same as PillNavigation)
const icons: Record<string, string> = {
list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
@ -38,6 +48,10 @@
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
trending: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6',
single: 'M4 6h16M4 12h16M4 18h16',
calendar:
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
'check-square':
'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4',
};
function getIconPath(name: string): string {
@ -51,7 +65,8 @@
}
</script>
<div class="pill-tab-group" class:sidebar-mode={isSidebarMode}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pill-tab-group" class:sidebar-mode={isSidebarMode} oncontextmenu={handleContextMenu}>
{#if sectionLabel && isSidebarMode}
<p class="section-label">{sectionLabel}</p>
{/if}

View file

@ -92,6 +92,8 @@ export interface PillTabGroupConfig {
onChange: (id: string) => void;
/** Optional section label (shown above in sidebar mode) */
sectionLabel?: string;
/** Called on right-click (context menu) - receives click coordinates */
onContextMenu?: (x: number, y: number) => void;
}
export interface PillDivider {
@ -137,6 +139,10 @@ export interface PillNavigationProps {
showThemeToggle?: boolean;
/** Primary color for active state */
primaryColor?: string;
/** Elements to prepend before nav items (tab groups, dividers, nav items) */
prependElements?: PillNavElement[];
/** Additional elements to show after nav items (tab groups, dividers) */
elements?: PillNavElement[];
}
export interface NavItem {