diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f15769..d4d618a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- Surface the new task date fields in the kanban UI. + Snoozed tasks (`scheduled` in the future) drop off the + board until the day arrives, then return with a small + alarm-clock badge top-left — clicking it acknowledges, + which clears the snooze. A toolbar pill shows the + snoozed count and expands to a list with one-click + "Wake" per task. Deadlines within three days (or + overdue) get a separate bell badge that can be + acknowledged per-device via localStorage — the deadline + date stays on the task, the urgency cue just gets + muted. Add-task and edit-task forms gain two date + inputs. + - Add `scheduled` and `deadline` date fields to the task model across all four backends (org, markdown, sql, json) and the cloud wire format. Both are date-only ISO diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4f877b11..7a9d69db 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -143,7 +143,10 @@ export function createTask( }); } -/** Update one or more fields on an existing task. */ +/** Update one or more fields on an existing task. + * ``scheduled`` / ``deadline`` accept date-only ISO + * strings (``YYYY-MM-DD``). Pass ``""`` to clear, omit + * to leave unchanged. */ export function updateTask( taskId: string, updates: { @@ -152,6 +155,8 @@ export function updateTask( status?: string; body?: string; github_url?: string; + scheduled?: string; + deadline?: string; } ): Promise { return patch(`/kanban/tasks/${taskId}`, updates); diff --git a/frontend/src/components/kanban/KanbanBoard.tsx b/frontend/src/components/kanban/KanbanBoard.tsx index 5447474d..9e9adb89 100644 --- a/frontend/src/components/kanban/KanbanBoard.tsx +++ b/frontend/src/components/kanban/KanbanBoard.tsx @@ -46,6 +46,7 @@ import { useCollapsedColumns } from "../../hooks/useCollapsedColumns"; import { DOCS } from "../../docs/panelDocs"; import { TaskCard } from "./TaskCard"; import { KanbanColumn } from "./KanbanColumn"; +import { SnoozedPill } from "./SnoozedPill"; import { registerPanelAction } from "../../utils/panelActions"; import { usePendingSearch } from "../../context/ViewContext"; import { stripCustomerPrefix } from "../../utils/customerPrefix"; @@ -352,7 +353,19 @@ export function KanbanBoard() { const { isCollapsed, toggle: toggleCollapsed, } = useCollapsedColumns(); - const tasks = rawTasks.filter((t) => matchesSearch(t, search)); + // Hide tasks whose ``scheduled`` date is still in the + // future — they reappear on the day. The count is shown + // in a toolbar pill so the user can still see them. + const todayStr = new Date().toISOString().slice(0, 10); + const snoozed = rawTasks.filter( + (t) => t.scheduled && t.scheduled > todayStr, + ); + const visibleTasks = rawTasks.filter( + (t) => !t.scheduled || t.scheduled <= todayStr, + ); + const tasks = visibleTasks.filter( + (t) => matchesSearch(t, search), + ); const { pendingSearch, clearPendingSearch } = usePendingSearch(); useEffect( @@ -629,6 +642,7 @@ export function KanbanBoard() { } right={<> + + {open && ( +
+ {ordered.map((task) => ( +
+
+
+ {stripCustomerPrefix(task.title)} +
+
+ {task.customer + ? `${task.customer} · ` + : ""} + {task.scheduled} +
+
+ +
+ ))} + +
+ )} + + ); +} diff --git a/frontend/src/components/kanban/TaskCard.tsx b/frontend/src/components/kanban/TaskCard.tsx index 5dd1c6ef..6f12fc1e 100644 --- a/frontend/src/components/kanban/TaskCard.tsx +++ b/frontend/src/components/kanban/TaskCard.tsx @@ -33,6 +33,7 @@ import { TaskEditForm } from "./TaskEditForm"; import { TaskCardActions } from "./TaskCardActions"; import { TaskCardContent } from "./TaskCardContent"; import { StateHistoryPopup } from "./StateHistoryPopup"; +import { TaskDateBadges } from "./TaskDateBadges"; interface TaskCardProps { task: Task; @@ -87,6 +88,8 @@ export function TaskCard({ const [editGithubUrl, setEditGithubUrl] = useState( "", ); + const [editScheduled, setEditScheduled] = useState(""); + const [editDeadline, setEditDeadline] = useState(""); const { overlayUrl, openOverlay, closeOverlay } = useLinkOverlay(); const updateTask = useUpdateTask(); @@ -106,6 +109,8 @@ export function TaskCard({ setEditTags([...task.tags]); setEditBody(task.body ?? ""); setEditGithubUrl(task.github_url ?? ""); + setEditScheduled(task.scheduled ?? ""); + setEditDeadline(task.deadline ?? ""); setEditing(true); } @@ -121,6 +126,11 @@ export function TaskCard({ customer: editCustomer.trim(), body: editBody, github_url: editGithubUrl, + // Always send so the user can also clear by + // emptying the input. ``""`` clears server-side; + // an unchanged value just rewrites the same date. + scheduled: editScheduled, + deadline: editDeadline, }, }, { @@ -169,6 +179,9 @@ export function TaskCard({ style={{ backgroundColor: statusColor }} /> + {/* Snooze + deadline badges (top-left) */} + {!editing && } +
{/* Drag handle + state picker */}
@@ -228,11 +241,15 @@ export function TaskCard({ updateTask.isPending || setTaskTags.isPending } + editScheduled={editScheduled} + editDeadline={editDeadline} onCustomerChange={setEditCustomer} onTitleChange={setEditTitle} onBodyChange={setEditBody} onGithubUrlChange={setEditGithubUrl} onTagsChange={setEditTags} + onScheduledChange={setEditScheduled} + onDeadlineChange={setEditDeadline} onSave={handleSave} onCancel={() => setEditing(false)} /> diff --git a/frontend/src/components/kanban/TaskDateBadges.tsx b/frontend/src/components/kanban/TaskDateBadges.tsx new file mode 100644 index 00000000..7a3ffff6 --- /dev/null +++ b/frontend/src/components/kanban/TaskDateBadges.tsx @@ -0,0 +1,159 @@ +/** + * Top-left badges on a task card for the snooze surface + * (``scheduled``) and the deadline cue (``deadline``). + * + * - Scheduled badge: shown when ``task.scheduled`` is set + * and the date is **today or in the past**. Clicking + * acknowledges: the badge clears the ``scheduled`` field + * server-side so the marker is gone forever (the snooze + * served its purpose). Future-dated scheduled tasks are + * filtered out of the board entirely by the parent + * board component, so this badge only ever renders for + * the surfaced state. + * + * - Deadline badge: shown when ``task.deadline`` is set + * AND the day is close enough (today or within + * ``DEADLINE_URGENCY_DAYS``) — earlier-than-urgency + * dates carry no badge (the deadline still appears in + * the edit form). Click acknowledges **locally** via + * localStorage keyed by ``(task_id, deadline_date)`` — + * the deadline itself stays on the task. Changing the + * deadline re-keys the ack so the badge returns with + * the new date. + */ +import { useEffect, useState } from "react"; +import { AlarmClock, BellRing } from "lucide-react"; + +import { useUpdateTask } from "../../hooks/useTasks"; +import { + isDeadlineAcked, + ackDeadline, +} from "../../utils/deadlineAck"; +import type { Task } from "../../types"; + +const DEADLINE_ACK_EVENT = "kaisho:deadline-acked"; + +const DEADLINE_URGENCY_DAYS = 3; + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +/** Inclusive day-difference between two ``YYYY-MM-DD`` + * strings: positive when ``a`` is after ``b``. */ +function daysBetween(a: string, b: string): number { + const ms = 24 * 60 * 60 * 1000; + return Math.floor( + (Date.parse(a) - Date.parse(b)) / ms, + ); +} + +function isScheduledSurfaced(task: Task): boolean { + if (!task.scheduled) return false; + return task.scheduled <= todayIso(); +} + +function isDeadlineUrgent(task: Task): boolean { + if (!task.deadline) return false; + if (isDeadlineAcked(task.id, task.deadline)) { + return false; + } + const today = todayIso(); + // Surface when the deadline is today, past, or coming + // up within the urgency window. Earlier than that and + // the badge would be too noisy. + return daysBetween(task.deadline, today) + <= DEADLINE_URGENCY_DAYS; +} + +interface TaskDateBadgesProps { + task: Task; +} + +export function TaskDateBadges({ + task, +}: TaskDateBadgesProps) { + const updateTask = useUpdateTask(); + // Local re-render trigger after a deadline ack — the + // ack lives in localStorage, not React state, so we + // need a nudge to recompute ``isDeadlineUrgent``. + const [ackTick, setAckTick] = useState(0); + + useEffect(() => { + function bump() { + setAckTick((n) => n + 1); + } + window.addEventListener(DEADLINE_ACK_EVENT, bump); + return () => { + window.removeEventListener( + DEADLINE_ACK_EVENT, bump, + ); + }; + }, []); + + // ackTick is read here to make React track the dep. + void ackTick; + + const showScheduled = isScheduledSurfaced(task); + const showDeadline = isDeadlineUrgent(task); + if (!showScheduled && !showDeadline) return null; + + function ackScheduled(e: React.MouseEvent) { + e.stopPropagation(); + updateTask.mutate({ + taskId: task.id, + // Empty string clears the field server-side + // (PATCH convention). + updates: { scheduled: "" }, + }); + } + + function ackDeadlineHere(e: React.MouseEvent) { + e.stopPropagation(); + if (task.deadline) { + ackDeadline(task.id, task.deadline); + window.dispatchEvent( + new CustomEvent(DEADLINE_ACK_EVENT), + ); + } + } + + const overdue = task.deadline + && task.deadline < todayIso(); + const deadlineColor = overdue + ? "text-red-500 hover:bg-red-500/10" + : "text-amber-500 hover:bg-amber-500/10"; + + return ( +
e.stopPropagation()} + > + {showScheduled && ( + + )} + {showDeadline && ( + + )} +
+ ); +} diff --git a/frontend/src/components/kanban/TaskEditForm.tsx b/frontend/src/components/kanban/TaskEditForm.tsx index c1895af3..48e3c8e8 100644 --- a/frontend/src/components/kanban/TaskEditForm.tsx +++ b/frontend/src/components/kanban/TaskEditForm.tsx @@ -26,6 +26,10 @@ interface TaskEditFormProps { editBody: string; editGithubUrl: string; editTags: string[]; + /** Date-only ISO ``YYYY-MM-DD`` or empty string. */ + editScheduled: string; + /** Date-only ISO ``YYYY-MM-DD`` or empty string. */ + editDeadline: string; allTags: TagDef[]; isSaving: boolean; onCustomerChange: (v: string) => void; @@ -33,6 +37,8 @@ interface TaskEditFormProps { onBodyChange: (v: string) => void; onGithubUrlChange: (v: string) => void; onTagsChange: (tags: string[]) => void; + onScheduledChange: (v: string) => void; + onDeadlineChange: (v: string) => void; onSave: () => void; onCancel: () => void; } @@ -48,6 +54,8 @@ export function TaskEditForm({ editBody, editGithubUrl, editTags, + editScheduled, + editDeadline, allTags, isSaving, onCustomerChange, @@ -55,9 +63,12 @@ export function TaskEditForm({ onBodyChange, onGithubUrlChange, onTagsChange, + onScheduledChange, + onDeadlineChange, onSave, onCancel, }: TaskEditFormProps) { + const { t } = useTranslation("kanban"); const { t: tc } = useTranslation("common"); function handleKeyDown(e: React.KeyboardEvent) { @@ -109,6 +120,39 @@ export function TaskEditForm({ inputClassName={editInputCls} />
+
e.stopPropagation()} + className="flex gap-1.5" + > + + +
e.stopPropagation()} > diff --git a/frontend/src/hooks/useTasks.ts b/frontend/src/hooks/useTasks.ts index bc60fb20..2346fbcf 100644 --- a/frontend/src/hooks/useTasks.ts +++ b/frontend/src/hooks/useTasks.ts @@ -87,6 +87,8 @@ export function useUpdateTask() { status?: string; body?: string; github_url?: string; + scheduled?: string; + deadline?: string; }; }) => updateTask(taskId, updates), onSuccess: () => { diff --git a/frontend/src/locales/de/kanban.json b/frontend/src/locales/de/kanban.json index b15061a4..efd7e8cb 100644 --- a/frontend/src/locales/de/kanban.json +++ b/frontend/src/locales/de/kanban.json @@ -4,6 +4,12 @@ "newTask": "Neue Aufgabe (Doppeltipp B)", "collapseColumn": "Spalte einklappen", "expandColumn": "Spalte ausklappen", + "scheduledLabel": "Geplant", + "deadlineLabel": "Frist", + "snoozedCount": "{{count}} geplant", + "snoozedTooltip": "Aufgaben, die auf ein zukünftiges Datum warten", + "wake": "Anzeigen", + "close": "Schließen", "empty": "Leer", "addTask": "Aufgabe hinzuf\u00fcgen", "taskTitle": "Aufgabentitel", diff --git a/frontend/src/locales/en/kanban.json b/frontend/src/locales/en/kanban.json index 8262f53f..73e6c85d 100644 --- a/frontend/src/locales/en/kanban.json +++ b/frontend/src/locales/en/kanban.json @@ -4,6 +4,12 @@ "newTask": "New task (double-tap B)", "collapseColumn": "Collapse column", "expandColumn": "Expand column", + "scheduledLabel": "Scheduled", + "deadlineLabel": "Deadline", + "snoozedCount": "{{count}} snoozed", + "snoozedTooltip": "Tasks waiting for a future date", + "wake": "Wake", + "close": "Close", "empty": "Empty", "addTask": "Add task", "taskTitle": "Task title", diff --git a/frontend/src/locales/es/kanban.json b/frontend/src/locales/es/kanban.json index 7870bb62..4f2e23ad 100644 --- a/frontend/src/locales/es/kanban.json +++ b/frontend/src/locales/es/kanban.json @@ -4,6 +4,12 @@ "newTask": "Nueva tarea (doble clic en B)", "collapseColumn": "Colapsar columna", "expandColumn": "Expandir columna", + "scheduledLabel": "Programada", + "deadlineLabel": "Fecha l\u00edmite", + "snoozedCount": "{{count}} pospuestas", + "snoozedTooltip": "Tareas en espera de una fecha futura", + "wake": "Mostrar", + "close": "Cerrar", "empty": "Vac\u00edo", "addTask": "A\u00f1adir tarea", "taskTitle": "T\u00edtulo de la tarea", diff --git a/frontend/src/locales/ru/kanban.json b/frontend/src/locales/ru/kanban.json index 8e196b0e..280f21dc 100644 --- a/frontend/src/locales/ru/kanban.json +++ b/frontend/src/locales/ru/kanban.json @@ -4,6 +4,12 @@ "newTask": "Новая задача (двойное нажатие B)", "collapseColumn": "Свернуть колонку", "expandColumn": "Развернуть колонку", + "scheduledLabel": "Запланировано", + "deadlineLabel": "Срок", + "snoozedCount": "{{count}} отложено", + "snoozedTooltip": "Задачи, ожидающие будущей даты", + "wake": "Показать", + "close": "Закрыть", "empty": "Пусто", "addTask": "Добавить задачу", "taskTitle": "Название задачи", diff --git a/frontend/src/utils/deadlineAck.ts b/frontend/src/utils/deadlineAck.ts new file mode 100644 index 00000000..214195be --- /dev/null +++ b/frontend/src/utils/deadlineAck.ts @@ -0,0 +1,69 @@ +/** + * Per-device, per-deadline acknowledgement state. + * + * Deadlines are a task property that crosses the wire and + * stays on the task forever; the *badge* on the card is a + * local wake-up call that the user can mute without + * losing the underlying date. Each device acknowledges + * independently because the cue is a UI nudge, not a + * piece of shared state. + * + * The ack is keyed by ``(task_id, deadline_date)`` so + * changing the deadline re-fires the badge with the new + * date — silently moving a deadline forward should not + * keep the previous ack alive. + * + * Stored as a Set in a single localStorage key under the + * profile-scoped helper, both for compact storage and so + * a "clear all acks" admin action could land later in one + * write. + */ +import { + profileGet, + profileSet, +} from "./profileStorage"; + +const STORAGE_KEY = "task_deadline_acks"; + +function load(): Set { + const raw = profileGet(STORAGE_KEY); + if (!raw) return new Set(); + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) { + return new Set( + parsed.filter( + (item): item is string => + typeof item === "string", + ), + ); + } + } catch { + // Corrupted value — start fresh rather than crash. + } + return new Set(); +} + +function persist(state: Set): void { + profileSet( + STORAGE_KEY, JSON.stringify(Array.from(state)), + ); +} + +function key(taskId: string, deadline: string): string { + return `${taskId}:${deadline}`; +} + +export function isDeadlineAcked( + taskId: string, deadline: string, +): boolean { + return load().has(key(taskId, deadline)); +} + +export function ackDeadline( + taskId: string, deadline: string, +): void { + const state = load(); + state.add(key(taskId, deadline)); + persist(state); +}