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"
>
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];
}