diff --git a/CHANGELOG.md b/CHANGELOG.md index d15c110..d524eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed duplicate `generateTimeOptions()` helper functions from all dialog components ### Fixed +- Improved Weekly Report error messages to distinguish between distinct Gemini API failure modes + — `src/hooks/useReportSummary.ts` (added `classifyGeminiError()` and `classifyFinishReason()` helpers; 429 rate limit vs. quota exhaustion, 503 overload, 500 server error, 504 timeout, 403 bad key, 400 precondition, 404 model not found, network failures, and content-blocked responses each produce specific actionable messages instead of Gemini's generic "high demand" text) +- Fixed Weekly Report page only reading archived data from localStorage, causing empty reports for authenticated (Supabase) users + — `src/pages/Report.tsx`, `src/utils/reportUtils.ts` (replaced direct `localStorage.getItem()` call with `useTimeTracking().archivedDays` via new `dayRecordsToArchivedDays()` adapter; both storage modes now work correctly) - Resolved merge conflicts in `src/index.css` ## [0.21.1] - 2026-02-06 diff --git a/CLAUDE.md b/CLAUDE.md index 10e9dde..489fa6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md - AI Assistant Codebase Guide -**Last Updated:** 2026-02-06 -**Version:** 1.0.4 +**Last Updated:** 2026-03-09 +**Version:** 1.0.6 This document provides comprehensive guidance for AI assistants working with the TimeTracker Pro codebase. It covers architecture, conventions, workflows, and best practices. @@ -46,6 +46,7 @@ TimeTracker Pro is a modern time tracking application built for freelancers, con - **Archive System**: Permanent record of completed work days - **Export/Import**: CSV and JSON formats, database-compatible schema - **Revenue Tracking**: Automatic calculation based on hourly rates +- **Weekly Report**: AI-generated work summaries (standup, client, retrospective tones) sourced from `archivedDays` via context — works in both guest and authenticated modes - **Responsive Design**: Works on desktop and mobile ### Project Origin @@ -225,6 +226,7 @@ TimeTrackerPro/ │ │ ├── useAuth.tsx # Auth hook │ │ ├── useOffline.tsx # Offline state hook (PWA) │ │ ├── useTimeTracking.tsx # Time tracking hook +│ │ ├── useReportSummary.ts # Gemini API call + error classification for /report │ │ ├── use-toast.tsx # Toast notifications │ │ └── useRealtimeSync.ts # Database sync │ ├── lib/ # Utility libraries @@ -235,12 +237,14 @@ TimeTrackerPro/ │ │ ├── Archive.tsx # Archived days │ │ ├── ProjectList.tsx # Project management │ │ ├── Categories.tsx # Category management +│ │ ├── Report.tsx # AI weekly summary generator │ │ ├── Settings.tsx # App settings │ │ └── NotFound.tsx # 404 page │ ├── services/ # Business logic services │ │ └── dataService.ts # Data persistence layer │ ├── utils/ # Utility functions │ │ ├── timeUtil.ts # Time formatting helpers +│ │ ├── reportUtils.ts # Report grouping, serialization, and DayRecord→ArchivedDay adapter │ │ └── supabase.ts # Supabase utilities │ ├── App.tsx # Root component │ ├── main.tsx # Application entry point @@ -888,6 +892,22 @@ setTasks([...tasks, newTask]); await forceSyncToDatabase(); // Or wait for critical event ``` +#### 6. Bypassing the DataService (reading localStorage directly) +```typescript +// ❌ WRONG - Reads localStorage only; breaks for authenticated users whose +// data lives in Supabase, not localStorage +const raw = localStorage.getItem("timetracker_archived_days"); + +// ✅ CORRECT - Use the context, which routes through LocalStorageService +// or SupabaseService automatically +const { archivedDays } = useTimeTracking(); +``` + +Any utility that needs archived days must consume the context or accept +`DayRecord[]` as a parameter. Use `dayRecordsToArchivedDays()` from +`src/utils/reportUtils.ts` to convert to the `ArchivedDay[]` shape that +the report utils expect. + ### Architecture Gotchas #### 1. Manual Sync Required @@ -922,6 +942,26 @@ const { data: { user } } = await supabase.auth.getUser(); const user = await getCachedUser(); ``` +#### 5. Gemini API Error Classification + +Do **not** surface Gemini's raw `error.message` string to the user — it is often +vague ("high demand", "Resource has been exhausted") and gives no guidance on +whether to wait, fix a key, or switch plans. + +Always classify by HTTP status + `error.status` using `classifyGeminiError()` +in `src/hooks/useReportSummary.ts`. Key distinctions: + +| Symptom | HTTP | Gemini status | Meaning | +|---------|------|---------------|---------| +| Multiple consecutive failures | 503 | `UNAVAILABLE` | Server overloaded — retry in seconds | +| First request of the day fails | 429 | `RESOURCE_EXHAUSTED` + "quota" | Daily free-tier limit hit | +| Rapid retries fail | 429 | `RESOURCE_EXHAUSTED` + "rate" | Per-minute RPM limit — wait 30–60 s | +| Always fails | 403 | `PERMISSION_DENIED` | Bad API key | +| Always fails in region | 400 | `FAILED_PRECONDITION` | Free tier not available in region | + +Also handle `finishReason` on 200 OK responses: a `SAFETY` or `RECITATION` +block returns HTTP 200 with an empty `text` — use `classifyFinishReason()`. + ### Performance Gotchas #### 1. Avoiding Unnecessary Re-renders @@ -1063,6 +1103,8 @@ Before making changes, verify: | Version | Date | Changes | |---------|------|---------| +| 1.0.6 | 2026-03-09 | Added Gemini API error classification in `useReportSummary.ts`; added gotcha table for HTTP status mapping; added `useReportSummary.ts` to file organization | +| 1.0.5 | 2026-03-09 | Fixed Report page to use TimeTrackingContext instead of direct localStorage read; added `dayRecordsToArchivedDays()` adapter; added DataService bypass pitfall | | 1.0.4 | 2026-02-06 | Replaced custom scroll picker with native HTML5 time inputs for better UX and a11y | | 1.0.3 | 2026-02-06 | Added ScrollTimePicker component, replaced time dropdowns with scroll-wheel UI | | 1.0.2 | 2026-02-02 | Added auto-open New Task form feature when day starts | diff --git a/README.md b/README.md index c4f6529..9c9ca3b 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ TimeTracker Pro is a professional time tracking application that helps you monit - **Multiple Export Formats** - CSV, JSON, and invoice formats - **CSV Import** - Import existing time data from other tools - **Print Support** - Print-friendly archive views for physical records +- **Weekly Report** - AI-generated work summaries from archived days (standup, client, or retrospective tone); works in both guest and authenticated modes; errors surface specific, actionable guidance (rate limit vs. quota exhaustion vs. overload vs. key issues) ### Progressive Web App @@ -625,12 +626,14 @@ TimeTrackerPro/ │ │ ├── Archive.tsx # Archived days │ │ ├── ProjectList.tsx # Project management │ │ ├── Categories.tsx # Category management +│ │ ├── Report.tsx # AI weekly summary generator │ │ ├── Settings.tsx # App settings │ │ └── NotFound.tsx # 404 page │ ├── services/ # Data Layer │ │ └── dataService.ts # Persistence abstraction │ ├── utils/ # Utilities -│ │ └── timeUtil.ts # Time formatting +│ │ ├── timeUtil.ts # Time formatting +│ │ └── reportUtils.ts # Report grouping, formatting, and DayRecord adapter │ ├── App.tsx # Root component │ └── main.tsx # Entry point ├── public/ @@ -1066,6 +1069,8 @@ const MyPage = lazy(() => import("./pages/MyPage")); For a detailed list of changes, new features, and bug fixes, see [CHANGELOG.md](CHANGELOG.md). **Recent Updates:** +- Improved Weekly Report error messages to distinguish Gemini API failure modes (rate limit, quota, overload, key issues) +- Fixed Weekly Report for authenticated users (data now sourced from Supabase, not localStorage only) - Native HTML5 time inputs for intuitive, accessible time selection - Consistent UX with date inputs across all dialogs - Mobile-optimized with browser-native time pickers diff --git a/dev-dist/sw.js b/dev-dist/sw.js index a9749f8..2583aef 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -22,7 +22,7 @@ if (!self.define) { const singleRequire = (uri, parentUri) => { uri = new URL(uri + ".js", parentUri).href; return registry[uri] || ( - + new Promise(resolve => { if ("document" in self) { const script = document.createElement("script"); @@ -35,7 +35,7 @@ if (!self.define) { resolve(); } }) - + .then(() => { let promise = registry[uri]; if (!promise) { @@ -79,7 +79,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; */ workbox.precacheAndRoute([{ "url": "index.html", - "revision": "0.n3k1cm3l0gs" + "revision": "0.veugrm1c6no" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 639bb6f..b15d433 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png index b771851..392ae6e 100644 Binary files a/public/favicon-96x96.png and b/public/favicon-96x96.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 97f6c9f..b15d433 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..b15d433 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index 37c6f53..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..b15d433 Binary files /dev/null and b/public/icon.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..e951050 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/icon-96x96.png b/public/icons/icon-96x96.png index 2d86303..392ae6e 100644 Binary files a/public/icons/icon-96x96.png and b/public/icons/icon-96x96.png differ diff --git a/public/web-app-manifest-192x192.png b/public/web-app-manifest-192x192.png index 5c8a1cb..97d52b9 100644 Binary files a/public/web-app-manifest-192x192.png and b/public/web-app-manifest-192x192.png differ diff --git a/public/web-app-manifest-512x512.png b/public/web-app-manifest-512x512.png index be510be..6b370cc 100644 Binary files a/public/web-app-manifest-512x512.png and b/public/web-app-manifest-512x512.png differ diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index d966700..a9ede19 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -52,7 +52,7 @@ const SiteNavigationMenu = () => { className="flex items-center text-gray-900 hover:text-blue-700" > Logo diff --git a/src/hooks/useReportSummary.ts b/src/hooks/useReportSummary.ts index 76fc6a6..0716e96 100644 --- a/src/hooks/useReportSummary.ts +++ b/src/hooks/useReportSummary.ts @@ -37,9 +37,100 @@ export interface UseReportSummaryReturn { // Constants // --------------------------------------------------------------------------- -const GEMINI_MODEL = "gemini-3-flash-preview"; +const GEMINI_MODEL = "gemini-2.5-flash"; // Free-tier Gemini model as of June 2024. Check ai.google.dev for updates. const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"; +// --------------------------------------------------------------------------- +// Error classification +// --------------------------------------------------------------------------- + +interface GeminiErrorBody { + error?: { + code?: number; + message?: string; + status?: string; + }; +} + +/** + * Maps a Gemini API HTTP status + error body to a human-readable message. + * Distinguishes between quota exhaustion, rate limiting, overload, and + * configuration problems so the user knows what action to take. + */ +function classifyGeminiError(status: number, body: GeminiErrorBody, retryAfter: string | null): string { + const apiStatus = body?.error?.status ?? ""; + const apiMessage = body?.error?.message ?? ""; + const lower = apiMessage.toLowerCase(); + + switch (status) { + case 429: { + // RESOURCE_EXHAUSTED covers both per-minute rate limits and daily quota. + // Distinguish by the message text Gemini includes. + const waitHint = retryAfter ? ` Retry-After: ${retryAfter}s.` : " Wait 30–60 seconds and try again."; + if (lower.includes("quota") || lower.includes("exceeded") || lower.includes("billing")) { + return "Daily free-tier quota exhausted for this API key. Usage resets at midnight Pacific Time. Check your quota at aistudio.google.com, or try again tomorrow."; + } + return `Rate limit reached — too many requests in a short period.${waitHint}`; + } + + case 503: + // This is the actual "high demand / overloaded" error — distinct from quota. + return "The Gemini service is temporarily overloaded (HTTP 503). This is a server-side capacity issue, not a quota or key problem. Wait a few seconds and try again."; + + case 500: + // Often caused by an oversized input context on Gemini's end. + return "Gemini encountered an internal server error (HTTP 500). This may be caused by an unusually large date range. Try a shorter range, or wait a moment and retry."; + + case 504: + return "The request timed out before Gemini could respond (HTTP 504). Try a shorter date range or retry in a moment."; + + case 403: + if (apiStatus === "PERMISSION_DENIED") { + return "API key invalid or lacks permission for this model. Verify VITE_GEMINI_API_KEY at aistudio.google.com."; + } + return `Access denied (HTTP 403). Check your API key permissions.`; + + case 400: + if (apiStatus === "FAILED_PRECONDITION") { + return "This Gemini model is not available on the free tier in your region. Enable billing at console.cloud.google.com or use a different region."; + } + return `Invalid request (HTTP 400): ${apiMessage || "the prompt was malformed"}. Try a different date range.`; + + case 404: + return "Gemini model not found (HTTP 404). The model name in useReportSummary.ts may be outdated — check ai.google.dev for current model IDs."; + + default: + return apiMessage + ? `Gemini error (HTTP ${status}): ${apiMessage}` + : `Unexpected Gemini API error (HTTP ${status}). Try again in a moment.`; + } +} + +/** + * Maps a non-STOP finishReason to a human-readable message. + * Returns null if the reason is STOP or absent (normal completion). + */ +function classifyFinishReason(reason: string): string | null { + switch (reason) { + case "SAFETY": + return "The summary was blocked by Gemini safety filters. Try adjusting your task descriptions or selecting a different date range."; + case "RECITATION": + return "Gemini declined to generate the summary due to a potential content policy concern (recitation). Try rephrasing task descriptions."; + case "BLOCKLIST": + case "PROHIBITED_CONTENT": + case "SPII": + return "The summary was blocked by Gemini content policy. Check task descriptions for flagged content and try again."; + case "MAX_TOKENS": + // Handled before this function is called (when partial text exists). + // Falls through to the default for the edge case of MAX_TOKENS + no text. + return "The summary was cut off by the API token limit. Try selecting a shorter date range."; + case "OTHER": + return "Gemini stopped generating for an unspecified reason. Try again."; + default: + return null; + } +} + // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- @@ -89,32 +180,50 @@ export function useReportSummary(): UseReportSummaryReturn { }, ], generationConfig: { - maxOutputTokens: 512, + // No maxOutputTokens cap — let the model produce a complete response. + // gemini-2.5-flash is a thinking model: thinking tokens share the same + // output budget, so a low cap (e.g. 512) leaves almost nothing for + // visible text. Disabling thinking via thinkingBudget:0 avoids this + // entirely for a simple summarization task. temperature: 0.7, + thinkingConfig: { + thinkingBudget: 0, + }, }, }), }); if (!response.ok) { - const errBody = await response.json().catch(() => ({})); - const message = - errBody?.error?.message ?? `Gemini API error ${response.status}`; - throw new Error(message); + const errBody: GeminiErrorBody = await response.json().catch(() => ({})); + const retryAfter = response.headers.get("Retry-After"); + throw new Error(classifyGeminiError(response.status, errBody, retryAfter)); } const data = await response.json(); // Gemini response shape: - // { candidates: [{ content: { parts: [{ text: "..." }] } }] } + // { candidates: [{ finishReason: string, content: { parts: [{ text: "..." }] } }] } + const candidate = data.candidates?.[0]; + const finishReason: string = candidate?.finishReason ?? "STOP"; + const text: string = - data.candidates?.[0]?.content?.parts + candidate?.content?.parts ?.filter((p: { text?: string }) => typeof p.text === "string") .map((p: { text: string }) => p.text) .join("") ?? ""; + // Check MAX_TOKENS before the empty-text guard: a truncated response + // has partial text so !text is false, but it is not a complete summary. + if (finishReason === "MAX_TOKENS") { + throw new Error( + "The summary was cut off by the API token limit. Try selecting a shorter date range and regenerate." + ); + } + if (!text) { + const finishMessage = classifyFinishReason(finishReason); throw new Error( - "No text returned from Gemini. The prompt may have been blocked by safety filters." + finishMessage ?? `No text returned from Gemini (finishReason: ${finishReason || "unknown"}).` ); } @@ -122,9 +231,12 @@ export function useReportSummary(): UseReportSummaryReturn { setState("success"); } catch (err) { setState("error"); - setError( - err instanceof Error ? err.message : "An unexpected error occurred." - ); + // Distinguish network/connectivity failures from API errors + if (err instanceof TypeError && err.message.toLowerCase().includes("fetch")) { + setError("Network error — could not reach Gemini. Check your internet connection and try again."); + } else { + setError(err instanceof Error ? err.message : "An unexpected error occurred."); + } } }, [] diff --git a/src/pages/Report.tsx b/src/pages/Report.tsx index be4ab21..60cc2f7 100644 --- a/src/pages/Report.tsx +++ b/src/pages/Report.tsx @@ -25,7 +25,7 @@ import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import { - loadArchivedDays, + dayRecordsToArchivedDays, groupByCalendarWeek, groupByDateRange, getMostRecentCompleteWeek, @@ -35,6 +35,7 @@ import { ReportTone } from '@/utils/reportUtils'; import { useReportSummary } from '@/hooks/useReportSummary'; +import { useTimeTracking } from '@/hooks/useTimeTracking'; import SiteNavigationMenu from '@/components/Navigation'; // --------------------------------------------------------------------------- @@ -387,7 +388,11 @@ function ErrorState({ // --------------------------------------------------------------------------- export default function Report() { - const archivedDays = useMemo(() => loadArchivedDays(), []); + const { archivedDays: rawArchivedDays } = useTimeTracking(); + const archivedDays = useMemo( + () => dayRecordsToArchivedDays(rawArchivedDays), + [rawArchivedDays] + ); const calendarWeeks = useMemo( () => groupByCalendarWeek(archivedDays), [archivedDays] diff --git a/src/utils/reportUtils.ts b/src/utils/reportUtils.ts index 7bd2b15..26a5c06 100644 --- a/src/utils/reportUtils.ts +++ b/src/utils/reportUtils.ts @@ -1,6 +1,9 @@ // src/utils/reportUtils.ts // Utility functions for the /report route in TimeTracker Pro. -// Reads directly from localStorage keys: timetracker_archived_days +// Data is sourced via the TimeTrackingContext (which handles both localStorage +// and Supabase depending on auth state). + +import { DayRecord } from '@/contexts/TimeTrackingContext'; // --------------------------------------------------------------------------- // Types @@ -29,30 +32,44 @@ export interface ArchivedDay { export interface WeekGroup { weekStart: Date; // Sunday - weekEnd: Date; // Saturday - label: string; // e.g. "Jan 11 – Jan 17, 2026" + weekEnd: Date; // Saturday + label: string; // e.g. "Jan 11 – Jan 17, 2026" days: ArchivedDay[]; totalDuration: number; projects: string[]; } -export type ReportTone = "standup" | "client" | "retrospective"; +export type ReportTone = 'standup' | 'client' | 'retrospective'; // --------------------------------------------------------------------------- -// Storage +// Conversion from context DayRecord[] to ArchivedDay[] // --------------------------------------------------------------------------- -const ARCHIVED_DAYS_KEY = "timetracker_archived_days"; - -export function loadArchivedDays(): ArchivedDay[] { - try { - const raw = localStorage.getItem(ARCHIVED_DAYS_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } +/** + * Converts DayRecord[] from TimeTrackingContext (where dates are Date objects) + * to the ArchivedDay[] format used by report utilities (where dates are strings). + * This is the correct data source for Report.tsx — it works for both guest + * (localStorage) and authenticated (Supabase) users. + */ +export function dayRecordsToArchivedDays(records: DayRecord[]): ArchivedDay[] { + return records.map(day => ({ + id: day.id, + date: day.date, + startTime: day.startTime.toISOString(), + endTime: day.endTime.toISOString(), + totalDuration: day.totalDuration, + tasks: day.tasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + startTime: t.startTime.toISOString(), + endTime: t.endTime?.toISOString() ?? '', + duration: t.duration ?? 0, + project: t.project, + client: t.client, + category: t.category + })) + })); } // --------------------------------------------------------------------------- @@ -106,7 +123,7 @@ export function groupByCalendarWeek(days: ArchivedDay[]): WeekGroup[] { label: formatWeekLabel(ws, we), days: [], totalDuration: 0, - projects: [], + projects: [] }); } @@ -147,7 +164,7 @@ export function groupByDateRange( const fromMs = new Date(from).setHours(0, 0, 0, 0); const toMs = new Date(to).setHours(23, 59, 59, 999); - const filtered = days.filter((day) => { + const filtered = days.filter(day => { const t = new Date(day.date).getTime(); return t >= fromMs && t <= toMs; }); @@ -174,7 +191,7 @@ export function groupByDateRange( label: formatWeekLabel(new Date(from), new Date(to)), days: filtered, totalDuration, - projects, + projects }; } @@ -183,8 +200,18 @@ export function groupByDateRange( // --------------------------------------------------------------------------- const SHORT_MONTH = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' ]; function formatWeekLabel(start: Date, end: Date): string { @@ -209,17 +236,17 @@ export function formatDuration(ms: number): string { /** Short day label: "Mon Jan 15" */ function formatDayLabel(dateStr: string): string { const d = new Date(dateStr); - const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; return `${days[d.getDay()]} ${SHORT_MONTH[d.getMonth()]} ${d.getDate()}`; } // Categories to exclude from the summary prompt (non-work noise) const EXCLUDED_CATEGORIES = new Set([ - "break-time", - "break", - "lunch", - "personal", - "admin", + 'break-time', + 'break', + 'lunch', + 'personal', + 'admin' ]); // --------------------------------------------------------------------------- @@ -232,11 +259,11 @@ const EXCLUDED_CATEGORIES = new Set([ * and non-work tasks. Preserves the narrative content of descriptions. */ export function serializeWeekForPrompt(week: WeekGroup): string { - const lines: string[] = [`Week of ${week.label}`, ""]; + const lines: string[] = [`Week of ${week.label}`, '']; for (const day of week.days) { const workTasks = day.tasks.filter( - (t) => !EXCLUDED_CATEGORIES.has(t.category?.toLowerCase() ?? "") + t => !EXCLUDED_CATEGORIES.has(t.category?.toLowerCase() ?? '') ); if (workTasks.length === 0) continue; @@ -246,7 +273,7 @@ export function serializeWeekForPrompt(week: WeekGroup): string { lines.push(`${formatDayLabel(day.date)} (${dayHours})`); for (const task of workTasks) { - const project = task.project ? ` [${task.project}]` : ""; + const project = task.project ? ` [${task.project}]` : ''; const desc = task.description?.trim(); const taskHours = formatDuration(task.duration); if (desc) { @@ -256,10 +283,10 @@ export function serializeWeekForPrompt(week: WeekGroup): string { } } - lines.push(""); + lines.push(''); } - return lines.join("\n").trim(); + return lines.join('\n').trim(); } // --------------------------------------------------------------------------- @@ -268,11 +295,11 @@ export function serializeWeekForPrompt(week: WeekGroup): string { const TONE_INSTRUCTIONS: Record = { standup: - "Write in a concise first-person style suitable for a weekly team standup or async update. Focus on what was accomplished and any notable shifts in focus.", + 'Write in a concise first-person style suitable for a weekly team standup or async update. Focus on what was accomplished and any notable shifts in focus.', client: - "Write in a professional first-person style suitable for sharing with a client or stakeholder. Emphasize outcomes and progress on deliverables.", + 'Write in a professional first-person style suitable for sharing with a client or stakeholder. Emphasize outcomes and progress on deliverables.', retrospective: - "Write in a reflective first-person style suitable for a personal weekly retrospective. Note themes, what went well, and what shifted during the week.", + 'Write in a reflective first-person style suitable for a personal weekly retrospective. Note themes, what went well, and what shifted during the week.' }; /** @@ -281,7 +308,7 @@ const TONE_INSTRUCTIONS: Record = { */ export function buildSummaryPrompt( week: WeekGroup, - tone: ReportTone = "standup" + tone: ReportTone = 'standup' ): { system: string; userMessage: string } { const system = `You are a professional writing assistant that creates concise weekly work summaries from time tracking data. @@ -330,6 +357,6 @@ export function getMostRecentCompleteWeek( ): WeekGroup | null { if (weeks.length === 0) return null; const now = new Date(); - const complete = weeks.find((w) => w.weekEnd < now); + const complete = weeks.find(w => w.weekEnd < now); return complete ?? weeks[0]; }