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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Timetraked is a React 18 + TypeScript time tracking PWA for freelancers and cons
- When fixing a bug, add a regression test as part of the same change.
- Verify version alignment between related dev tools (e.g., vitest + @vitest/coverage) when test errors appear.

### Test File Inventory (~376 JS/TS tests across 36 files, plus 17 Rust unit tests across `src-tauri/src/backup.rs`/`menu.rs`/`quit_flush.rs` run via `cargo test`)
### Test File Inventory (~390 JS/TS tests across 37 files, plus 17 Rust unit tests across `src-tauri/src/backup.rs`/`menu.rs`/`quit_flush.rs` run via `cargo test`)

| File | Coverage |
|---|---|
Expand All @@ -49,6 +49,7 @@ Timetraked is a React 18 + TypeScript time tracking PWA for freelancers and cons
| `src/components/SummaryOutput.test.tsx` | Summary output rendering |
| `src/components/NewTaskForm.test.tsx` | Form render, submit, validation, cancel, FAB toggle |
| `src/components/TaskItem.test.tsx` | Badge render, delete confirm, edit dialog |
| `src/components/PlannedTaskPicker.test.tsx` | Dashboard empty-day picker: ordering, filtering, start vs resume, list cap |
| `src/components/ArchiveEditDialog.test.tsx` | Archive edit dialog render and task editing flow |
| `src/components/ArchivedTaskRow.test.tsx` | Inline expandable archived task row editing |
| `src/components/dateParsing.test.ts` | Date parsing utilities |
Expand Down Expand Up @@ -170,6 +171,7 @@ export const MyComponent = () => {
| `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/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/components/ArchivedTaskRow.tsx` | Inline expandable row for editing a single task within `ArchiveEditDialog` (replaces the old `TaskEditInArchiveDialog` modal) |
| `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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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
— `src/components/PlannedTaskPicker.tsx` (new), `src/pages/Index.tsx`
- Users can now add tasks directly to an existing archived day from the ArchiveEditDialog — a toggleable "Add Task" button in the task table header opens an inline form (title, description, category, project, start/end time) that appends the task and recalculates the day's total duration on save. Context method `addTaskToArchivedDay` handles the optimistic update and persistence
— `src/components/ArchiveEditDialog.tsx`, `src/contexts/TimeTrackingContext.tsx`
- Scaffolded a Tauri iOS build target alongside the existing Mac/Windows desktop app, so the app can now be run in the iOS Simulator (`pnpm tauri:ios:init`, `pnpm tauri:ios:dev`) and eventually shipped to the App Store. Split `src-tauri/src/main.rs` into a shared `lib.rs` (`#[cfg_attr(mobile, tauri::mobile_entry_point)]`) with a thin binary wrapper, and added a `[lib]` crate target (`crate-type = ["staticlib", "cdylib", "rlib"]`) required for mobile builds. The native menu, self-hosted updater, and updater-triggered restart — none of which apply on iOS — are now compiled out on mobile via `#[cfg(desktop)]`; their Tauri permissions moved to a new desktop-only capabilities file (`capabilities/desktop.json`, scoped via `"platforms"`). Added `src-tauri/tauri.ios.conf.json` to override the Tauri `identifier` to the App Store bundle ID (`com.adamjolicoeur.timetraked`) on iOS only, leaving the existing desktop identifier (and its app-data dir/keychain/updater identity) untouched. Also closed a touch-accessibility gap found during the investigation: the command palette (`Cmd/Ctrl+K`) previously had no on-screen trigger, only a keyboard shortcut and a desktop-only menu item — added a header button so it's reachable on iOS, which has neither. `src/lib/platform.ts` gained an `isIOS` check (touch-point-qualified, since iPadOS spoofs a "Macintosh" user agent) used to fix `isMac` misdetecting iPhone/iPad and to gate the startup silent-update check off on iOS. Corrected `index.html`'s CSP, which allowlisted a stale `capacitor://localhost` left over from an earlier Capacitor-based plan (this project ships via Tauri). CI/App Store Connect signing automation is an explicit follow-up, not included here
Expand Down Expand Up @@ -53,6 +57,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Editing a current-day task's start time no longer leaves the day starting after its own first task: `adjustTaskTime` now pulls `dayStartTime` earlier when a task is moved before it (moving a task later leaves the day start alone). It also now persists the change through `saveCurrentDay` like the other task mutations, instead of only flagging unsaved changes and waiting for the next sync
— `src/contexts/TimeTrackingContext.tsx`, `src/contexts/TimeTracking.test.tsx`
- Archived-day editing (`ArchiveEditDialog`, `ArchivedTaskRow`) had several accessibility gaps and unmemoized re-render/re-parse patterns. The "Restore" button no longer grabs focus via `autoFocus` when the dialog opens, letting Radix's default dialog focus behavior take over instead of landing keyboard focus on a state-changing action before the user has read anything. The day-summary and per-task edit toggles now announce their expanded/collapsed state via `aria-expanded`/`aria-controls`. The tasks table's header cells gained `scope="col"` and the table itself an accessible name tied to the day being edited. Per-task delete now requires a same-row Confirm/Cancel step instead of removing the row on a single click, matching the destructive-action pattern used elsewhere (the task isn't persisted until "Save Changes," so this doesn't need the full undo-toast machinery). On the performance side, the day/task dirty-check (`tasksChanged`) and the `handleTaskSave`/`handleTaskDelete`/toggle-expand callbacks are now memoized (`useMemo`/`useCallback`), and `ArchivedTaskRow`/`MarkdownDisplay` are wrapped in `React.memo`, so expanding one task row no longer re-renders every sibling row or re-parses unrelated markdown
— `src/components/ArchiveEditDialog.tsx`, `src/components/ArchiveEditDialog.test.tsx`, `src/components/ArchivedTaskRow.tsx`, `src/components/ArchivedTaskRow.test.tsx`, `src/components/MarkdownDisplay.tsx`
- `endDay` persisted the exact clock-out timestamp instead of rounding it to the nearest 15 minutes the way `startDay` already rounds the day's start time, so the archived day's `endTime` (and the last task's mirrored `endTime`) kept its raw seconds-precise value while `ArchiveEditDialog`'s Tasks table and day-summary form independently rounded the same values for display — producing a visible mismatch between the "posted"/persisted end time and what the edit dialog showed. `endDay` now rounds the effective end time at the source, so archived data and its display agree
Expand Down
1 change: 1 addition & 0 deletions README-EXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ src/
│ ├── PageLayout.tsx # Delegates title/badge/actions to PageTitleContext
│ ├── PlannedTaskCard.tsx
│ ├── PlannedTaskDialog.tsx
│ ├── PlannedTaskPicker.tsx
│ ├── ProjectManagement.tsx
│ ├── ProjectSheet.tsx
│ ├── PwaUpdatePrompt.tsx
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`)
- **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`)
- 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
27 changes: 26 additions & 1 deletion src/components/PlannedTaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
} from "@/components/ui/sheet";
import { MarkdownDisplay } from "@/components/MarkdownDisplay";
import { Badge } from "@/components/ui/badge";
import { Edit, Trash2, Play, MoveRight, MoreVertical, PencilIcon, Briefcase, Tag, Users, PlusCircle, Clock } from "lucide-react";
import { Edit, Trash2, Play, MoveRight, MoreVertical, PencilIcon, Briefcase, Tag, Users, PlusCircle, Clock, CirclePlay } from "lucide-react";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";

interface PlannedTaskCardProps {
Expand Down Expand Up @@ -78,6 +78,8 @@ const PlannedTaskCardComponent: React.FC<PlannedTaskCardProps> = ({
movePlannedTask,
pullPlannedTaskToDay,
addPlannedTaskToDay,
startDayAndPullPlannedTask,
tasks,
} = useTimeTracking();
const { confirmDelete } = useUndoableDelete<PlannedTask>();
const contextMenuTriggerRef = useRef<HTMLDivElement>(null);
Expand All @@ -95,6 +97,9 @@ const PlannedTaskCardComponent: React.FC<PlannedTaskCardProps> = ({
const category = categories.find((c) => c.id === task.category);
const canPull = isDayStarted && !isDayStale && task.status !== "done" && task.status !== "in_progress";
const canAddToDay = isDayStarted && !isDayStale && task.status === "in_progress";
// No day running and no ended-but-unposted day waiting: start one from here.
const canStartDayAndPull = !isDayStarted && tasks.length === 0 && task.status !== "done";
const startDayLabel = task.status === "in_progress" ? "Start Day and Resume" : "Start Day and Pull";

return (
<>
Expand Down Expand Up @@ -191,6 +196,17 @@ const PlannedTaskCardComponent: React.FC<PlannedTaskCardProps> = ({
<DropdownMenuSeparator />
</DropdownMenuGroup>
)}
{canStartDayAndPull && (
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => startDayAndPullPlannedTask(task.id)}
>
<CirclePlay className="w-3 h-3 mr-1" />
{startDayLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuGroup>
)}
{canAddToDay && (
<DropdownMenuGroup>
<DropdownMenuItem
Expand Down Expand Up @@ -259,6 +275,15 @@ const PlannedTaskCardComponent: React.FC<PlannedTaskCardProps> = ({
<ContextMenuSeparator />
</>
)}
{canStartDayAndPull && (
<>
<ContextMenuItem onClick={() => startDayAndPullPlannedTask(task.id)}>
<CirclePlay className="w-4 h-4 mr-2" />
{startDayLabel}
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{canAddToDay && (
<>
<ContextMenuItem onClick={() => addPlannedTaskToDay(task.id)}>
Expand Down
81 changes: 81 additions & 0 deletions src/components/PlannedTaskPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { PlannedTaskPicker } from "@/components/PlannedTaskPicker";
import type { PlannedTask } from "@/contexts/TimeTrackingContext";

const pullPlannedTaskToDay = vi.fn();
const addPlannedTaskToDay = vi.fn();
let plannedTasks: PlannedTask[] = [];

vi.mock("@/hooks/useTimeTracking", () => ({
useTimeTracking: () => ({ plannedTasks, pullPlannedTaskToDay, addPlannedTaskToDay }),
}));

const makeTask = (overrides: Partial<PlannedTask>): PlannedTask => ({
id: "t1",
title: "Task",
status: "todo",
priority: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
timeEntries: [],
timeSpent: 0,
...overrides,
}) as PlannedTask;

const renderPicker = () =>
render(
<MemoryRouter>
<PlannedTaskPicker />
</MemoryRouter>,
);

describe("PlannedTaskPicker", () => {
beforeEach(() => {
vi.clearAllMocks();
plannedTasks = [];
});

it("shows an empty message and a board link when nothing is startable", () => {
plannedTasks = [makeTask({ id: "d", title: "Finished", status: "done" })];
renderPicker();
expect(screen.getByText("No planned tasks are ready to start.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Open the Task Board" })).toHaveAttribute("href", "/tasks");
});

it("lists in-progress tasks first and omits done and blocked tasks", () => {
plannedTasks = [
makeTask({ id: "a", title: "Todo task", status: "todo", priority: 1 }),
makeTask({ id: "b", title: "Active task", status: "in_progress", priority: 2 }),
makeTask({ id: "c", title: "Blocked task", status: "blocked" }),
makeTask({ id: "d", title: "Done task", status: "done" }),
];
renderPicker();
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent("Active task");
expect(items[1]).toHaveTextContent("Todo task");
});

it("pulls a todo task and resumes an in-progress task", () => {
plannedTasks = [
makeTask({ id: "a", title: "Todo task", status: "todo" }),
makeTask({ id: "b", title: "Active task", status: "in_progress" }),
];
renderPicker();
fireEvent.click(screen.getByRole("button", { name: "Start Todo task" }));
expect(pullPlannedTaskToDay).toHaveBeenCalledWith("a");
fireEvent.click(screen.getByRole("button", { name: "Resume Active task" }));
expect(addPlannedTaskToDay).toHaveBeenCalledWith("b");
});

it("caps the list and links to the full board", () => {
plannedTasks = Array.from({ length: 7 }, (_, i) =>
makeTask({ id: `t${i}`, title: `Task ${i}`, priority: i }),
);
renderPicker();
expect(screen.getAllByRole("listitem")).toHaveLength(5);
expect(screen.getByRole("link", { name: "View all 7 on the Task Board" })).toBeInTheDocument();
});
});
90 changes: 90 additions & 0 deletions src/components/PlannedTaskPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React, { useMemo } from "react";
import { Link } from "react-router-dom";
import { useTimeTracking } from "@/hooks/useTimeTracking";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Briefcase, Play } from "lucide-react";

const MAX_VISIBLE = 5;

/**
* Empty-day state for the Dashboard: lists planned tasks that can be started
* right away so a day doesn't need a hand-typed first task. In-progress tasks
* come first (resume), then to-do by priority.
*/
export const PlannedTaskPicker: React.FC = () => {
const { plannedTasks, pullPlannedTaskToDay, addPlannedTaskToDay } = useTimeTracking();

const candidates = useMemo(
() =>
plannedTasks
.filter((t) => t.status === "in_progress" || t.status === "todo")
.sort(
(a, b) =>
Number(b.status === "in_progress") - Number(a.status === "in_progress") ||
a.priority - b.priority ||
a.createdAt.localeCompare(b.createdAt),
),
[plannedTasks],
);
const visible = candidates.slice(0, MAX_VISIBLE);

return (
<Card>
<CardHeader>
<CardTitle>Pick up a planned task</CardTitle>
<CardDescription>
Start from your task board, or use Add Task above for something new.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{visible.length === 0 ? (
<p className="text-sm text-muted-foreground">
No planned tasks are ready to start.
</p>
) : (
<ul className="space-y-2">
{visible.map((task) => {
const resuming = task.status === "in_progress";
return (
<li
key={task.id}
className="flex items-center justify-between gap-2 rounded-lg border p-2"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{task.title}</p>
{task.project && (
<Badge variant="outline" className="mt-1 border-mauve-7 text-mauve-11">
<Briefcase className="w-2.5 h-2.5 inline mr-0.5" />
{task.project}
</Badge>
)}
</div>
<Button
size="sm"
variant="outline"
aria-label={`${resuming ? "Resume" : "Start"} ${task.title}`}
onClick={() =>
resuming ? addPlannedTaskToDay(task.id) : pullPlannedTaskToDay(task.id)
}
>
<Play className="w-3 h-3 mr-1" />
{resuming ? "Resume" : "Start"}
</Button>
</li>
);
})}
</ul>
)}
<Button asChild variant="link" size="sm" className="px-0">
<Link to="/tasks">
{candidates.length > MAX_VISIBLE
? `View all ${candidates.length} on the Task Board`
: "Open the Task Board"}
</Link>
</Button>
</CardContent>
</Card>
);
};
Loading
Loading