Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<select>` on mobile (OS picker) vs the Radix `Select` on desktop; flat `options` array API shared by `NewTaskForm`, `ProjectSheet`, `BackdatedEntryDialog`, `PlannedTaskDialog`, `ArchivedTaskRow` |
| `src/components/ui/responsive-select.tsx` | `ResponsiveSelect` — branches on `useIsMobile()` to render a native `<select>` 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 |
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README-EXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
78 changes: 70 additions & 8 deletions src/components/KanbanBoard.tsx
Original file line number Diff line number Diff line change
@@ -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" },
Expand All @@ -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 (
<>
<KanbanBoardControls
sortBy={sortBy}
filters={filters}
projectOptions={projectOptions}
clientOptions={clientOptions}
categoryOptions={categoryOptions}
hasActiveFilters={hasActiveFilters}
onSortChange={setSortBy}
onFilterChange={setFilter}
onClearFilters={clearFilters}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{COLUMNS.map(({ status, title }) => (
<KanbanColumn
Expand Down
88 changes: 88 additions & 0 deletions src/components/KanbanBoardControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React from "react";
import { Button } from "@/components/ui/button";
import {
ResponsiveSelect,
ResponsiveSelectOption,
} from "@/components/ui/responsive-select";
import { X } from "lucide-react";
import {
TaskBoardFilters,
TaskSortOption,
} from "@/hooks/useTaskBoardView";

const SORT_OPTIONS: ResponsiveSelectOption[] = [
{ value: "priority", label: "Priority" },
{ value: "title", label: "Title (A–Z)" },
{ value: "newest", label: "Newest first" },
{ value: "oldest", label: "Oldest first" },
];

interface KanbanBoardControlsProps {
sortBy: TaskSortOption;
filters: TaskBoardFilters;
projectOptions: string[];
clientOptions: string[];
categoryOptions: string[];
hasActiveFilters: boolean;
onSortChange: (value: TaskSortOption) => 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<KanbanBoardControlsProps> = ({
sortBy,
filters,
projectOptions,
clientOptions,
categoryOptions,
hasActiveFilters,
onSortChange,
onFilterChange,
onClearFilters,
}) => {
return (
<div className="flex flex-wrap items-center gap-2 mb-4">
<ResponsiveSelect
value={sortBy}
onValueChange={(value) => onSortChange(value as TaskSortOption)}
options={SORT_OPTIONS}
aria-label="Sort tasks by"
className="w-[160px]"
/>
<ResponsiveSelect
value={filters.project}
onValueChange={(value) => onFilterChange("project", value)}
options={toOptions(projectOptions, "All projects")}
aria-label="Filter by project"
className="w-[160px]"
/>
<ResponsiveSelect
value={filters.client}
onValueChange={(value) => onFilterChange("client", value)}
options={toOptions(clientOptions, "All clients")}
aria-label="Filter by client"
className="w-[160px]"
/>
<ResponsiveSelect
value={filters.category}
onValueChange={(value) => onFilterChange("category", value)}
options={toOptions(categoryOptions, "All categories")}
aria-label="Filter by category"
className="w-[160px]"
/>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClearFilters}>
<X className="w-3 h-3 mr-1" />
Clear filters
</Button>
)}
</div>
);
};
81 changes: 81 additions & 0 deletions src/hooks/useTaskBoardView.ts
Original file line number Diff line number Diff line change
@@ -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<TaskBoardViewState>(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,
};
}
Loading