diff --git a/AGENTS.md b/AGENTS.md index 3e75d29..8c3c114 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,15 @@ Timetraked is a React 18 + TypeScript time tracking PWA for freelancers and consultants, with dual storage (localStorage guest mode and optional Supabase cloud sync). +--- + +## Rules + +- NEVER add any "Co-Authored-By:" text to commits +- ALWAYS run the `pnpm check:design-tokens`, `pnpm lint:markdown`, and `pnpm check:a11y` before committing any code + +--- + ## Output Style - Be terse and direct. Avoid walls of text and verbose explanations. @@ -170,8 +179,9 @@ export const MyComponent = () => { | `src/components/PageLayout.tsx` | Thin page wrapper — delegates title/badge/actions to `PageTitleContext` via `usePageTitle` | | `src/components/BackdatedEntryDialog.tsx` | Multi-step dialog for logging past workdays; uses `addBackdatedDay` from context | | `src/hooks/useLongPress.ts` | 500 ms hold detector for context menu trigger on touch | -| `src/components/ui/responsive-select.tsx` | `ResponsiveSelect` — branches on `useIsMobile()` to render a native `` on mobile (OS picker) vs the Radix `Select` on desktop; flat `options` array API shared by `NewTaskForm`, `ProjectSheet`, `BackdatedEntryDialog`, `PlannedTaskDialog`, `ArchivedTaskRow`, `KanbanBoardControls` | | `src/components/PlannedTaskPicker.tsx` | Empty started-day card on the Dashboard: lists in-progress/to-do planned tasks with Start (`pullPlannedTaskToDay`) / Resume (`addPlannedTaskToDay`) buttons. Task Board cards and the Tasks page header can also start the day (`startDayAndPullPlannedTask`, "now" rounded to 15 min, no dialog) | +| `src/hooks/useTaskBoardView.ts` | Kanban board sort (priority/title/newest/oldest) and filter (project/client/category) state; persisted to `localStorage` (`timetracker_task_board_view`) only, never synced to Supabase. Consumed by `KanbanBoard.tsx`/`KanbanBoardControls.tsx` | | `src/components/ArchivedTaskRow.tsx` | Inline expandable row for editing a single task within `ArchiveEditDialog` (replaces the old `TaskEditInArchiveDialog` modal); disables Save and shows an inline alert when the end time is before the start | | `src/services/localStorageService/recovery.ts` | Lists/previews/restores localStorage schema-mismatch backup keys and desktop (Tauri) disk-backup snapshots; the read side of the write-only backup safety nets | | `src/hooks/useDataRecovery.ts` | Unifies browser (`recovery.ts`) and desktop (`useElectronBackup`'s `listDiskBackups`/`readDiskBackup`, Tauri-backed) backup sources behind one list/preview/restore API | diff --git a/CHANGELOG.md b/CHANGELOG.md index f67e863..e9d42dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Task board now has sort (Priority, Title A–Z, Newest first, Oldest first) and filter (project, client, category) controls above the columns, applying to all four status columns at once. Choices persist to `localStorage` (`timetracker_task_board_view`), not Supabase, so they're per-browser only. New `useTaskBoardView` hook holds the sort/filter state and reconciles it with `plannedTasks` + — `src/hooks/useTaskBoardView.ts` (new), `src/components/KanbanBoardControls.tsx` (new), `src/components/KanbanBoard.tsx` - Starting a day from the task board no longer requires a detour through the Dashboard. The Tasks page header has a "Start Day" button (starts at the current time, rounded to 15 minutes, without the start-time dialog), and each planned task's menu offers "Start Day and Pull" (or "Start Day and Resume" for in-progress tasks) when no day is running. New context method `startDayAndPullPlannedTask` starts the day and the task in one step; it is blocked while an ended day is still awaiting posting. Pull/add/start-day-and-pull share one internal helper, and `startNewTask` now delegates to an internal `beginTask` that takes the day state explicitly so a day and its first task can be created in the same tick without a stale-closure save — `src/contexts/TimeTrackingContext.tsx`, `src/components/PlannedTaskCard.tsx`, `src/pages/TaskList.tsx` - A started day with no tasks now shows a "Pick up a planned task" card on the Dashboard (up to five in-progress or to-do planned tasks with Start/Resume buttons, plus a link to the Task Board) instead of force-opening the new task form diff --git a/README-EXT.md b/README-EXT.md index 308463d..e3fae8a 100644 --- a/README-EXT.md +++ b/README-EXT.md @@ -408,6 +408,7 @@ src/ │ ├── ExportDialog.tsx │ ├── InstallPrompt.tsx │ ├── KanbanBoard.tsx # Kanban planning board +│ ├── KanbanBoardControls.tsx # Sort/filter bar above the board, backed by useTaskBoardView │ ├── KanbanColumn.tsx │ ├── KeyboardShortcutsDialog.tsx # "?" help dialog listing shortcuts (uses ui/kbd) │ ├── MarkdownDisplay.tsx @@ -447,6 +448,7 @@ src/ │ ├── usePageTitle.ts # Page title state management │ ├── useReportStorage.ts # Persist generated report summaries │ ├── useReportSummary.ts # Gemini AI summary generation +│ ├── useTaskBoardView.ts # Kanban board sort/filter state, persisted to localStorage │ └── useTimeTracking.tsx # Time tracking state management ├── lib/ │ ├── electronMenuActions.ts # Pending menu-action stash consumed by page-mount effects diff --git a/README.md b/README.md index 1ecc5b2..a03a3ae 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the full history of changes. - **Guest-mode data durability** — schema-mismatch backups, write-failure toasts, undo for hard deletes, Tauri desktop disk-based backup snapshots (independent of `localStorage`, including a final flush on app quit), and an in-app "Data Recovery" UI in Settings to preview and restore those backups - **Self-hosted SQL backend** — opt-in PostgreSQL/MySQL support via a small REST API (`server/`), alongside the existing Supabase and local storage modes - **Backdated entry creation** — "Add Past Entry" button on Archive page opens a multi-step dialog to log tasks for any past date -- **Kanban planning board** — drag-and-drop task planning view (`KanbanBoard`, `KanbanColumn`, `PlannedTaskCard`); start the day (and a task) straight from the board, or pick a planned task from the Dashboard's empty-day card (`PlannedTaskPicker`) +- **Kanban planning board** — drag-and-drop task planning view (`KanbanBoard`, `KanbanColumn`, `PlannedTaskCard`); start the day (and a task) straight from the board, or pick a planned task from the Dashboard's empty-day card (`PlannedTaskPicker`); sort and filter controls (by priority/title/date, and by project/client/category) apply across all columns and persist to `localStorage` - Persistent report summaries saved to localStorage; markdown preview/export in the report output panel - `AppSidebar` collapsible sidebar navigation with live session timer; `PageTitleContext` decouples page header state from layout diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx index 99a5c3b..c1cb57c 100644 --- a/src/components/KanbanBoard.tsx +++ b/src/components/KanbanBoard.tsx @@ -1,7 +1,9 @@ -import React from "react"; -import { PlannedTaskStatus } from "@/contexts/TimeTrackingContext"; +import React, { useMemo } from "react"; +import { PlannedTask, PlannedTaskStatus } from "@/contexts/TimeTrackingContext"; import { useTimeTracking } from "@/hooks/useTimeTracking"; +import { TaskSortOption, useTaskBoardView } from "@/hooks/useTaskBoardView"; import { KanbanColumn } from "@/components/KanbanColumn"; +import { KanbanBoardControls } from "@/components/KanbanBoardControls"; const COLUMNS: { status: PlannedTaskStatus; title: string }[] = [ { status: "todo", title: "To Do" }, @@ -10,19 +12,79 @@ const COLUMNS: { status: PlannedTaskStatus; title: string }[] = [ { status: "done", title: "Done" }, ]; +function uniqueSorted(values: (string | undefined)[]): string[] { + return Array.from(new Set(values.filter((v): v is string => !!v))).sort((a, b) => + a.localeCompare(b), + ); +} + +function sortTasks(tasks: PlannedTask[], sortBy: TaskSortOption) { + const sorted = [...tasks]; + switch (sortBy) { + case "title": + return sorted.sort((a, b) => a.title.localeCompare(b.title)); + case "newest": + return sorted.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + case "oldest": + return sorted.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + default: + return sorted.sort( + (a, b) => a.priority - b.priority || a.createdAt.localeCompare(b.createdAt), + ); + } +} + export const KanbanBoard: React.FC = () => { const { plannedTasks, isDayStarted, isDayStale } = useTimeTracking(); + const { + sortBy, + filters, + hasActiveFilters, + setSortBy, + setFilter, + clearFilters, + } = useTaskBoardView(); + + const projectOptions = useMemo( + () => uniqueSorted(plannedTasks.map((t) => t.project)), + [plannedTasks], + ); + const clientOptions = useMemo( + () => uniqueSorted(plannedTasks.map((t) => t.client)), + [plannedTasks], + ); + const categoryOptions = useMemo( + () => uniqueSorted(plannedTasks.map((t) => t.category)), + [plannedTasks], + ); + + const visibleTasks = useMemo( + () => + plannedTasks.filter( + (t) => + (filters.project === "all" || t.project === filters.project) && + (filters.client === "all" || t.client === filters.client) && + (filters.category === "all" || t.category === filters.category), + ), + [plannedTasks, filters], + ); const tasksByStatus = (status: PlannedTaskStatus) => - [...plannedTasks] - .filter((t) => t.status === status) - .sort( - (a, b) => - a.priority - b.priority || a.createdAt.localeCompare(b.createdAt), - ); + sortTasks(visibleTasks.filter((t) => t.status === status), sortBy); return ( <> +
{COLUMNS.map(({ status, title }) => ( void; + onFilterChange: (key: keyof TaskBoardFilters, value: string) => void; + onClearFilters: () => void; +} + +function toOptions(values: string[], allLabel: string): ResponsiveSelectOption[] { + return [ + { value: "all", label: allLabel }, + ...values.map((value) => ({ value, label: value })), + ]; +} + +export const KanbanBoardControls: React.FC = ({ + sortBy, + filters, + projectOptions, + clientOptions, + categoryOptions, + hasActiveFilters, + onSortChange, + onFilterChange, + onClearFilters, +}) => { + return ( +
+ onSortChange(value as TaskSortOption)} + options={SORT_OPTIONS} + aria-label="Sort tasks by" + className="w-[160px]" + /> + onFilterChange("project", value)} + options={toOptions(projectOptions, "All projects")} + aria-label="Filter by project" + className="w-[160px]" + /> + onFilterChange("client", value)} + options={toOptions(clientOptions, "All clients")} + aria-label="Filter by client" + className="w-[160px]" + /> + onFilterChange("category", value)} + options={toOptions(categoryOptions, "All categories")} + aria-label="Filter by category" + className="w-[160px]" + /> + {hasActiveFilters && ( + + )} +
+ ); +}; diff --git a/src/hooks/useTaskBoardView.ts b/src/hooks/useTaskBoardView.ts new file mode 100644 index 0000000..13a8846 --- /dev/null +++ b/src/hooks/useTaskBoardView.ts @@ -0,0 +1,81 @@ +import { useCallback, useState } from "react"; + +export type TaskSortOption = "priority" | "title" | "newest" | "oldest"; + +export interface TaskBoardFilters { + project: string; + client: string; + category: string; +} + +export interface TaskBoardViewState { + sortBy: TaskSortOption; + filters: TaskBoardFilters; +} + +const STORAGE_KEY = "timetracker_task_board_view"; + +const DEFAULT_STATE: TaskBoardViewState = { + sortBy: "priority", + filters: { project: "all", client: "all", category: "all" }, +}; + +function readState(): TaskBoardViewState { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_STATE; + const parsed = JSON.parse(raw); + return { + sortBy: parsed.sortBy ?? DEFAULT_STATE.sortBy, + filters: { ...DEFAULT_STATE.filters, ...parsed.filters }, + }; + } catch { + return DEFAULT_STATE; + } +} + +// View-only state (sort + filter choices for the Task board). Persisted to +// localStorage, never synced to Supabase — this is a per-browser UI +// preference, not app data. +export function useTaskBoardView() { + const [state, setState] = useState(readState); + + const persist = useCallback((next: TaskBoardViewState) => { + setState(next); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // localStorage unavailable; view choice won't persist across reloads + } + }, []); + + const setSortBy = useCallback( + (sortBy: TaskSortOption) => persist({ ...state, sortBy }), + [state, persist], + ); + + const setFilter = useCallback( + (key: keyof TaskBoardFilters, value: string) => + persist({ ...state, filters: { ...state.filters, [key]: value } }), + [state, persist], + ); + + const clearFilters = useCallback( + () => persist({ ...state, filters: DEFAULT_STATE.filters }), + [state, persist], + ); + + const hasActiveFilters = + state.filters.project !== "all" || + state.filters.client !== "all" || + state.filters.category !== "all"; + + return { + sortBy: state.sortBy, + filters: state.filters, + hasActiveFilters, + setSortBy, + setFilter, + clearFilters, + }; +}