diff --git a/CHANGELOG.md b/CHANGELOG.md index dc8e588..3e87f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Backdated entry creation — "Add Past Entry" button on the Archive page opens a multi-step dialog to log a full past workday (date picker, day-level start/end times, per-task time pickers with category/project selectors, markdown description support, live duration preview). Persists via `addBackdatedDay` which calls `dataService.saveArchivedDays` with optimistic rollback on failure. + — `src/components/BackdatedEntryDialog.tsx` (new), `src/contexts/TimeTrackingContext.tsx` (`addBackdatedDay` method), `src/pages/Archive.tsx` (CirclePlus "Add Past Entry" action button) + +### Fixed + +- `BackdatedEntryDialog` imported `useTimeTracking` from `@/contexts/TimeTrackingContext` (not exported there) — corrected to import the hook from `@/hooks/useTimeTracking` and types (`DayRecord`, `Task`) from the context + — `src/components/BackdatedEntryDialog.tsx` + ### Changed - iOS navigation bar redesigned as a floating pill — rounded-full shape, frosted-glass background (`rgba(255,255,255,0.80)`), drop shadow, and `mb-2 mx-2` margins replacing the full-width border-top bar diff --git a/CLAUDE.md b/CLAUDE.md index c018a1c..6c2647a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,7 @@ export const MyComponent = () => { | `src/components/PageLayout.tsx` | Shared page chrome (title + optional actions slot); renders `IosPageHeader` on iOS | | `src/components/IosPageHeader.tsx` | iOS-only sticky nav bar with safe-area-inset-top, back chevron, and action slot | | `src/components/ui/adaptive-dialog.tsx` | Renders vaul `Drawer` on iOS, Radix `Dialog` on web | +| `src/components/BackdatedEntryDialog.tsx` | Multi-step dialog for logging past workdays; uses `addBackdatedDay` from context | | `src/hooks/useHaptics.ts` | `@capacitor/haptics` wrapper (light/medium/heavy, success/error) | | `src/hooks/useAppLifecycle.ts` | `@capacitor/app` appStateChange hook for reliable background persistence | | `src/hooks/useLongPress.ts` | 500 ms hold detector for context menu trigger on touch | diff --git a/README.md b/README.md index 41154e7..0b449af 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A Progressive Web App (PWA) for time tracking built with React, TypeScript, and - **Custom Categories** — color-coded, billable/non-billable categorization - **Rich Text Notes** — GitHub Flavored Markdown in task descriptions - **Archive & Export** — permanent record with CSV, JSON, and invoice export formats +- **Backdated Entry Creation** — log work for past days directly from the Archive page via "Add Past Entry" - **CSV Import** — bring in existing time data from other tools - **Weekly Report** — AI-generated work summaries (standup, client, or retrospective tone) - **No Account Required** — full functionality with local storage; optional cloud sync via Supabase @@ -119,6 +120,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the full history of changes. **Recent highlights:** +- **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`) - Apple HIG pass for native iOS: bottom sheets, haptic feedback, status bar theming, page transitions, and long-press context menus - Persistent report summaries saved to localStorage; markdown preview/export in the report output panel diff --git a/src/components/BackdatedEntryDialog.tsx b/src/components/BackdatedEntryDialog.tsx new file mode 100644 index 0000000..ab3067a --- /dev/null +++ b/src/components/BackdatedEntryDialog.tsx @@ -0,0 +1,511 @@ +import React, { useState, useEffect } from "react"; +import { + AdaptiveDialog, + AdaptiveDialogContent, + AdaptiveDialogHeader, + AdaptiveDialogTitle, + AdaptiveDialogDescription, + AdaptiveDialogFooter, +} from "@/components/ui/adaptive-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { TimePicker } from "@/components/ui/scroll-time-picker"; +import { MarkdownDisplay } from "@/components/MarkdownDisplay"; +import { Badge } from "@/components/ui/badge"; +import { DayRecord, Task } from "@/contexts/TimeTrackingContext"; +import { useTimeTracking } from "@/hooks/useTimeTracking"; +import { useHaptics } from "@/hooks/useHaptics"; +import { formatDuration } from "@/utils/timeUtil"; +import { Calendar, Plus, Trash2, Loader2 } from "lucide-react"; + +interface BackdatedEntryDialogProps { + isOpen: boolean; + onClose: () => void; +} + +interface BackdatedTask { + id: string; + title: string; + description: string; + startTime: string; + endTime: string; + category: string; + project: string; +} + +function getYesterday(): string { + const d = new Date(); + d.setDate(d.getDate() - 1); + const year = d.getFullYear(); + const month = (d.getMonth() + 1).toString().padStart(2, "0"); + const day = d.getDate().toString().padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function getMaxDate(): string { + return getYesterday(); +} + +function buildDate(dateStr: string, timeStr: string): Date { + const [year, month, day] = dateStr.split("-").map(Number); + const [hours, minutes] = timeStr.split(":").map(Number); + return new Date(year, month - 1, day, hours || 0, minutes || 0, 0, 0); +} + +function calcDurationMs(start: string, end: string): number { + if (!start || !end) return 0; + const [sh, sm] = start.split(":").map(Number); + const [eh, em] = end.split(":").map(Number); + const startMs = sh * 60 * 60 * 1000 + sm * 60 * 1000; + const endMs = eh * 60 * 60 * 1000 + em * 60 * 1000; + return Math.max(0, endMs - startMs); +} + +function makeBlankTask(): BackdatedTask { + return { + id: Date.now().toString() + Math.random().toString(36).slice(2, 6), + title: "", + description: "", + startTime: "09:00", + endTime: "10:00", + category: "", + project: "", + }; +} + +export const BackdatedEntryDialog: React.FC = ({ + isOpen, + onClose, +}) => { + const { projects, categories, addBackdatedDay } = useTimeTracking(); + const { successNotify, errorNotify } = useHaptics(); + + const [selectedDate, setSelectedDate] = useState(getYesterday()); + const [dayStartTime, setDayStartTime] = useState("09:00"); + const [dayEndTime, setDayEndTime] = useState("17:00"); + const [notes, setNotes] = useState(""); + const [notesTab, setNotesTab] = useState<"edit" | "preview">("edit"); + const [tasks, setTasks] = useState([makeBlankTask()]); + const [isSaving, setIsSaving] = useState(false); + const [errors, setErrors] = useState([]); + + useEffect(() => { + if (isOpen) { + setSelectedDate(getYesterday()); + setDayStartTime("09:00"); + setDayEndTime("17:00"); + setNotes(""); + setNotesTab("edit"); + setTasks([makeBlankTask()]); + setIsSaving(false); + setErrors([]); + } + }, [isOpen]); + + const totalDuration = tasks.reduce( + (sum, t) => sum + calcDurationMs(t.startTime, t.endTime), + 0 + ); + + const validate = (): string[] => { + const errs: string[] = []; + if (!selectedDate) { + errs.push("Please select a date."); + } else { + const chosen = new Date(selectedDate + "T00:00:00"); + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (chosen >= today) { + errs.push("Date must be before today."); + } + } + if (tasks.length === 0) { + errs.push("Add at least one task."); + } + tasks.forEach((task, i) => { + if (!task.title.trim()) { + errs.push(`Task ${i + 1} needs a title.`); + } + if (task.startTime && task.endTime) { + const dur = calcDurationMs(task.startTime, task.endTime); + if (dur <= 0) { + errs.push(`Task ${i + 1} end time must be after start time.`); + } + } + }); + return errs; + }; + + const handleSave = async () => { + const errs = validate(); + if (errs.length > 0) { + setErrors(errs); + errorNotify(); + return; + } + setErrors([]); + setIsSaving(true); + + try { + const builtTasks: Task[] = tasks.map(t => { + const projectData = projects.find(p => p.id === t.project); + const startTime = buildDate(selectedDate, t.startTime); + const endTime = t.endTime ? buildDate(selectedDate, t.endTime) : undefined; + const duration = endTime + ? endTime.getTime() - startTime.getTime() + : undefined; + + return { + id: Date.now().toString() + Math.random().toString(36).slice(2, 6), + title: t.title.trim(), + description: t.description.trim() || undefined, + startTime, + endTime, + duration, + category: t.category || undefined, + project: projectData?.name, + client: projectData?.client, + }; + }); + + const dayStartDate = buildDate(selectedDate, dayStartTime); + const dayEndDate = buildDate(selectedDate, dayEndTime); + + const dayRecord: DayRecord = { + id: Date.now().toString(), + date: dayStartDate.toDateString(), + tasks: builtTasks, + totalDuration: builtTasks.reduce((s, t) => s + (t.duration ?? 0), 0), + startTime: dayStartDate, + endTime: dayEndDate, + notes: notes.trim() || undefined, + }; + + await addBackdatedDay(dayRecord); + successNotify(); + onClose(); + } catch { + // addBackdatedDay already shows a toast and calls errorNotify + } finally { + setIsSaving(false); + } + }; + + const addTask = () => { + const last = tasks[tasks.length - 1]; + const newTask = makeBlankTask(); + if (last) { + newTask.startTime = last.endTime || "09:00"; + const [h, m] = newTask.startTime.split(":").map(Number); + const endH = h + 1; + newTask.endTime = `${endH.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`; + } + setTasks(prev => [...prev, newTask]); + }; + + const removeTask = (id: string) => { + setTasks(prev => prev.filter(t => t.id !== id)); + }; + + const updateTask = (id: string, field: keyof BackdatedTask, value: string) => { + setTasks(prev => + prev.map(t => (t.id === id ? { ...t, [field]: value } : t)) + ); + }; + + const isValid = errors.length === 0 || true; + + return ( + + + + + + Add Past Entry + + + Record time you worked on a previous day. + + + +
+ {errors.length > 0 && ( +
+ {errors.map((e, i) => ( +

{e}

+ ))} +
+ )} + + {/* Day Details */} + + + Day Details + + +
+ + setSelectedDate(e.target.value)} + className="w-full" + /> +
+
+
+ + +
+
+ + +
+
+
+ + setNotesTab(v as "edit" | "preview")} + className="w-full" + > + + Edit + Preview + + +