import { useEffect, useRef, useState } from 'react'; import type { ChangeEvent, FormEvent } from 'react'; import { Badge, Button, Dialog, DialogTitle, Input, Textarea } from '@noorddev/vlak-react'; const STORAGE_KEY = 'planning-room-v2'; const CAPACITY_HOURS = 16; const WEEK_START = '2026-09-14'; const WEEK_END = '2026-09-18'; const WEEK_LABEL = 'September 14–18, 2026'; const STATES = ['This week', 'Waiting', 'Later', 'Done'] as const; type TaskState = (typeof STATES)[number]; type Filter = 'All' | TaskState; const FILTERS: readonly Filter[] = ['All', ...STATES]; type Task = { id: string; title: string; project: string; owner: string; hours: number; due: string | null; state: TaskState; blocker: string; nextAction: string; }; type Draft = { id: string; state: TaskState; nextAction: string; }; type Plan = { planned: number; excess: number; remaining: number; verdict: string; hint: string; }; const SEED: readonly Task[] = [ { id: 'p01', title: 'Review the annotation flow', project: 'Noord', owner: 'Sam', hours: 4, due: '2026-09-15', state: 'This week', blocker: '', nextAction: 'Check keyboard use in the review dialog.', }, { id: 'p02', title: 'Write the brand guide', project: 'Polder', owner: 'Alex', hours: 6, due: '2026-09-17', state: 'This week', blocker: '', nextAction: 'Draft the color and typography sections.', }, { id: 'p03', title: 'Prepare the partner review', project: 'Studio', owner: 'Rae', hours: 3, due: '2026-09-16', state: 'This week', blocker: '', nextAction: 'Choose two directions and write the review questions.', }, { id: 'p04', title: 'Fix the empty state on small screens', project: 'Vlak', owner: 'Sam', hours: 5, due: '2026-09-18', state: 'This week', blocker: '', nextAction: 'Reproduce the overflow at 390px.', }, { id: 'p05', title: 'Export the campaign images', project: 'Polder', owner: 'Alex', hours: 2, due: '2026-09-16', state: 'Waiting', blocker: 'Waiting for approved copy.', nextAction: 'Ask for the final headline.', }, { id: 'p06', title: 'Check payment confirmation copy', project: 'Studio', owner: 'Rae', hours: 3, due: '2026-09-18', state: 'Waiting', blocker: 'Waiting for the finance review.', nextAction: 'Confirm which receipt details are required.', }, { id: 'p07', title: 'Document how the long project title behaves in the navigation', project: 'Vlak', owner: 'Sam', hours: 2, due: null, state: 'Later', blocker: '', nextAction: 'Capture the narrow and wide examples.', }, { id: 'p08', title: 'Explore a new photo sequence', project: 'Noord', owner: 'Alex', hours: 4, due: null, state: 'Later', blocker: '', nextAction: 'Choose six photographs to compare.', }, { id: 'p09', title: 'Send the workshop notes', project: 'Studio', owner: 'Rae', hours: 1, due: '2026-09-14', state: 'Done', blocker: '', nextAction: 'Notes sent for participant correction.', }, { id: 'p10', title: 'Check the font licenses', project: 'Polder', owner: 'Alex', hours: 2, due: '2026-09-14', state: 'Done', blocker: '', nextAction: 'License links saved with the source files.', }, ]; function isTaskState(value: unknown): value is TaskState { return typeof value === 'string' && (STATES as readonly string[]).includes(value); } function toKey(value: string): string { return value.toLowerCase().replace(/\s+/g, '-'); } function formatHours(hours: number): string { return `${hours} h`; } function countTasks(count: number): string { return `${count} ${count === 1 ? 'task' : 'tasks'}`; } function formatDue(due: string | null): string { if (!due) return 'No due date'; const [year, month, day] = due.split('-').map(Number); if (!year || !month || !day) return due; return new Date(year, month - 1, day).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', }); } function isDueThisWeek(task: Task): boolean { return task.due !== null && task.due >= WEEK_START && task.due <= WEEK_END; } function sumHours(tasks: readonly Task[]): number { return tasks.reduce((total, task) => total + task.hours, 0); } function summarize(tasks: readonly Task[]): Plan { const planned = sumHours(tasks.filter((task) => task.state === 'This week')); const excess = Math.max(planned - CAPACITY_HOURS, 0); const remaining = Math.max(CAPACITY_HOURS - planned, 0); if (excess > 0) { return { planned, excess, remaining, verdict: `${formatHours(excess)} over capacity`, hint: 'Move work to Later or Waiting to fit, or accept the overrun.', }; } if (remaining === 0) { return { planned, excess, remaining, verdict: 'At capacity', hint: 'Every focused hour is spoken for.', }; } return { planned, excess, remaining, verdict: `${formatHours(remaining)} left`, hint: 'There is room to plan more work this week.', }; } function stateVariant(state: TaskState): 'solid' | 'outline' | 'muted' { if (state === 'This week') return 'solid'; if (state === 'Waiting') return 'outline'; return 'muted'; } function focusById(id: string): boolean { const element = document.getElementById(id); if (element instanceof HTMLElement) { element.focus(); return true; } return false; } function loadTasks(): Task[] { const seed = SEED.map((task) => ({ ...task })); if (typeof window === 'undefined') return seed; let raw: string | null = null; try { raw = window.localStorage.getItem(STORAGE_KEY); } catch { return seed; } if (!raw) return seed; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return seed; } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return seed; const stored = parsed as Record; return seed.map((task) => { const entry = stored[task.id]; if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return task; const { state, nextAction } = entry as Record; const restored = { ...task }; if (isTaskState(state) && !(task.blocker && state === 'This week')) restored.state = state; if (typeof nextAction === 'string' && nextAction.trim()) restored.nextAction = nextAction.trim(); return restored; }); } function persistTasks(tasks: readonly Task[]): void { const record: Record = {}; for (const task of tasks) { record[task.id] = { state: task.state, nextAction: task.nextAction }; } try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(record)); } catch { // Storage is unavailable or full. The plan still updates in memory. } } type TaskRowProps = { task: Task; onEdit: (task: Task) => void; }; function TaskRow({ task, onEdit }: TaskRowProps) { const flagDue = isDueThisWeek(task) && task.state !== 'This week' && task.state !== 'Done'; return (
  • {task.title}

    {task.state} {task.project} {task.owner} {task.due ? `Due ${formatDue(task.due)}` : 'No due date'} {flagDue && Due this week}

    Next action

    {task.nextAction}

    {task.blocker &&

    Blocked: {task.blocker}

    }

    {formatHours(task.hours)}

  • ); } export default function App() { const [tasks, setTasks] = useState(loadTasks); const [query, setQuery] = useState(''); const [filter, setFilter] = useState('All'); const [draft, setDraft] = useState(null); const [attemptedSave, setAttemptedSave] = useState(false); const [savedMessage, setSavedMessage] = useState(''); const returnFocusTo = useRef(null); const plan = summarize(tasks); const scale = Math.max(plan.planned, CAPACITY_HOURS); const withinPercent = (Math.min(plan.planned, CAPACITY_HOURS) / scale) * 100; const overPercent = (plan.excess / scale) * 100; const capPercent = (CAPACITY_HOURS / scale) * 100; const needle = query.trim().toLowerCase(); const searched = needle ? tasks.filter((task) => [task.title, task.project, task.owner].some((value) => value.toLowerCase().includes(needle)), ) : tasks; const counts: Record = { All: searched.length, 'This week': 0, Waiting: 0, Later: 0, Done: 0, }; for (const task of searched) counts[task.state] += 1; const visible = filter === 'All' ? searched : searched.filter((task) => task.state === filter); const groups = STATES.map((state) => ({ state, items: visible.filter((task) => task.state === state), })).filter((group) => group.items.length > 0); const resultsText = visible.length === tasks.length ? `Showing ${countTasks(tasks.length)}` : `Showing ${visible.length} of ${tasks.length} tasks`; const editingTask = draft ? (tasks.find((task) => task.id === draft.id) ?? null) : null; const blockedError = draft && editingTask && editingTask.blocker && draft.state === 'This week' ? `Blocked: ${editingTask.blocker} Blocked tasks can't be planned for this week. Choose Waiting, Later, or Done.` : ''; const noteError = attemptedSave && draft && !draft.nextAction.trim() ? 'Add a next action before saving.' : ''; let planPreview = ''; if (draft && editingTask && !blockedError && draft.state !== editingTask.state) { const entering = draft.state === 'This week'; const leaving = editingTask.state === 'This week'; if (entering || leaving) { const preview = summarize( tasks.map((task) => (task.id === draft.id ? { ...task, state: draft.state } : task)), ); planPreview = `${entering ? 'Adds' : 'Frees'} ${formatHours(editingTask.hours)}. Planned would be ${formatHours(preview.planned)} of ${formatHours(CAPACITY_HOURS)}, ${preview.verdict.toLowerCase()}.`; } } useEffect(() => { if (draft !== null) return; const openerId = returnFocusTo.current; if (openerId === null) return; returnFocusTo.current = null; if (!focusById(`edit-${openerId}`)) { focusById(`filter-${toKey(filter)}`); } }, [draft, filter]); function openEditor(task: Task) { returnFocusTo.current = task.id; setDraft({ id: task.id, state: task.state, nextAction: task.nextAction }); setAttemptedSave(false); setSavedMessage(''); } function closeEditor() { setDraft(null); setAttemptedSave(false); setSavedMessage(''); } function clearFilters() { setQuery(''); setFilter('All'); focusById('pr-search'); } function handleStateChange(event: ChangeEvent) { const value = event.target.value; if (!isTaskState(value)) return; setDraft((current) => (current ? { ...current, state: value } : current)); setSavedMessage(''); } function handleNoteChange(event: ChangeEvent) { const value = event.target.value; setDraft((current) => (current ? { ...current, nextAction: value } : current)); setSavedMessage(''); } function handleSave(event: FormEvent) { event.preventDefault(); if (!draft || !editingTask) return; setAttemptedSave(true); setSavedMessage(''); if (editingTask.blocker && draft.state === 'This week') { focusById('task-state'); return; } const note = draft.nextAction.trim(); if (!note) { focusById('task-next-action'); return; } const nextTasks = tasks.map((task) => task.id === draft.id ? { ...task, state: draft.state, nextAction: note } : task, ); setTasks(nextTasks); persistTasks(nextTasks); setDraft({ ...draft, nextAction: note }); setAttemptedSave(false); const next = summarize(nextTasks); setSavedMessage( `Saved. Planned ${formatHours(next.planned)} of ${formatHours(CAPACITY_HOURS)}, ${next.verdict.toLowerCase()}.`, ); } function describeEmpty(): string { const term = query.trim(); if (term && filter !== 'All') return `No tasks match “${term}” in ${filter}.`; if (term) return `No tasks match “${term}”.`; if (filter !== 'All') return `No tasks in ${filter}.`; return 'No tasks to show.'; } return (

    Planning room

    Review week of {WEEK_LABEL}

    {formatHours(CAPACITY_HOURS)} of focused time available

    Plan for this week

    0 ? 'pr-verdict is-over' : 'pr-verdict is-ok'}>{plan.verdict}

    {formatHours(plan.planned)} planned of {formatHours(CAPACITY_HOURS)} capacity

    ) => setQuery(event.target.value)} />
    {FILTERS.map((item) => { const active = item === filter; return ( ); })}

    {resultsText}

    {groups.length === 0 ? (

    {describeEmpty()}

    ) : ( groups.map((group) => (

    {group.state}

    {countTasks(group.items.length)} · {formatHours(sumHours(group.items))} {group.state === 'This week' ? ` of ${formatHours(CAPACITY_HOURS)}` : ''}
      {group.items.map((task) => ( ))}
    )) )} {draft && editingTask && ( <>

    Edit task

    {editingTask.title}
    Project
    {editingTask.project}
    Owner
    {editingTask.owner}
    Estimate
    {formatHours(editingTask.hours)}
    Due
    {formatDue(editingTask.due)}
    Blocker
    {editingTask.blocker || 'None'}
    Saved next action
    {editingTask.nextAction}
    {blockedError && ( )} {!blockedError && planPreview && (

    {planPreview}

    )}