From 375b91e9689d4fad4b9a54d42ed33b366106521a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 16:39:00 +0000 Subject: [PATCH] feat: 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 --- src/components/TaskTrackingPanel.tsx | 220 +++++++++++++++++++++++++++ src/contexts/TimeTrackingContext.tsx | 70 ++++++++- src/hooks/useReportSummary.ts | 7 +- src/pages/Index.tsx | 148 +++++++++--------- src/pages/Report.tsx | 4 +- src/services/dataService.ts | 6 +- src/services/localStorageService.ts | 31 +++- src/services/supabaseService.ts | 91 ++++++++++- src/utils/checklistUtils.ts | 47 ++++++ src/utils/reportUtils.ts | 30 +++- supabase/schema.sql | 31 ++++ 11 files changed, 601 insertions(+), 84 deletions(-) create mode 100644 src/components/TaskTrackingPanel.tsx create mode 100644 src/utils/checklistUtils.ts diff --git a/src/components/TaskTrackingPanel.tsx b/src/components/TaskTrackingPanel.tsx new file mode 100644 index 0000000..097dba8 --- /dev/null +++ b/src/components/TaskTrackingPanel.tsx @@ -0,0 +1,220 @@ +// src/components/TaskTrackingPanel.tsx +// A persistent task-tracking panel that combines: +// 1. A standalone to-do list (stored via DataService, synced across devices for auth users) +// 2. GFM checklist items extracted from current-day task descriptions + +import { useState, KeyboardEvent } from "react"; +import { useTimeTracking } from "@/hooks/useTimeTracking"; +import { parseTaskChecklist, toggleDescriptionChecklistItem } from "@/utils/checklistUtils"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { CheckboxIcon, TrashIcon, PlusIcon } from "@radix-ui/react-icons"; + +export function TaskTrackingPanel() { + const { + todoItems, + addTodoItem, + toggleTodoItem, + deleteTodoItem, + clearCompletedTodos, + tasks, + isDayStarted, + updateTask + } = useTimeTracking(); + + const [inputValue, setInputValue] = useState(""); + + const activeTodos = todoItems.filter((item) => !item.completed); + const completedTodos = todoItems.filter((item) => item.completed); + + // Gather checklist items from current-day task descriptions + const taskChecklists = isDayStarted + ? tasks + .map((task) => ({ + task, + entries: parseTaskChecklist(task.description ?? "") + })) + .filter(({ entries }) => entries.length > 0) + : []; + + function handleAdd() { + const trimmed = inputValue.trim(); + if (!trimmed) return; + addTodoItem(trimmed); + setInputValue(""); + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") handleAdd(); + } + + function handleTaskChecklistToggle(taskId: string, description: string, lineIndex: number) { + const updated = toggleDescriptionChecklistItem(description, lineIndex); + updateTask(taskId, { description: updated }); + } + + return ( + + + + + Task Tracking + + + + {/* Add new to-do */} +
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + className="h-8 text-sm" + /> + +
+ + {/* Active to-dos */} + {activeTodos.length > 0 && ( +
    + {activeTodos.map((item) => ( +
  • + toggleTodoItem(item.id)} + className="mt-0.5 shrink-0" + /> + + +
  • + ))} +
+ )} + + {activeTodos.length === 0 && completedTodos.length === 0 && taskChecklists.length === 0 && ( +

+ No to-do items yet. Add one above. +

+ )} + + {/* Completed to-dos */} + {completedTodos.length > 0 && ( +
+
+ + Completed ({completedTodos.length}) + + +
+
    + {completedTodos.map((item) => ( +
  • + toggleTodoItem(item.id)} + className="mt-0.5 shrink-0" + /> + + +
  • + ))} +
+
+ )} + + {/* Checklist items from task descriptions */} + {taskChecklists.length > 0 && ( + <> + +
+ + From Tasks + + {taskChecklists.map(({ task, entries }) => ( +
+

+ {task.title} +

+
    + {entries.map((entry) => ( +
  • + + handleTaskChecklistToggle( + task.id, + task.description ?? "", + entry.lineIndex + ) + } + className="mt-0.5 shrink-0" + /> + +
  • + ))} +
+
+ ))} +
+ + )} +
+
+ ); +} diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index 61c67eb..1d2ef1d 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -61,6 +61,14 @@ export interface Project { isBillable?: boolean; } +export interface TodoItem { + id: string; + text: string; + completed: boolean; + createdAt: string; // ISO string + completedAt?: string; // ISO string — set when toggled to done, cleared when toggled back +} + export interface TimeEntry { id: string; date: string; @@ -103,6 +111,13 @@ interface TimeTrackingContextType { // Archive state archivedDays: DayRecord[]; + // Todo items + todoItems: TodoItem[]; + addTodoItem: (text: string) => void; + toggleTodoItem: (id: string) => void; + deleteTodoItem: (id: string) => void; + clearCompletedTodos: () => void; + // Projects and clients projects: Project[]; @@ -206,6 +221,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ convertDefaultProjects(DEFAULT_PROJECTS) ); const [categories, setCategories] = useState([]); + const [todoItems, setTodoItems] = useState([]); const [loading, setLoading] = useState(true); const [isSyncing, setIsSyncing] = useState(false); const [lastSyncTime, setLastSyncTime] = useState(null); @@ -302,6 +318,10 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ setCategories(DEFAULT_CATEGORIES); } + // Load todos + const loadedTodos = await dataService.getTodos(); + setTodoItems(loadedTodos); + // If switching from localStorage to Supabase, migrate data if (currentAuthStateRef.current && dataService) { await dataService.migrateFromLocalStorage(); @@ -383,7 +403,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ stableSaveCurrentDay(), dataService.saveProjects(projects), dataService.saveCategories(categories), - dataService.saveArchivedDays(archivedDays) + dataService.saveArchivedDays(archivedDays), + dataService.saveTodos(todoItems) ]); const failed = results.filter((r) => r.status === "rejected"); @@ -403,7 +424,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ } finally { setIsSyncing(false); } - }, [dataService, stableSaveCurrentDay, projects, categories, archivedDays]); + }, [dataService, stableSaveCurrentDay, projects, categories, archivedDays, todoItems]); // Load current day data (for periodic sync) const loadCurrentDay = useCallback(async () => { @@ -866,6 +887,46 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ ): InvoiceData => utilGenerateInvoiceData(archivedDays, projects, categories, clientName, startDate, endDate); + const addTodoItem = useCallback(async (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + const newItem: TodoItem = { + id: `todo-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + text: trimmed, + completed: false, + createdAt: new Date().toISOString() + }; + const updated = [...todoItems, newItem]; + setTodoItems(updated); + if (dataService) await dataService.saveTodos(updated); + }, [todoItems, dataService]); + + const toggleTodoItem = useCallback(async (id: string) => { + const updated = todoItems.map((item) => { + if (item.id !== id) return item; + const nowCompleted = !item.completed; + return { + ...item, + completed: nowCompleted, + completedAt: nowCompleted ? new Date().toISOString() : undefined + }; + }); + setTodoItems(updated); + if (dataService) await dataService.saveTodos(updated); + }, [todoItems, dataService]); + + const deleteTodoItem = useCallback(async (id: string) => { + const updated = todoItems.filter((item) => item.id !== id); + setTodoItems(updated); + if (dataService) await dataService.saveTodos(updated); + }, [todoItems, dataService]); + + const clearCompletedTodos = useCallback(async () => { + const updated = todoItems.filter((item) => !item.completed); + setTodoItems(updated); + if (dataService) await dataService.saveTodos(updated); + }, [todoItems, dataService]); + const importFromCSV = async ( csvContent: string ): Promise<{ success: boolean; message: string; importedCount: number }> => { @@ -917,6 +978,11 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ refreshFromDatabase: loadCurrentDay, forceSyncToDatabase, archivedDays, + todoItems, + addTodoItem, + toggleTodoItem, + deleteTodoItem, + clearCompletedTodos, projects, categories, startDay, diff --git a/src/hooks/useReportSummary.ts b/src/hooks/useReportSummary.ts index daa7c87..bf83d6d 100644 --- a/src/hooks/useReportSummary.ts +++ b/src/hooks/useReportSummary.ts @@ -14,6 +14,7 @@ import { ReportTone, buildSummaryPrompt, } from "@/utils/reportUtils"; +import { TodoItem } from "@/contexts/TimeTrackingContext"; import { supabase } from "@/lib/supabase"; // --------------------------------------------------------------------------- @@ -30,7 +31,7 @@ export interface UseReportSummaryReturn { summary: string; state: GenerationState; error: string | null; - generate: (week: WeekGroup, tone: ReportTone) => Promise; + generate: (week: WeekGroup, tone: ReportTone, todos?: TodoItem[]) => Promise; updateSummary: (value: string) => void; reset: () => void; } @@ -142,13 +143,13 @@ export function useReportSummary(): UseReportSummaryReturn { const [error, setError] = useState(null); const generate = useCallback( - async (week: WeekGroup, tone: ReportTone) => { + async (week: WeekGroup, tone: ReportTone, todos?: TodoItem[]) => { setState("loading"); setError(null); setSummary(""); try { - const { system, userMessage } = buildSummaryPrompt(week, tone); + const { system, userMessage } = buildSummaryPrompt(week, tone, todos); // API key stays server-side in the Edge Function. // The client posts the prompt; the proxy injects the key and forwards to Gemini. diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 72934a1..64432d0 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { CirclePlay, CircleStop, Archive as Play } from 'lucide-react'; import { DashboardIcon } from '@radix-ui/react-icons'; import SiteNavigationMenu from '@/components/Navigation'; +import { TaskTrackingPanel } from '@/components/TaskTrackingPanel'; import { useState } from 'react'; const TimeTrackerContent = () => { @@ -91,9 +92,15 @@ const TimeTrackerContent = () => { return (
- {/* Main Content */} - {!isDayStarted ? ( -
+
+ setShowStartDayDialog(false)} + onStartDay={handleStartDayWithDateTime} + /> + + {/* Dashboard header + stats (day not started) */} + {!isDayStarted && (

@@ -101,7 +108,6 @@ const TimeTrackerContent = () => { Dashboard

- {/* Summary Stats */}
@@ -121,71 +127,77 @@ const TimeTrackerContent = () => {
-
- ) : null} -
- setShowStartDayDialog(false)} - onStartDay={handleStartDayWithDateTime} - /> - {!isDayStarted ? ( - - - - - Start Your Work Day - - - -

- Click the button below to start tracking your work time for - today. -

- -
-
- ) : ( - <> - - {tasks.length > 0 && ( -
-

Tasks ({tasks.length}) - {dayStartTime && ( -

- Day started at: {dayStartTime.toLocaleTimeString()} -

- )} -

- {tasks.map((task) => ( - - ))} -
- )} - - )} + + {/* Two-column layout: main content + tracking panel */} +
+ {/* Left column: day actions + tasks */} +
+ {!isDayStarted ? ( + + + + + Start Your Work Day + + + +

+ Click the button below to start tracking your work time for + today. +

+ +
+
+ ) : ( + <> + + {tasks.length > 0 && ( +
+

+ Tasks ({tasks.length}) + {dayStartTime && ( +

+ Day started at: {dayStartTime.toLocaleTimeString()} +

+ )} +

+ {tasks.map((task) => ( + + ))} +
+ )} + + + )} +
+ + {/* Right column: task tracking panel (always visible) */} +
+ +
+
); diff --git a/src/pages/Report.tsx b/src/pages/Report.tsx index 60cc2f7..f042bc2 100644 --- a/src/pages/Report.tsx +++ b/src/pages/Report.tsx @@ -388,7 +388,7 @@ function ErrorState({ // --------------------------------------------------------------------------- export default function Report() { - const { archivedDays: rawArchivedDays } = useTimeTracking(); + const { archivedDays: rawArchivedDays, todoItems } = useTimeTracking(); const archivedDays = useMemo( () => dayRecordsToArchivedDays(rawArchivedDays), [rawArchivedDays] @@ -445,7 +445,7 @@ export default function Report() { function handleGenerate() { if (!selectedWeek) return; - generate(selectedWeek, tone); + generate(selectedWeek, tone, todoItems); } const canGoPrev = !isCustomRange && calendarIndex < calendarWeeks.length - 1; diff --git a/src/services/dataService.ts b/src/services/dataService.ts index abb2303..bd18b8c 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -1,4 +1,4 @@ -import { DayRecord, Project } from "@/contexts/TimeTrackingContext"; +import { DayRecord, Project, TodoItem } from "@/contexts/TimeTrackingContext"; import { Task } from "@/contexts/TimeTrackingContext"; import { TaskCategory } from "@/config/categories"; import { LocalStorageService } from "@/services/localStorageService"; @@ -34,6 +34,10 @@ export interface DataService { saveCategories: (categories: TaskCategory[]) => Promise; getCategories: () => Promise; + // Todo items operations + saveTodos: (todos: TodoItem[]) => Promise; + getTodos: () => Promise; + // Migration operations migrateFromLocalStorage: () => Promise; migrateToLocalStorage: () => Promise; diff --git a/src/services/localStorageService.ts b/src/services/localStorageService.ts index d050f85..124bf5b 100644 --- a/src/services/localStorageService.ts +++ b/src/services/localStorageService.ts @@ -1,4 +1,4 @@ -import { Task, DayRecord, Project } from "@/contexts/TimeTrackingContext"; +import { Task, DayRecord, Project, TodoItem } from "@/contexts/TimeTrackingContext"; import { TaskCategory } from "@/config/categories"; import { DataService, CurrentDayData } from "@/services/dataService"; @@ -6,7 +6,8 @@ export const STORAGE_KEYS = { CURRENT_DAY: "timetracker_current_day", ARCHIVED_DAYS: "timetracker_archived_days", PROJECTS: "timetracker_projects", - CATEGORIES: "timetracker_categories" + CATEGORIES: "timetracker_categories", + TODOS: "timetracker_todos" }; // Increment this when the stored data format changes in a breaking way. @@ -165,6 +166,32 @@ export class LocalStorageService implements DataService { } } + async saveTodos(todos: TodoItem[]): Promise { + try { + localStorage.setItem(STORAGE_KEYS.TODOS, JSON.stringify({ data: todos, _v: SCHEMA_VERSION })); + } catch (error) { + console.warn("Failed to save todos to localStorage:", error); + } + } + + async getTodos(): Promise { + try { + const saved = localStorage.getItem(STORAGE_KEYS.TODOS); + if (!saved) return []; + const parsed = JSON.parse(saved); + const data: TodoItem[] = Array.isArray(parsed) ? parsed : parsed?.data; + if (!Array.isArray(parsed) && parsed?._v !== SCHEMA_VERSION) { + console.warn("localStorage todos schema mismatch — clearing stale data"); + localStorage.removeItem(STORAGE_KEYS.TODOS); + return []; + } + return Array.isArray(data) ? data : []; + } catch (error) { + console.error("Error loading todos from localStorage:", error); + return []; + } + } + async migrateFromLocalStorage(): Promise { // No-op for localStorage service } diff --git a/src/services/supabaseService.ts b/src/services/supabaseService.ts index 5a2c858..5814a14 100644 --- a/src/services/supabaseService.ts +++ b/src/services/supabaseService.ts @@ -9,7 +9,7 @@ import { clearDataCaches, trackAuthCall } from "@/lib/supabase"; -import { Task, DayRecord, Project } from "@/contexts/TimeTrackingContext"; +import { Task, DayRecord, Project, TodoItem } from "@/contexts/TimeTrackingContext"; import { TaskCategory } from "@/config/categories"; import { DataService, CurrentDayData } from "@/services/dataService"; import { LocalStorageService } from "@/services/localStorageService"; @@ -795,6 +795,81 @@ export class SupabaseService implements DataService { return result; } + async saveTodos(todos: TodoItem[]): Promise { + const user = await this.requireUser(); + + if (todos.length === 0) { + await supabase.from("todo_items").delete().eq("user_id", user.id); + trackDbCall("delete", "todo_items"); + return; + } + + const { data: existingTodos } = await supabase + .from("todo_items") + .select("id") + .eq("user_id", user.id); + trackDbCall("select", "todo_items"); + + const existingIds = new Set(existingTodos?.map((t: { id: string }) => t.id) || []); + const newIds = new Set(todos.map((t) => t.id)); + + const toDelete = Array.from(existingIds).filter((id) => !newIds.has(id)); + if (toDelete.length > 0) { + const { error: deleteError } = await supabase + .from("todo_items") + .delete() + .eq("user_id", user.id) + .in("id", toDelete); + trackDbCall("delete", "todo_items"); + if (deleteError) throw deleteError; + } + + const toUpsert = todos.map((item) => ({ + id: item.id, + user_id: user.id, + text: item.text, + completed: item.completed, + created_at: item.createdAt, + completed_at: item.completedAt ?? null + })); + + const { error } = await supabase + .from("todo_items") + .upsert(toUpsert, { onConflict: "id" }); + trackDbCall("upsert", "todo_items"); + if (error) throw error; + } + + async getTodos(): Promise { + const user = await this.requireUser(); + + const { data, error } = await supabase + .from("todo_items") + .select("*") + .eq("user_id", user.id) + .order("created_at", { ascending: true }); + trackDbCall("select", "todo_items"); + + if (error) { + console.error("❌ Error loading todos:", error); + throw error; + } + + return (data || []).map((row: { + id: string; + text: string; + completed: boolean; + created_at: string; + completed_at: string | null; + }) => ({ + id: row.id, + text: row.text, + completed: row.completed, + createdAt: row.created_at, + completedAt: row.completed_at ?? undefined + })); + } + async migrateFromLocalStorage(): Promise { try { const localService = new LocalStorageService(); @@ -803,14 +878,16 @@ export class SupabaseService implements DataService { const categories = await localService.getCategories(); const currentDay = await localService.getCurrentDay(); const archivedDays = await localService.getArchivedDays(); + const todos = await localService.getTodos(); const hasProjects = projects.length > 0; const hasCategories = categories.length > 0; const hasCurrentDay = currentDay && (currentDay.tasks.length > 0 || currentDay.isDayStarted); const hasArchivedDays = archivedDays.length > 0; + const hasTodos = todos.length > 0; - if (!hasProjects && !hasCategories && !hasCurrentDay && !hasArchivedDays) { + if (!hasProjects && !hasCategories && !hasCurrentDay && !hasArchivedDays && !hasTodos) { return; } @@ -854,12 +931,17 @@ export class SupabaseService implements DataService { if (hasCategories && existingCategories.length === 0) { await this.saveCategories(categories); } + + if (hasTodos) { + await this.saveTodos(todos); + } } else { if (hasProjects) await this.saveProjects(projects); if (hasCategories) await this.saveCategories(categories); if (hasCurrentDay) await this.saveCurrentDay(currentDay); if (hasArchivedDays) await this.saveArchivedDays(archivedDays); + if (hasTodos) await this.saveTodos(todos); } } catch (error) { @@ -875,6 +957,7 @@ export class SupabaseService implements DataService { const archivedDays = await this.getArchivedDays(); const projects = await this.getProjects(); const categories = await this.getCategories(); + const todos = await this.getTodos(); if (currentDay) { await localService.saveCurrentDay(currentDay); @@ -892,6 +975,10 @@ export class SupabaseService implements DataService { await localService.saveCategories(categories); } + if (todos.length > 0) { + await localService.saveTodos(todos); + } + } catch (error) { console.error("❌ Error migrating data to localStorage:", error); } diff --git a/src/utils/checklistUtils.ts b/src/utils/checklistUtils.ts new file mode 100644 index 0000000..0eb18be --- /dev/null +++ b/src/utils/checklistUtils.ts @@ -0,0 +1,47 @@ +// src/utils/checklistUtils.ts +// Utilities for parsing and toggling GFM task-list items inside task descriptions. +// Supports the `- [ ] text` (unchecked) and `- [x] text` (checked) syntax. + +export interface ChecklistEntry { + text: string; + completed: boolean; + lineIndex: number; // index into description.split('\n') — used for toggling +} + +/** + * Extracts all GFM task-list items from a markdown description string. + * Returns entries in the order they appear in the text. + */ +export function parseTaskChecklist(description: string): ChecklistEntry[] { + if (!description) return []; + + return description.split("\n").flatMap((line, lineIndex) => { + const unchecked = line.match(/^(\s*)-\s\[ \]\s(.+)/); + const checked = line.match(/^(\s*)-\s\[x\]\s(.+)/i); + if (unchecked) { + return [{ text: unchecked[2].trim(), completed: false, lineIndex }]; + } + if (checked) { + return [{ text: checked[2].trim(), completed: true, lineIndex }]; + } + return []; + }); +} + +/** + * Toggles the checked/unchecked state of a task-list item at `lineIndex` + * in the given description string. Returns the updated description. + */ +export function toggleDescriptionChecklistItem(description: string, lineIndex: number): string { + const lines = description.split("\n"); + const line = lines[lineIndex]; + if (!line) return description; + + if (line.match(/^(\s*)-\s\[ \]\s/)) { + lines[lineIndex] = line.replace(/^(\s*)-\s\[ \]\s/, "$1- [x] "); + } else if (line.match(/^(\s*)-\s\[x\]\s/i)) { + lines[lineIndex] = line.replace(/^(\s*)-\s\[x\]\s/i, "$1- [ ] "); + } + + return lines.join("\n"); +} diff --git a/src/utils/reportUtils.ts b/src/utils/reportUtils.ts index 26a5c06..1bbd437 100644 --- a/src/utils/reportUtils.ts +++ b/src/utils/reportUtils.ts @@ -3,7 +3,7 @@ // Data is sourced via the TimeTrackingContext (which handles both localStorage // and Supabase depending on auth state). -import { DayRecord } from '@/contexts/TimeTrackingContext'; +import { DayRecord, TodoItem } from '@/contexts/TimeTrackingContext'; // --------------------------------------------------------------------------- // Types @@ -257,8 +257,10 @@ const EXCLUDED_CATEGORIES = new Set([ * Serializes a WeekGroup into a lean, human-readable string suitable * for inclusion in the Anthropic API prompt. Strips IDs, timestamps, * and non-work tasks. Preserves the narrative content of descriptions. + * + * Optionally appends a section listing todos completed during the week. */ -export function serializeWeekForPrompt(week: WeekGroup): string { +export function serializeWeekForPrompt(week: WeekGroup, todos?: TodoItem[]): string { const lines: string[] = [`Week of ${week.label}`, '']; for (const day of week.days) { @@ -286,6 +288,25 @@ export function serializeWeekForPrompt(week: WeekGroup): string { lines.push(''); } + // Append completed todos that fall within this week's date range + if (todos && todos.length > 0) { + const weekStartMs = week.weekStart.getTime(); + const weekEndMs = week.weekEnd.getTime(); + const completedThisWeek = todos.filter((t) => { + if (!t.completed || !t.completedAt) return false; + const ts = new Date(t.completedAt).getTime(); + return ts >= weekStartMs && ts <= weekEndMs; + }); + + if (completedThisWeek.length > 0) { + lines.push('Completed to-dos this week:'); + for (const item of completedThisWeek) { + lines.push(`- ${item.text}`); + } + lines.push(''); + } + } + return lines.join('\n').trim(); } @@ -308,7 +329,8 @@ const TONE_INSTRUCTIONS: Record = { */ export function buildSummaryPrompt( week: WeekGroup, - tone: ReportTone = 'standup' + tone: ReportTone = 'standup', + todos?: TodoItem[] ): { system: string; userMessage: string } { const system = `You are a professional writing assistant that creates concise weekly work summaries from time tracking data. @@ -323,7 +345,7 @@ ${TONE_INSTRUCTIONS[tone]} Omit breaks, lunch, and any purely administrative tasks. If multiple days covered the same project or theme, synthesize them into a single coherent statement rather than repeating.`; - const userMessage = `Please summarize the following work week:\n\n${serializeWeekForPrompt(week)}`; + const userMessage = `Please summarize the following work week:\n\n${serializeWeekForPrompt(week, todos)}`; return { system, userMessage }; } diff --git a/supabase/schema.sql b/supabase/schema.sql index 46d963a..c4e88b6 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -193,6 +193,37 @@ CREATE POLICY "Users can update their own current day" ON current_day CREATE POLICY "Users can delete their own current day" ON current_day FOR DELETE USING (auth.uid() = user_id); +-- Create todo_items table +CREATE TABLE IF NOT EXISTS todo_items ( + id text PRIMARY KEY, + user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, + text text NOT NULL, + completed boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz, + inserted_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_todo_items_user_id ON todo_items(user_id); +CREATE INDEX IF NOT EXISTS idx_todo_items_created_at ON todo_items(created_at); + +CREATE TRIGGER trg_update_todo_items_updated_at +BEFORE UPDATE ON todo_items +FOR EACH ROW +EXECUTE PROCEDURE update_updated_at_column(); + +ALTER TABLE todo_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view their own todos" ON todo_items + FOR SELECT USING (auth.uid() = user_id); +CREATE POLICY "Users can insert their own todos" ON todo_items + FOR INSERT WITH CHECK (auth.uid() = user_id); +CREATE POLICY "Users can update their own todos" ON todo_items + FOR UPDATE USING (auth.uid() = user_id); +CREATE POLICY "Users can delete their own todos" ON todo_items + FOR DELETE USING (auth.uid() = user_id); + -- Migration: Add is_billable columns if they don't exist DO $$ BEGIN