feat(todo): complete animation + "Heute erledigt" section on focus pages

KanbanTaskCard:
- Checkbox click animates first (checkPop 300ms, fade to 50% opacity)
- After 500ms the actual onToggleComplete fires and task moves
- Modal cannot open during animation (pointer-events: none)

FokusLayout:
- Derives completedToday from tasks context (isCompleted + completedAt today)
- Shows "Heute erledigt" section at bottom of every sheet
- New items slide in from above (slideDown 350ms animation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 17:45:22 +02:00
parent 402baf7c7f
commit a22f1de6d0
2 changed files with 111 additions and 5 deletions

View file

@ -1,5 +1,7 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID, type DndEvent } from 'svelte-dnd-action';
import { getContext } from 'svelte';
import { isToday } from 'date-fns';
import type { Task } from '@todo/shared';
import type { GroupedColumn } from '$lib/data/view-grouping';
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
@ -34,6 +36,12 @@
onAddColumn,
}: Props = $props();
// Today's completed tasks — shown at the bottom of every sheet
const tasksCtx: { readonly value: Task[] } = getContext('tasks');
let completedToday = $derived(
tasksCtx.value.filter((t) => t.isCompleted && t.completedAt && isToday(new Date(t.completedAt)))
);
const PAGE_WIDTH_MAP: Record<string, string> = {
narrow: 'min(360px, 85vw)',
medium: 'min(480px, 85vw)',
@ -161,6 +169,22 @@
<div class="sheet-footer">
<QuickAddTaskInline onAdd={(title) => handleAddTask(column, title)} />
</div>
{#if completedToday.length > 0}
<div class="completed-today">
<div class="completed-today-label">Heute erledigt</div>
{#each completedToday as task (task.id)}
<div class="completed-today-item">
<KanbanTaskCard
{task}
onToggleComplete={() => onTaskToggle(task)}
onSave={(data) => onTaskUpdate(task.id, data)}
onDelete={() => onTaskDelete(task.id)}
/>
</div>
{/each}
</div>
{/if}
</div>
{/each}
@ -309,6 +333,42 @@
color: #8b5cf6;
}
/* Heute erledigt section */
.completed-today {
padding: 0.75rem 1rem 1rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
margin-top: 0.25rem;
}
:global(.dark) .completed-today {
border-top-color: rgba(255, 255, 255, 0.08);
}
.completed-today-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(0, 0, 0, 0.3);
margin-bottom: 0.5rem;
}
:global(.dark) .completed-today-label {
color: rgba(255, 255, 255, 0.3);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.completed-today-item {
animation: slideDown 0.35s ease-out both;
}
/* Page dots */
.page-dots {
display: flex;

View file

@ -28,6 +28,9 @@
let showModal = $state(false);
let showDeleteConfirm = $state(false);
// Completion animation
let isAnimatingComplete = $state(false);
// Inline edit state
let isEditingTitle = $state(false);
let editTitle = $state('');
@ -58,13 +61,27 @@
// Click to open modal
function handleCardClick(e: MouseEvent) {
// Don't open modal if clicking on checkbox or during inline edit
if (isEditingTitle) return;
// Don't open modal if clicking on checkbox or during inline edit or animation
if (isEditingTitle || isAnimatingComplete) return;
const target = e.target as HTMLElement;
if (target.closest('.task-checkbox')) return;
showModal = true;
}
function handleCheckboxClick(e: MouseEvent) {
e.stopPropagation();
if (task.isCompleted) {
onToggleComplete?.();
return;
}
if (isAnimatingComplete) return;
isAnimatingComplete = true;
setTimeout(() => {
isAnimatingComplete = false;
onToggleComplete?.();
}, 500);
}
// Double-click to edit title inline
function handleTitleDoubleClick(e: MouseEvent) {
e.stopPropagation();
@ -172,6 +189,7 @@
<div
class="kanban-card group"
class:completed={task.isCompleted}
class:completing={isAnimatingComplete}
onclick={handleCardClick}
oncontextmenu={handleContextMenu}
role="button"
@ -182,8 +200,12 @@
<!-- Checkbox -->
{#if onToggleComplete}
<button class="task-checkbox" class:checked={task.isCompleted} onclick={onToggleComplete}>
{#if task.isCompleted}
<button
class="task-checkbox"
class:checked={task.isCompleted || isAnimatingComplete}
onclick={handleCheckboxClick}
>
{#if task.isCompleted || isAnimatingComplete}
<Check size={20} class="check-icon" />
{/if}
</button>
@ -203,7 +225,7 @@
{:else}
<span
class="task-title"
class:line-through={task.isCompleted}
class:line-through={task.isCompleted || isAnimatingComplete}
ondblclick={handleTitleDoubleClick}
>
{task.title}
@ -363,6 +385,30 @@
opacity: 0.45;
}
.kanban-card.completing {
opacity: 0.5;
pointer-events: none;
transition: opacity 0.4s ease;
}
@keyframes checkPop {
0% {
transform: scale(0.5);
opacity: 0;
}
60% {
transform: scale(1.25);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.task-checkbox.checked {
animation: checkPop 0.3s ease-out;
}
/* Priority dot — slim left accent, aligned to first line */
.priority-dot {
width: 3px;