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
220 changes: 220 additions & 0 deletions src/components/TaskTrackingPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>) {
if (e.key === "Enter") handleAdd();
}

function handleTaskChecklistToggle(taskId: string, description: string, lineIndex: number) {
const updated = toggleDescriptionChecklistItem(description, lineIndex);
updateTask(taskId, { description: updated });
}

return (
<Card className="h-fit">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<CheckboxIcon className="w-4 h-4" />
Task Tracking
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Add new to-do */}
<div className="flex gap-2">
<Input
placeholder="Add a to-do item…"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 text-sm"
/>
<Button
size="sm"
variant="outline"
onClick={handleAdd}
disabled={!inputValue.trim()}
className="h-8 px-2 shrink-0"
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>

{/* Active to-dos */}
{activeTodos.length > 0 && (
<ul className="space-y-2">
{activeTodos.map((item) => (
<li key={item.id} className="flex items-start gap-2 group">
<Checkbox
id={`todo-${item.id}`}
checked={false}
onCheckedChange={() => toggleTodoItem(item.id)}
className="mt-0.5 shrink-0"
/>
<label
htmlFor={`todo-${item.id}`}
className="flex-1 text-sm leading-snug cursor-pointer"
>
{item.text}
</label>
<Button
size="sm"
variant="ghost"
onClick={() => deleteTodoItem(item.id)}
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
>
<TrashIcon className="w-3 h-3" />
</Button>
</li>
))}
</ul>
)}

{activeTodos.length === 0 && completedTodos.length === 0 && taskChecklists.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
No to-do items yet. Add one above.
</p>
)}

{/* Completed to-dos */}
{completedTodos.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Completed ({completedTodos.length})
</span>
<Button
size="sm"
variant="ghost"
onClick={clearCompletedTodos}
className="h-5 text-xs text-muted-foreground hover:text-destructive px-1"
>
Clear all
</Button>
</div>
<ul className="space-y-2">
{completedTodos.map((item) => (
<li key={item.id} className="flex items-start gap-2 group">
<Checkbox
id={`todo-${item.id}`}
checked={true}
onCheckedChange={() => toggleTodoItem(item.id)}
className="mt-0.5 shrink-0"
/>
<label
htmlFor={`todo-${item.id}`}
className="flex-1 text-sm leading-snug line-through text-muted-foreground cursor-pointer"
>
{item.text}
</label>
<Button
size="sm"
variant="ghost"
onClick={() => deleteTodoItem(item.id)}
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
>
<TrashIcon className="w-3 h-3" />
</Button>
</li>
))}
</ul>
</div>
)}

{/* Checklist items from task descriptions */}
{taskChecklists.length > 0 && (
<>
<Separator />
<div className="space-y-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
From Tasks
</span>
{taskChecklists.map(({ task, entries }) => (
<div key={task.id} className="space-y-1.5">
<p className="text-xs font-medium text-foreground truncate" title={task.title}>
{task.title}
</p>
<ul className="space-y-1.5 pl-1">
{entries.map((entry) => (
<li
key={`${task.id}-${entry.lineIndex}`}
className="flex items-start gap-2"
>
<Checkbox
id={`task-check-${task.id}-${entry.lineIndex}`}
checked={entry.completed}
onCheckedChange={() =>
handleTaskChecklistToggle(
task.id,
task.description ?? "",
entry.lineIndex
)
}
className="mt-0.5 shrink-0"
/>
<label
htmlFor={`task-check-${task.id}-${entry.lineIndex}`}
className={
"flex-1 text-sm leading-snug cursor-pointer" +
(entry.completed ? " line-through text-muted-foreground" : "")
}
>
{entry.text}
</label>
</li>
))}
</ul>
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
);
}
70 changes: 68 additions & 2 deletions src/contexts/TimeTrackingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[];

Expand Down Expand Up @@ -206,6 +221,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
convertDefaultProjects(DEFAULT_PROJECTS)
);
const [categories, setCategories] = useState<TaskCategory[]>([]);
const [todoItems, setTodoItems] = useState<TodoItem[]>([]);
const [loading, setLoading] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 }> => {
Expand Down Expand Up @@ -917,6 +978,11 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
refreshFromDatabase: loadCurrentDay,
forceSyncToDatabase,
archivedDays,
todoItems,
addTodoItem,
toggleTodoItem,
deleteTodoItem,
clearCompletedTodos,
projects,
categories,
startDay,
Expand Down
7 changes: 4 additions & 3 deletions src/hooks/useReportSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ReportTone,
buildSummaryPrompt,
} from "@/utils/reportUtils";
import { TodoItem } from "@/contexts/TimeTrackingContext";
import { supabase } from "@/lib/supabase";

// ---------------------------------------------------------------------------
Expand All @@ -30,7 +31,7 @@ export interface UseReportSummaryReturn {
summary: string;
state: GenerationState;
error: string | null;
generate: (week: WeekGroup, tone: ReportTone) => Promise<void>;
generate: (week: WeekGroup, tone: ReportTone, todos?: TodoItem[]) => Promise<void>;
updateSummary: (value: string) => void;
reset: () => void;
}
Expand Down Expand Up @@ -142,13 +143,13 @@ export function useReportSummary(): UseReportSummaryReturn {
const [error, setError] = useState<string | null>(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.
Expand Down
Loading
Loading