import { useCallback, useEffect, useId, useMemo, useRef, useState, type FormEvent, } from 'react'; import { Badge, Button, Dialog, DialogTitle, Input, Textarea, } from '@noorddev/vlak-react'; type TaskState = 'This week' | 'Waiting' | 'Later' | 'Done'; type Task = { id: string; title: string; project: string; owner: string; hours: number; due: string | null; state: TaskState; blocker: string; nextAction: string; }; type StoredSlice = { state: TaskState; nextAction: string; }; const STORAGE_KEY = 'planning-room-v2'; const CAPACITY_HOURS = 16; const WEEK_LABEL = 'September 14–18, 2026'; const INITIAL_TASKS: 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.', }, ]; const FILTERS = ['All', 'This week', 'Waiting', 'Later', 'Done'] as const; type Filter = (typeof FILTERS)[number]; const TASK_STATES: TaskState[] = ['This week', 'Waiting', 'Later', 'Done']; function isTaskState(value: unknown): value is TaskState { return ( value === 'This week' || value === 'Waiting' || value === 'Later' || value === 'Done' ); } function loadStored(): Record { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== 'object') return {}; const source = parsed as Record; const next: Record = {}; for (const task of INITIAL_TASKS) { const entry = source[task.id]; if (!entry || typeof entry !== 'object') continue; const record = entry as Record; if (!isTaskState(record.state) || typeof record.nextAction !== 'string') { continue; } next[task.id] = { state: record.state, nextAction: record.nextAction, }; } return next; } catch { return {}; } } function formatDue(due: string | null): string { if (!due) return 'No due date'; const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(due); if (!match) return due; const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; const month = months[Number(match[2]) - 1] ?? match[2]; return `${month} ${Number(match[3])}, ${match[1]}`; } function badgeVariant(state: TaskState): 'outline' | 'solid' | 'muted' { if (state === 'This week') return 'solid'; if (state === 'Waiting') return 'outline'; if (state === 'Done') return 'muted'; return 'outline'; } export default function App() { const searchId = useId(); const stateId = useId(); const nextActionId = useId(); const formErrorId = useId(); const confirmId = useId(); const [stored, setStored] = useState>(() => loadStored()); const [search, setSearch] = useState(''); const [filter, setFilter] = useState('All'); const [openId, setOpenId] = useState(null); const [draftState, setDraftState] = useState('This week'); const [draftNextAction, setDraftNextAction] = useState(''); const [formError, setFormError] = useState(null); const [saveConfirm, setSaveConfirm] = useState(null); const openerRef = useRef(null); const editButtonsRef = useRef>(new Map()); const filterButtonsRef = useRef>(new Map()); const tasks = useMemo( () => INITIAL_TASKS.map((task) => ({ ...task, state: stored[task.id]?.state ?? task.state, nextAction: stored[task.id]?.nextAction ?? task.nextAction, })), [stored], ); useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); } catch { // Ignore quota / private-mode failures. } }, [stored]); const counts = useMemo(() => { const next = { All: tasks.length, 'This week': 0, Waiting: 0, Later: 0, Done: 0, }; for (const task of tasks) { next[task.state] += 1; } return next; }, [tasks]); const plannedHours = useMemo( () => tasks .filter((task) => task.state === 'This week') .reduce((sum, task) => sum + task.hours, 0), [tasks], ); const remainingHours = CAPACITY_HOURS - plannedHours; const overCapacity = plannedHours > CAPACITY_HOURS; const visible = useMemo(() => { const q = search.trim().toLowerCase(); return tasks.filter((task) => { const matchesFilter = filter === 'All' || task.state === filter; const matchesSearch = q.length === 0 || task.title.toLowerCase().includes(q) || task.project.toLowerCase().includes(q) || task.owner.toLowerCase().includes(q); return matchesFilter && matchesSearch; }); }, [tasks, filter, search]); const openTask = openId ? tasks.find((task) => task.id === openId) ?? null : null; const persistTask = useCallback((id: string, state: TaskState, nextAction: string) => { setStored((prev) => ({ ...prev, [id]: { state, nextAction }, })); }, []); const openEdit = (id: string) => { const task = tasks.find((entry) => entry.id === id); if (!task) return; openerRef.current = editButtonsRef.current.get(id) ?? null; setOpenId(id); setDraftState(task.state); setDraftNextAction(task.nextAction); setFormError(null); setSaveConfirm(null); }; const returnFocus = () => { const opener = openerRef.current; window.requestAnimationFrame(() => { if (opener && document.body.contains(opener) && opener.offsetParent !== null) { opener.focus(); return; } filterButtonsRef.current.get(filter)?.focus(); }); }; const closeDialog = () => { setOpenId(null); setFormError(null); setSaveConfirm(null); returnFocus(); }; const clearFilters = () => { setSearch(''); setFilter('All'); }; const saveTask = (event?: FormEvent) => { event?.preventDefault(); if (!openTask) return; const trimmed = draftNextAction.trim(); if (!trimmed) { setSaveConfirm(null); setFormError('Add a next-action note before saving.'); return; } if (openTask.blocker.trim() && draftState === 'This week') { setSaveConfirm(null); setFormError( `This task stays blocked: ${openTask.blocker} Move it to Waiting, Later, or Done instead.`, ); return; } persistTask(openTask.id, draftState, trimmed); setDraftNextAction(trimmed); setFormError(null); setSaveConfirm('Task saved. Totals update with the current This week plan.'); }; return (

Planning room

Choose a week that can actually ship

Review week {WEEK_LABEL}. You have {CAPACITY_HOURS} focused hours. Pull unblocked work into This week, keep waiting work visible, and leave a clear next action on every task.

Planned {plannedHours}h
Capacity {CAPACITY_HOURS}h
{overCapacity ? 'Over by' : 'Remaining'} {overCapacity ? `${plannedHours - CAPACITY_HOURS}h` : `${remainingHours}h`}
{overCapacity ? (

This week is {plannedHours - CAPACITY_HOURS} hours over the {CAPACITY_HOURS}-hour capacity. You can keep that plan, but something will slip.

) : (

Waiting, later, and done work do not count toward this total.

)}
setSearch(event.target.value)} placeholder="Search title, project, or owner" autoComplete="off" />
{FILTERS.map((name) => { const selected = filter === name; return (
{counts[name]} tasks
); })}
{visible.length === 0 ? (

No matching tasks

Nothing matches this search and filter. Clear them to see the full review week again.

) : (
    {visible.map((task) => (
  • {task.project}

    {task.state}

    {task.title}

    Owner
    {task.owner}
    Estimate
    {task.hours}h
    Due
    {formatDue(task.due)}
    {task.blocker ? (

    Blocked {task.blocker}

    ) : null}

    Next action {task.nextAction}

  • ))}
)} {openTask ? (
Edit {openTask.title}

{openTask.project}

{openTask.title}

Owner
{openTask.owner}
Estimate
{openTask.hours}h
Due
{formatDue(openTask.due)}
{openTask.blocker ? (

Blocked {openTask.blocker}

) : (

No blocker on this task.

)}