feat(todo/web): add page minimize/restore tabs, inline task creation, i18n completed times

- Secondary pages can be minimized to compact tabs above the fokus track
- Minimized tabs show centered with rounded corners, click to restore
- Add inline task creation input at the bottom of every page (except completed)
- Completed page shows date + time per task (i18n: "14:32 Uhr" DE, "14:32" EN)
- To Do page shows recently completed tasks with time
- Add + button next to minimized tabs to open page picker
- PagePicker auto-scrolls into view when opened
- i18n keys added for all 5 languages (DE/EN/FR/ES/IT)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 17:19:24 +02:00
parent e449172932
commit 6d51d3eefb
7 changed files with 401 additions and 33 deletions

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { getContext } from 'svelte';
import { isToday, isPast, startOfDay, addDays } from 'date-fns';
import { isToday, isPast, startOfDay, addDays, subHours, format } from 'date-fns';
import { t } from 'svelte-i18n';
import type { Task } from '@todo/shared';
import { X } from '@manacore/shared-icons';
import { X, Circle, Minus } from '@manacore/shared-icons';
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { todoSettings } from '$lib/stores/settings.svelte';
@ -10,9 +11,10 @@
interface Props {
pageId: string;
onClose: () => void;
onMinimize?: () => void;
}
let { pageId, onClose }: Props = $props();
let { pageId, onClose, onMinimize }: Props = $props();
const tasksCtx: { readonly value: Task[] } = getContext('tasks');
@ -35,8 +37,14 @@
const weekEnd = addDays(today, 7);
switch (pageId) {
case 'todo':
return tasks.filter((t) => !t.isCompleted);
case 'todo': {
const recentCutoff = subHours(new Date(), 24);
return tasks.filter(
(t) =>
!t.isCompleted ||
(t.isCompleted && t.completedAt && new Date(t.completedAt) >= recentCutoff)
);
}
case 'completed':
return tasks.filter((t) => t.isCompleted);
case 'today':
@ -106,6 +114,40 @@
};
let sheetWidth = $derived(PAGE_WIDTH_MAP[todoSettings.pageWidth] || PAGE_WIDTH_MAP.medium);
let openTasks = $derived(
pageId === 'todo' ? filteredTasks.filter((t) => !t.isCompleted) : filteredTasks
);
let recentlyCompleted = $derived(
pageId === 'todo' ? filteredTasks.filter((t) => t.isCompleted) : []
);
function formatCompletedTime(completedAt: string): string {
const date = new Date(completedAt);
const time = format(date, 'HH:mm');
if (pageId === 'completed') {
const dateStr = format(date, 'dd.MM.');
return $t('secondaryPage.completedAtDateTime', { values: { date: dateStr, time } });
}
return $t('secondaryPage.completedAtTime', { values: { time } });
}
let newTaskTitle = $state('');
let inputEl = $state<HTMLInputElement | null>(null);
async function handleInlineCreate() {
const title = newTaskTitle.trim();
if (!title) return;
const data: { title: string; dueDate?: string } = { title };
if (pageId === 'today') {
data.dueDate = new Date().toISOString();
} else if (pageId === 'this-week') {
data.dueDate = new Date().toISOString();
}
await tasksStore.createTask(data);
newTaskTitle = '';
inputEl?.focus();
}
</script>
<div class="secondary-page" style="width: {sheetWidth}">
@ -115,18 +157,35 @@
<span class="page-title">{meta.title}</span>
<span class="task-count">{filteredTasks.length}</span>
</div>
<button class="close-btn" onclick={onClose} title="Seite schließen">
<X size={14} />
</button>
<div class="header-actions">
{#if onMinimize}
<button class="header-btn" onclick={onMinimize} title="Minimieren">
<Minus size={14} />
</button>
{/if}
<button class="header-btn" onclick={onClose} title="Seite schließen">
<X size={14} />
</button>
</div>
</div>
<div class="page-body">
{#if filteredTasks.length === 0}
<div class="empty-state">
<p>Keine Aufgaben</p>
</div>
{:else}
{#if pageId === 'completed'}
{#each filteredTasks as task (task.id)}
<div class="task-card-wrapper completed-task">
<KanbanTaskCard
{task}
onToggleComplete={() => handleToggle(task)}
onSave={(data) => handleUpdate(task.id, data)}
onDelete={() => handleDelete(task.id)}
/>
{#if task.completedAt}
<span class="completed-time">{formatCompletedTime(task.completedAt)}</span>
{/if}
</div>
{/each}
{:else}
{#each openTasks as task (task.id)}
<div class="task-card-wrapper">
<KanbanTaskCard
{task}
@ -136,6 +195,40 @@
/>
</div>
{/each}
{#if recentlyCompleted.length > 0}
<div class="completed-section">
<span class="completed-label">{$t('secondaryPage.recentlyCompleted')}</span>
{#each recentlyCompleted as task (task.id)}
<div class="task-card-wrapper completed-task">
<KanbanTaskCard
{task}
onToggleComplete={() => handleToggle(task)}
onSave={(data) => handleUpdate(task.id, data)}
onDelete={() => handleDelete(task.id)}
/>
{#if task.completedAt}
<span class="completed-time">{formatCompletedTime(task.completedAt)}</span>
{/if}
</div>
{/each}
</div>
{/if}
<div class="inline-create">
<span class="inline-create-icon">
<Circle size={18} />
</span>
<input
bind:this={inputEl}
bind:value={newTaskTitle}
class="inline-create-input"
placeholder={$t('secondaryPage.newTaskPlaceholder')}
onkeydown={(e) => {
if (e.key === 'Enter') handleInlineCreate();
}}
/>
</div>
{/if}
</div>
</div>
@ -213,7 +306,13 @@
color: #6b7280;
}
.close-btn {
.header-actions {
display: flex;
align-items: center;
gap: 0.125rem;
}
.header-btn {
display: flex;
align-items: center;
justify-content: center;
@ -226,11 +325,11 @@
cursor: pointer;
transition: all 0.15s;
}
.close-btn:hover {
.header-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .close-btn:hover {
:global(.dark) .header-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
@ -248,14 +347,73 @@
margin-bottom: 0;
}
.empty-state {
.completed-section {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .completed-section {
border-top-color: rgba(255, 255, 255, 0.08);
}
.completed-label {
font-size: 0.6875rem;
font-weight: 500;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
display: block;
}
.completed-task {
position: relative;
opacity: 0.6;
}
.completed-time {
position: absolute;
right: 0.5rem;
top: 0.5rem;
font-size: 0.6875rem;
color: #9ca3af;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.inline-create {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
gap: 0.5rem;
padding: 0.5rem 0.25rem;
margin-top: 0.25rem;
}
.empty-state p {
.inline-create-icon {
flex-shrink: 0;
color: #d1d5db;
display: flex;
align-items: center;
}
:global(.dark) .inline-create-icon {
color: #4b5563;
}
.inline-create-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.8125rem;
color: #9ca3af;
color: #374151;
outline: none;
}
.inline-create-input::placeholder {
color: #c0bfba;
}
:global(.dark) .inline-create-input {
color: #f3f4f6;
}
:global(.dark) .inline-create-input::placeholder {
color: #4b5563;
}
</style>

View file

@ -224,5 +224,11 @@
"toolbar": {
"taskOptions": "Aufgaben-Optionen",
"switchView": "Ansicht wechseln"
},
"secondaryPage": {
"recentlyCompleted": "Kürzlich erledigt",
"completedAtTime": "{time} Uhr",
"completedAtDateTime": "{date}, {time} Uhr",
"newTaskPlaceholder": "Neue Aufgabe…"
}
}

View file

@ -224,5 +224,11 @@
"toolbar": {
"taskOptions": "Task options",
"switchView": "Switch view"
},
"secondaryPage": {
"recentlyCompleted": "Recently completed",
"completedAtTime": "{time}",
"completedAtDateTime": "{date}, {time}",
"newTaskPlaceholder": "New task…"
}
}

View file

@ -224,5 +224,11 @@
"toolbar": {
"taskOptions": "Opciones de tareas",
"switchView": "Cambiar vista"
},
"secondaryPage": {
"recentlyCompleted": "Completadas recientemente",
"completedAtTime": "{time}",
"completedAtDateTime": "{date}, {time}",
"newTaskPlaceholder": "Nueva tarea…"
}
}

View file

@ -224,5 +224,11 @@
"toolbar": {
"taskOptions": "Options des tâches",
"switchView": "Changer de vue"
},
"secondaryPage": {
"recentlyCompleted": "Récemment terminées",
"completedAtTime": "{time} h",
"completedAtDateTime": "{date}, {time} h",
"newTaskPlaceholder": "Nouvelle tâche…"
}
}

View file

@ -224,5 +224,11 @@
"toolbar": {
"taskOptions": "Opzioni attività",
"switchView": "Cambia vista"
},
"secondaryPage": {
"recentlyCompleted": "Completate di recente",
"completedAtTime": "ore {time}",
"completedAtDateTime": "{date}, ore {time}",
"newTaskPlaceholder": "Nuova attività…"
}
}

View file

@ -4,7 +4,7 @@
import { BoardViewRenderer } from '$lib/components/board-views';
import { todoSettings, type PageWidth } from '$lib/stores/settings.svelte';
import { boardViewsStore } from '$lib/stores/board-views.svelte';
import { Plus } from '@manacore/shared-icons';
import { Plus, Minus, X } from '@manacore/shared-icons';
import PagePicker from '$lib/components/pages/PagePicker.svelte';
import SecondaryPage from '$lib/components/pages/SecondaryPage.svelte';
@ -19,17 +19,31 @@
// ── Secondary Pages ─────────────────────────────────────
let showPagePicker = $state(false);
let openPages = $state<string[]>([]);
let openPages = $state<{ id: string; minimized: boolean }[]>([]);
let expandedPages = $derived(openPages.filter((p) => !p.minimized));
let minimizedPages = $derived(openPages.filter((p) => p.minimized));
function handleAddPage(pageId: string) {
if (!openPages.includes(pageId)) {
openPages = [...openPages, pageId];
if (!openPages.some((p) => p.id === pageId)) {
openPages = [...openPages, { id: pageId, minimized: false }];
} else {
// Restore if minimized
openPages = openPages.map((p) => (p.id === pageId ? { ...p, minimized: false } : p));
}
showPagePicker = false;
}
function handleRemovePage(pageId: string) {
openPages = openPages.filter((p) => p !== pageId);
openPages = openPages.filter((p) => p.id !== pageId);
}
function handleMinimizePage(pageId: string) {
openPages = openPages.map((p) => (p.id === pageId ? { ...p, minimized: true } : p));
}
function handleRestorePage(pageId: string) {
openPages = openPages.map((p) => (p.id === pageId ? { ...p, minimized: false } : p));
}
function togglePagePicker() {
@ -112,6 +126,25 @@
activeView?.groupBy === 'status' || activeView?.groupBy === 'custom'
);
const PAGE_META: Record<string, { title: string; color: string }> = {
todo: { title: 'To Do', color: '#6B7280' },
completed: { title: 'Erledigt', color: '#22C55E' },
today: { title: 'Heute', color: '#F59E0B' },
overdue: { title: 'Überfällig', color: '#EF4444' },
all: { title: 'Alle Aufgaben', color: '#3B82F6' },
'high-priority': { title: 'Hohe Priorität', color: '#EF4444' },
'this-week': { title: 'Diese Woche', color: '#8B5CF6' },
'no-date': { title: 'Ohne Datum', color: '#6B7280' },
};
let pagePickerEl = $state<HTMLDivElement | null>(null);
$effect(() => {
if (showPagePicker && pagePickerEl) {
pagePickerEl.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && editMode) {
editModeCtx.set(false);
@ -169,6 +202,37 @@
</div>
{/if}
<!-- Minimized Page Tabs -->
{#if minimizedPages.length > 0}
<div class="minimized-tabs">
{#each minimizedPages as page (page.id)}
{@const meta = PAGE_META[page.id] ?? { title: page.id, color: '#6B7280' }}
<div
class="minimized-tab"
role="button"
tabindex="0"
onclick={() => handleRestorePage(page.id)}
>
<span class="minimized-tab-dot" style="background-color: {meta.color}"></span>
<span class="minimized-tab-title">{meta.title}</span>
<button
class="minimized-tab-close"
onclick={(e) => {
e.stopPropagation();
handleRemovePage(page.id);
}}
title="Schließen"
>
<X size={10} />
</button>
</div>
{/each}
<button class="minimized-tab-add" onclick={togglePagePicker} title="Neue Seite hinzufügen">
<Plus size={14} />
</button>
</div>
{/if}
<!-- Board Content -->
{#if activeView}
<BoardViewRenderer
@ -183,18 +247,24 @@
>
{#snippet trailing()}
<!-- Secondary Pages -->
{#each openPages as pageId (pageId)}
<SecondaryPage {pageId} onClose={() => handleRemovePage(pageId)} />
{#each expandedPages as page (page.id)}
<SecondaryPage
pageId={page.id}
onClose={() => handleRemovePage(page.id)}
onMinimize={() => handleMinimizePage(page.id)}
/>
{/each}
<!-- Neue Seite button (always last in track) -->
{#if !editMode}
{#if showPagePicker}
<PagePicker
onSelect={handleAddPage}
onClose={() => (showPagePicker = false)}
activePageIds={openPages}
/>
<div bind:this={pagePickerEl}>
<PagePicker
onSelect={handleAddPage}
onClose={() => (showPagePicker = false)}
activePageIds={openPages.map((p) => p.id)}
/>
</div>
{:else}
<button
class="neue-seite-card"
@ -250,6 +320,116 @@
background: color-mix(in srgb, var(--color-primary, #8b5cf6) 8%, transparent);
}
/* ── Minimized Tabs ──────────────────────────────────── */
.minimized-tabs {
display: flex;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1.5rem 0.25rem;
overflow-x: auto;
scrollbar-width: none;
}
.minimized-tabs::-webkit-scrollbar {
display: none;
}
.minimized-tab {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.4rem 0.75rem 0.4rem 0.75rem;
background: #fffef5;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.minimized-tab:hover {
background: #fffdf0;
border-color: rgba(0, 0, 0, 0.12);
}
:global(.dark) .minimized-tab {
background: #252220;
border-color: rgba(255, 255, 255, 0.08);
}
:global(.dark) .minimized-tab:hover {
background: #2a2725;
border-color: rgba(255, 255, 255, 0.14);
}
.minimized-tab-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
flex-shrink: 0;
}
.minimized-tab-title {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
}
:global(.dark) .minimized-tab-title {
color: #9ca3af;
}
.minimized-tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
background: transparent;
color: #d1d5db;
border-radius: 0.125rem;
cursor: pointer;
padding: 0;
transition: all 0.15s;
}
.minimized-tab-close:hover {
color: #6b7280;
background: rgba(0, 0, 0, 0.06);
}
:global(.dark) .minimized-tab-close {
color: #4b5563;
}
:global(.dark) .minimized-tab-close:hover {
color: #9ca3af;
background: rgba(255, 255, 255, 0.08);
}
.minimized-tab-add {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 0.375rem;
border: 1px dashed rgba(0, 0, 0, 0.15);
background: transparent;
color: #9ca3af;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.minimized-tab-add:hover {
border-color: var(--color-primary, #8b5cf6);
color: var(--color-primary, #8b5cf6);
background: color-mix(in srgb, var(--color-primary, #8b5cf6) 4%, transparent);
}
:global(.dark) .minimized-tab-add {
border-color: rgba(255, 255, 255, 0.1);
color: #4b5563;
}
:global(.dark) .minimized-tab-add:hover {
border-color: var(--color-primary, #8b5cf6);
color: var(--color-primary, #8b5cf6);
background: color-mix(in srgb, var(--color-primary, #8b5cf6) 8%, transparent);
}
.empty-state {
display: flex;
align-items: center;