Skip to content

Commit cfeb60c

Browse files
committed
Add task tracking panel with to-do list and task checklist integration
- Add TodoItem type to TimeTrackingContext with id, text, completed, createdAt, and completedAt fields; expose addTodoItem, toggleTodoItem, deleteTodoItem, clearCompletedTodos from context - Persist todos via DataService (localStorage for guests, Supabase for authenticated users) for cross-device consistency; add todo_items table DDL with RLS policies to supabase/schema.sql; update both migration methods to include todos on login/logout - Add checklistUtils.ts to parse and toggle GFM task-list items (- [ ] / - [x]) inside task descriptions without storing them separately - Add TaskTrackingPanel component: standalone to-do list with active and completed sections, plus a "From Tasks" section that surfaces checklist items from current-day task descriptions as interactive checkboxes; toggling a task-description item updates the task's description string - Update serializeWeekForPrompt and buildSummaryPrompt in reportUtils.ts to accept TodoItem[]; todos completed during the report week are appended to the AI prompt so weekly summaries reflect completed to-dos - Thread todos through useReportSummary.generate and Report.tsx - Update Index.tsx to a responsive two-column grid (lg+): tasks on the left, TaskTrackingPanel sticky on the right; panel also visible when day is not started https://claude.ai/code/session_01SdLnNUbmT6PXS4Y6zERcoD
1 parent fdd18f6 commit cfeb60c

11 files changed

Lines changed: 601 additions & 84 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// src/components/TaskTrackingPanel.tsx
2+
// A persistent task-tracking panel that combines:
3+
// 1. A standalone to-do list (stored via DataService, synced across devices for auth users)
4+
// 2. GFM checklist items extracted from current-day task descriptions
5+
6+
import { useState, KeyboardEvent } from "react";
7+
import { useTimeTracking } from "@/hooks/useTimeTracking";
8+
import { parseTaskChecklist, toggleDescriptionChecklistItem } from "@/utils/checklistUtils";
9+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
10+
import { Button } from "@/components/ui/button";
11+
import { Input } from "@/components/ui/input";
12+
import { Checkbox } from "@/components/ui/checkbox";
13+
import { Separator } from "@/components/ui/separator";
14+
import { CheckboxIcon, TrashIcon, PlusIcon } from "@radix-ui/react-icons";
15+
16+
export function TaskTrackingPanel() {
17+
const {
18+
todoItems,
19+
addTodoItem,
20+
toggleTodoItem,
21+
deleteTodoItem,
22+
clearCompletedTodos,
23+
tasks,
24+
isDayStarted,
25+
updateTask
26+
} = useTimeTracking();
27+
28+
const [inputValue, setInputValue] = useState("");
29+
30+
const activeTodos = todoItems.filter((item) => !item.completed);
31+
const completedTodos = todoItems.filter((item) => item.completed);
32+
33+
// Gather checklist items from current-day task descriptions
34+
const taskChecklists = isDayStarted
35+
? tasks
36+
.map((task) => ({
37+
task,
38+
entries: parseTaskChecklist(task.description ?? "")
39+
}))
40+
.filter(({ entries }) => entries.length > 0)
41+
: [];
42+
43+
function handleAdd() {
44+
const trimmed = inputValue.trim();
45+
if (!trimmed) return;
46+
addTodoItem(trimmed);
47+
setInputValue("");
48+
}
49+
50+
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
51+
if (e.key === "Enter") handleAdd();
52+
}
53+
54+
function handleTaskChecklistToggle(taskId: string, description: string, lineIndex: number) {
55+
const updated = toggleDescriptionChecklistItem(description, lineIndex);
56+
updateTask(taskId, { description: updated });
57+
}
58+
59+
return (
60+
<Card className="h-fit">
61+
<CardHeader className="pb-3">
62+
<CardTitle className="flex items-center gap-2 text-base">
63+
<CheckboxIcon className="w-4 h-4" />
64+
Task Tracking
65+
</CardTitle>
66+
</CardHeader>
67+
<CardContent className="space-y-4">
68+
{/* Add new to-do */}
69+
<div className="flex gap-2">
70+
<Input
71+
placeholder="Add a to-do item…"
72+
value={inputValue}
73+
onChange={(e) => setInputValue(e.target.value)}
74+
onKeyDown={handleKeyDown}
75+
className="h-8 text-sm"
76+
/>
77+
<Button
78+
size="sm"
79+
variant="outline"
80+
onClick={handleAdd}
81+
disabled={!inputValue.trim()}
82+
className="h-8 px-2 shrink-0"
83+
>
84+
<PlusIcon className="w-4 h-4" />
85+
</Button>
86+
</div>
87+
88+
{/* Active to-dos */}
89+
{activeTodos.length > 0 && (
90+
<ul className="space-y-2">
91+
{activeTodos.map((item) => (
92+
<li key={item.id} className="flex items-start gap-2 group">
93+
<Checkbox
94+
id={`todo-${item.id}`}
95+
checked={false}
96+
onCheckedChange={() => toggleTodoItem(item.id)}
97+
className="mt-0.5 shrink-0"
98+
/>
99+
<label
100+
htmlFor={`todo-${item.id}`}
101+
className="flex-1 text-sm leading-snug cursor-pointer"
102+
>
103+
{item.text}
104+
</label>
105+
<Button
106+
size="sm"
107+
variant="ghost"
108+
onClick={() => deleteTodoItem(item.id)}
109+
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
110+
>
111+
<TrashIcon className="w-3 h-3" />
112+
</Button>
113+
</li>
114+
))}
115+
</ul>
116+
)}
117+
118+
{activeTodos.length === 0 && completedTodos.length === 0 && taskChecklists.length === 0 && (
119+
<p className="text-xs text-muted-foreground text-center py-2">
120+
No to-do items yet. Add one above.
121+
</p>
122+
)}
123+
124+
{/* Completed to-dos */}
125+
{completedTodos.length > 0 && (
126+
<div className="space-y-2">
127+
<div className="flex items-center justify-between">
128+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
129+
Completed ({completedTodos.length})
130+
</span>
131+
<Button
132+
size="sm"
133+
variant="ghost"
134+
onClick={clearCompletedTodos}
135+
className="h-5 text-xs text-muted-foreground hover:text-destructive px-1"
136+
>
137+
Clear all
138+
</Button>
139+
</div>
140+
<ul className="space-y-2">
141+
{completedTodos.map((item) => (
142+
<li key={item.id} className="flex items-start gap-2 group">
143+
<Checkbox
144+
id={`todo-${item.id}`}
145+
checked={true}
146+
onCheckedChange={() => toggleTodoItem(item.id)}
147+
className="mt-0.5 shrink-0"
148+
/>
149+
<label
150+
htmlFor={`todo-${item.id}`}
151+
className="flex-1 text-sm leading-snug line-through text-muted-foreground cursor-pointer"
152+
>
153+
{item.text}
154+
</label>
155+
<Button
156+
size="sm"
157+
variant="ghost"
158+
onClick={() => deleteTodoItem(item.id)}
159+
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
160+
>
161+
<TrashIcon className="w-3 h-3" />
162+
</Button>
163+
</li>
164+
))}
165+
</ul>
166+
</div>
167+
)}
168+
169+
{/* Checklist items from task descriptions */}
170+
{taskChecklists.length > 0 && (
171+
<>
172+
<Separator />
173+
<div className="space-y-3">
174+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
175+
From Tasks
176+
</span>
177+
{taskChecklists.map(({ task, entries }) => (
178+
<div key={task.id} className="space-y-1.5">
179+
<p className="text-xs font-medium text-foreground truncate" title={task.title}>
180+
{task.title}
181+
</p>
182+
<ul className="space-y-1.5 pl-1">
183+
{entries.map((entry) => (
184+
<li
185+
key={`${task.id}-${entry.lineIndex}`}
186+
className="flex items-start gap-2"
187+
>
188+
<Checkbox
189+
id={`task-check-${task.id}-${entry.lineIndex}`}
190+
checked={entry.completed}
191+
onCheckedChange={() =>
192+
handleTaskChecklistToggle(
193+
task.id,
194+
task.description ?? "",
195+
entry.lineIndex
196+
)
197+
}
198+
className="mt-0.5 shrink-0"
199+
/>
200+
<label
201+
htmlFor={`task-check-${task.id}-${entry.lineIndex}`}
202+
className={
203+
"flex-1 text-sm leading-snug cursor-pointer" +
204+
(entry.completed ? " line-through text-muted-foreground" : "")
205+
}
206+
>
207+
{entry.text}
208+
</label>
209+
</li>
210+
))}
211+
</ul>
212+
</div>
213+
))}
214+
</div>
215+
</>
216+
)}
217+
</CardContent>
218+
</Card>
219+
);
220+
}

src/contexts/TimeTrackingContext.tsx

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ export interface Project {
6161
isBillable?: boolean;
6262
}
6363

64+
export interface TodoItem {
65+
id: string;
66+
text: string;
67+
completed: boolean;
68+
createdAt: string; // ISO string
69+
completedAt?: string; // ISO string — set when toggled to done, cleared when toggled back
70+
}
71+
6472
export interface TimeEntry {
6573
id: string;
6674
date: string;
@@ -103,6 +111,13 @@ interface TimeTrackingContextType {
103111
// Archive state
104112
archivedDays: DayRecord[];
105113

114+
// Todo items
115+
todoItems: TodoItem[];
116+
addTodoItem: (text: string) => void;
117+
toggleTodoItem: (id: string) => void;
118+
deleteTodoItem: (id: string) => void;
119+
clearCompletedTodos: () => void;
120+
106121
// Projects and clients
107122
projects: Project[];
108123

@@ -206,6 +221,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
206221
convertDefaultProjects(DEFAULT_PROJECTS)
207222
);
208223
const [categories, setCategories] = useState<TaskCategory[]>([]);
224+
const [todoItems, setTodoItems] = useState<TodoItem[]>([]);
209225
const [loading, setLoading] = useState(true);
210226
const [isSyncing, setIsSyncing] = useState(false);
211227
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
@@ -302,6 +318,10 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
302318
setCategories(DEFAULT_CATEGORIES);
303319
}
304320

321+
// Load todos
322+
const loadedTodos = await dataService.getTodos();
323+
setTodoItems(loadedTodos);
324+
305325
// If switching from localStorage to Supabase, migrate data
306326
if (currentAuthStateRef.current && dataService) {
307327
await dataService.migrateFromLocalStorage();
@@ -383,7 +403,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
383403
stableSaveCurrentDay(),
384404
dataService.saveProjects(projects),
385405
dataService.saveCategories(categories),
386-
dataService.saveArchivedDays(archivedDays)
406+
dataService.saveArchivedDays(archivedDays),
407+
dataService.saveTodos(todoItems)
387408
]);
388409

389410
const failed = results.filter((r) => r.status === "rejected");
@@ -403,7 +424,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
403424
} finally {
404425
setIsSyncing(false);
405426
}
406-
}, [dataService, stableSaveCurrentDay, projects, categories, archivedDays]);
427+
}, [dataService, stableSaveCurrentDay, projects, categories, archivedDays, todoItems]);
407428

408429
// Load current day data (for periodic sync)
409430
const loadCurrentDay = useCallback(async () => {
@@ -866,6 +887,46 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
866887
): InvoiceData =>
867888
utilGenerateInvoiceData(archivedDays, projects, categories, clientName, startDate, endDate);
868889

890+
const addTodoItem = useCallback(async (text: string) => {
891+
const trimmed = text.trim();
892+
if (!trimmed) return;
893+
const newItem: TodoItem = {
894+
id: `todo-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
895+
text: trimmed,
896+
completed: false,
897+
createdAt: new Date().toISOString()
898+
};
899+
const updated = [...todoItems, newItem];
900+
setTodoItems(updated);
901+
if (dataService) await dataService.saveTodos(updated);
902+
}, [todoItems, dataService]);
903+
904+
const toggleTodoItem = useCallback(async (id: string) => {
905+
const updated = todoItems.map((item) => {
906+
if (item.id !== id) return item;
907+
const nowCompleted = !item.completed;
908+
return {
909+
...item,
910+
completed: nowCompleted,
911+
completedAt: nowCompleted ? new Date().toISOString() : undefined
912+
};
913+
});
914+
setTodoItems(updated);
915+
if (dataService) await dataService.saveTodos(updated);
916+
}, [todoItems, dataService]);
917+
918+
const deleteTodoItem = useCallback(async (id: string) => {
919+
const updated = todoItems.filter((item) => item.id !== id);
920+
setTodoItems(updated);
921+
if (dataService) await dataService.saveTodos(updated);
922+
}, [todoItems, dataService]);
923+
924+
const clearCompletedTodos = useCallback(async () => {
925+
const updated = todoItems.filter((item) => !item.completed);
926+
setTodoItems(updated);
927+
if (dataService) await dataService.saveTodos(updated);
928+
}, [todoItems, dataService]);
929+
869930
const importFromCSV = async (
870931
csvContent: string
871932
): Promise<{ success: boolean; message: string; importedCount: number }> => {
@@ -917,6 +978,11 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
917978
refreshFromDatabase: loadCurrentDay,
918979
forceSyncToDatabase,
919980
archivedDays,
981+
todoItems,
982+
addTodoItem,
983+
toggleTodoItem,
984+
deleteTodoItem,
985+
clearCompletedTodos,
920986
projects,
921987
categories,
922988
startDay,

src/hooks/useReportSummary.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ReportTone,
1515
buildSummaryPrompt,
1616
} from "@/utils/reportUtils";
17+
import { TodoItem } from "@/contexts/TimeTrackingContext";
1718
import { supabase } from "@/lib/supabase";
1819

1920
// ---------------------------------------------------------------------------
@@ -30,7 +31,7 @@ export interface UseReportSummaryReturn {
3031
summary: string;
3132
state: GenerationState;
3233
error: string | null;
33-
generate: (week: WeekGroup, tone: ReportTone) => Promise<void>;
34+
generate: (week: WeekGroup, tone: ReportTone, todos?: TodoItem[]) => Promise<void>;
3435
updateSummary: (value: string) => void;
3536
reset: () => void;
3637
}
@@ -142,13 +143,13 @@ export function useReportSummary(): UseReportSummaryReturn {
142143
const [error, setError] = useState<string | null>(null);
143144

144145
const generate = useCallback(
145-
async (week: WeekGroup, tone: ReportTone) => {
146+
async (week: WeekGroup, tone: ReportTone, todos?: TodoItem[]) => {
146147
setState("loading");
147148
setError(null);
148149
setSummary("");
149150

150151
try {
151-
const { system, userMessage } = buildSummaryPrompt(week, tone);
152+
const { system, userMessage } = buildSummaryPrompt(week, tone, todos);
152153

153154
// API key stays server-side in the Edge Function.
154155
// The client posts the prompt; the proxy injects the key and forwards to Gemini.

0 commit comments

Comments
 (0)