diff --git a/src/app/api/cron/notifications/route.test.ts b/src/app/api/cron/notifications/route.test.ts index 3e7c7a3..da4d93a 100644 --- a/src/app/api/cron/notifications/route.test.ts +++ b/src/app/api/cron/notifications/route.test.ts @@ -205,6 +205,112 @@ describe("POST /api/cron/notifications", () => { }); }); + describe("Quarter-hour time matching", () => { + it("sends notification at exact 15-minute slot (07:15)", async () => { + // Current time is 07:15 UTC + vi.setSystemTime(new Date("2025-01-15T07:15:00Z")); + mockUsers = [ + createMockUser({ notificationTime: "07:15", timezone: "UTC" }), + ]; + mockDailyLogs = [createMockDailyLog()]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + expect(mockSendDailyEmail).toHaveBeenCalled(); + }); + + it("rounds down notification time to nearest 15-minute slot (07:10 -> 07:00)", async () => { + // Current time is 07:00 UTC + vi.setSystemTime(new Date("2025-01-15T07:00:00Z")); + // User set 07:10, which rounds down to 07:00 slot + mockUsers = [ + createMockUser({ notificationTime: "07:10", timezone: "UTC" }), + ]; + mockDailyLogs = [createMockDailyLog()]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + expect(mockSendDailyEmail).toHaveBeenCalled(); + }); + + it("rounds down notification time (07:29 -> 07:15)", async () => { + // Current time is 07:15 UTC + vi.setSystemTime(new Date("2025-01-15T07:15:00Z")); + // User set 07:29, which rounds down to 07:15 slot + mockUsers = [ + createMockUser({ notificationTime: "07:29", timezone: "UTC" }), + ]; + mockDailyLogs = [createMockDailyLog()]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + expect(mockSendDailyEmail).toHaveBeenCalled(); + }); + + it("does not send notification when minute slot does not match", async () => { + // Current time is 07:00 UTC + vi.setSystemTime(new Date("2025-01-15T07:00:00Z")); + // User wants 07:15, but current slot is 07:00 + mockUsers = [ + createMockUser({ notificationTime: "07:15", timezone: "UTC" }), + ]; + mockDailyLogs = [createMockDailyLog()]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + expect(mockSendDailyEmail).not.toHaveBeenCalled(); + }); + + it("handles 30-minute slot correctly", async () => { + // Current time is 07:30 UTC + vi.setSystemTime(new Date("2025-01-15T07:30:00Z")); + mockUsers = [ + createMockUser({ notificationTime: "07:30", timezone: "UTC" }), + ]; + mockDailyLogs = [createMockDailyLog()]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + expect(mockSendDailyEmail).toHaveBeenCalled(); + }); + + it("handles 45-minute slot correctly", async () => { + // Current time is 07:45 UTC + vi.setSystemTime(new Date("2025-01-15T07:45:00Z")); + mockUsers = [ + createMockUser({ notificationTime: "07:45", timezone: "UTC" }), + ]; + mockDailyLogs = [createMockDailyLog()]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + expect(mockSendDailyEmail).toHaveBeenCalled(); + }); + + it("handles timezone with 15-minute matching", async () => { + // Current time is 07:15 UTC = 02:15 America/New_York (EST is UTC-5) + vi.setSystemTime(new Date("2025-01-15T07:15:00Z")); + mockUsers = [ + createMockUser({ + notificationTime: "02:15", + timezone: "America/New_York", + }), + ]; + mockDailyLogs = [createMockDailyLog()]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + expect(mockSendDailyEmail).toHaveBeenCalled(); + }); + }); + describe("DailyLog handling", () => { it("does not send notification if no DailyLog exists for today", async () => { mockUsers = [ diff --git a/src/app/api/cron/notifications/route.ts b/src/app/api/cron/notifications/route.ts index 3850dc4..a9904d3 100644 --- a/src/app/api/cron/notifications/route.ts +++ b/src/app/api/cron/notifications/route.ts @@ -17,19 +17,40 @@ interface NotificationResult { timestamp: string; } -// Get the current hour in a specific timezone -function getCurrentHourInTimezone(timezone: string): number { +// Get current quarter-hour slot (0, 15, 30, 45) in user's timezone +function getCurrentQuarterHourSlot(timezone: string): { + hour: number; + minute: number; +} { const formatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, hour: "numeric", + minute: "numeric", hour12: false, }); - return parseInt(formatter.format(new Date()), 10); + const parts = formatter.formatToParts(new Date()); + const hour = Number.parseInt( + parts.find((p) => p.type === "hour")?.value ?? "0", + 10, + ); + const minute = Number.parseInt( + parts.find((p) => p.type === "minute")?.value ?? "0", + 10, + ); + // Round down to nearest 15-min slot + const slot = Math.floor(minute / 15) * 15; + return { hour, minute: slot }; } -// Extract hour from "HH:MM" format -function getNotificationHour(notificationTime: string): number { - return parseInt(notificationTime.split(":")[0], 10); +// Extract quarter-hour slot from "HH:MM" format +function getNotificationSlot(notificationTime: string): { + hour: number; + minute: number; +} { + const [h, m] = notificationTime.split(":").map(Number); + // Round down to nearest 15-min slot + const slot = Math.floor(m / 15) * 15; + return { hour: h, minute: slot }; } // Map decision status to icon @@ -95,11 +116,14 @@ export async function POST(request: Request) { for (const user of users) { try { - // Check if current hour in user's timezone matches their notification time - const currentHour = getCurrentHourInTimezone(user.timezone); - const notificationHour = getNotificationHour(user.notificationTime); + // Check if current quarter-hour slot in user's timezone matches their notification time + const currentSlot = getCurrentQuarterHourSlot(user.timezone); + const notificationSlot = getNotificationSlot(user.notificationTime); - if (currentHour !== notificationHour) { + if ( + currentSlot.hour !== notificationSlot.hour || + currentSlot.minute !== notificationSlot.minute + ) { result.skippedWrongTime++; continue; } diff --git a/src/app/api/today/route.test.ts b/src/app/api/today/route.test.ts index a4e5e42..8cc2aa4 100644 --- a/src/app/api/today/route.test.ts +++ b/src/app/api/today/route.test.ts @@ -9,9 +9,12 @@ import type { DailyLog, User } from "@/types"; // Module-level variable to control mock user in tests let currentMockUser: User | null = null; -// Module-level variable to control mock daily log in tests +// Module-level variable to control mock daily log for today in tests let currentMockDailyLog: DailyLog | null = null; +// Module-level variable to control mock daily log for fallback (most recent) +let fallbackMockDailyLog: DailyLog | null = null; + // Track the filter string passed to getFirstListItem let lastDailyLogFilter: string | null = null; @@ -37,13 +40,33 @@ const mockPb = { // Capture the filter for testing if (collectionName === "dailyLogs") { lastDailyLogFilter = filter; - } - if (!currentMockDailyLog) { + + // Check if this is a query for today's log (has date range filter) + const isTodayQuery = + filter.includes("date>=") && filter.includes("date<"); + if (isTodayQuery) { + if (!currentMockDailyLog) { + const error = new Error("No DailyLog found for today"); + (error as { status?: number }).status = 404; + throw error; + } + return currentMockDailyLog; + } + + // This is the fallback query for most recent log + if (fallbackMockDailyLog) { + return fallbackMockDailyLog; + } + if (currentMockDailyLog) { + return currentMockDailyLog; + } const error = new Error("No DailyLog found"); (error as { status?: number }).status = 404; throw error; } - return currentMockDailyLog; + const error = new Error("No DailyLog found"); + (error as { status?: number }).status = 404; + throw error; }), })), }; @@ -112,6 +135,7 @@ describe("GET /api/today", () => { vi.clearAllMocks(); currentMockUser = null; currentMockDailyLog = null; + fallbackMockDailyLog = null; lastDailyLogFilter = null; // Mock current date to 2025-01-10 for predictable testing vi.useFakeTimers(); @@ -592,4 +616,90 @@ describe("GET /api/today", () => { expect(body.decision.status).toBe("TRAIN"); }); }); + + describe("DailyLog fallback to most recent", () => { + it("returns lastSyncedAt as today when today's DailyLog exists", async () => { + currentMockUser = createMockUser(); + currentMockDailyLog = createMockDailyLog(); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.lastSyncedAt).toBe("2025-01-10"); + }); + + it("uses yesterday's DailyLog when today's does not exist", async () => { + currentMockUser = createMockUser(); + currentMockDailyLog = null; // No today's log + // Yesterday's log with different biometrics + fallbackMockDailyLog = createMockDailyLog({ + date: new Date("2025-01-09"), + hrvStatus: "Balanced", + bodyBatteryCurrent: 72, + bodyBatteryYesterdayLow: 38, + weekIntensityMinutes: 90, + phaseLimit: 150, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + // Should use fallback data + expect(body.biometrics.hrvStatus).toBe("Balanced"); + expect(body.biometrics.bodyBatteryCurrent).toBe(72); + expect(body.biometrics.bodyBatteryYesterdayLow).toBe(38); + expect(body.biometrics.weekIntensityMinutes).toBe(90); + }); + + it("returns lastSyncedAt as yesterday's date when using fallback", async () => { + currentMockUser = createMockUser(); + currentMockDailyLog = null; + fallbackMockDailyLog = createMockDailyLog({ + date: new Date("2025-01-09"), + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.lastSyncedAt).toBe("2025-01-09"); + }); + + it("returns null lastSyncedAt when no logs exist at all", async () => { + currentMockUser = createMockUser(); + currentMockDailyLog = null; + fallbackMockDailyLog = null; + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.lastSyncedAt).toBeNull(); + // Should use DEFAULT_BIOMETRICS + expect(body.biometrics.hrvStatus).toBe("Unknown"); + expect(body.biometrics.bodyBatteryCurrent).toBe(100); + expect(body.biometrics.bodyBatteryYesterdayLow).toBe(100); + }); + + it("handles fallback log with string date format", async () => { + currentMockUser = createMockUser(); + currentMockDailyLog = null; + fallbackMockDailyLog = createMockDailyLog({ + date: "2025-01-08T10:00:00Z" as unknown as Date, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.lastSyncedAt).toBe("2025-01-08"); + }); + }); }); diff --git a/src/app/api/today/route.ts b/src/app/api/today/route.ts index 454c496..3259158 100644 --- a/src/app/api/today/route.ts +++ b/src/app/api/today/route.ts @@ -72,18 +72,19 @@ export const GET = withAuth(async (_request, user, pb) => { daysUntilNextPhase = cycleLength - 6 - cycleDay; } - // Try to fetch today's DailyLog for biometrics + // Try to fetch today's DailyLog for biometrics, fall back to most recent // Sort by date DESC to get the most recent record if multiple exist let biometrics = { ...DEFAULT_BIOMETRICS, phaseLimit }; - try { - // Use YYYY-MM-DD format with >= and < operators for PocketBase date field - // PocketBase accepts simple date strings in comparison operators - const today = new Date().toISOString().split("T")[0]; - const tomorrow = new Date(Date.now() + 86400000) - .toISOString() - .split("T")[0]; + let lastSyncedAt: string | null = null; - logger.info({ userId: user.id, today, tomorrow }, "Fetching dailyLog"); + // Use YYYY-MM-DD format with >= and < operators for PocketBase date field + const today = new Date().toISOString().split("T")[0]; + const tomorrow = new Date(Date.now() + 86400000).toISOString().split("T")[0]; + + logger.info({ userId: user.id, today, tomorrow }, "Fetching dailyLog"); + + try { + // First try to get today's log const dailyLog = await pb .collection("dailyLogs") .getFirstListItem( @@ -98,7 +99,7 @@ export const GET = withAuth(async (_request, user, pb) => { hrvStatus: dailyLog.hrvStatus, bodyBatteryCurrent: dailyLog.bodyBatteryCurrent, }, - "Found dailyLog", + "Found dailyLog for today", ); biometrics = { @@ -111,8 +112,51 @@ export const GET = withAuth(async (_request, user, pb) => { weekIntensityMinutes: dailyLog.weekIntensityMinutes, phaseLimit: dailyLog.phaseLimit, }; - } catch (err) { - logger.warn({ userId: user.id, err }, "No dailyLog found, using defaults"); + lastSyncedAt = today; + } catch { + // No today's log - try to get most recent + logger.info( + { userId: user.id }, + "No dailyLog for today, trying most recent", + ); + try { + const dailyLog = await pb + .collection("dailyLogs") + .getFirstListItem(`user="${user.id}"`, { sort: "-date" }); + + // Extract date from the log for "last synced" indicator + const dateValue = dailyLog.date as unknown as string | Date; + lastSyncedAt = + typeof dateValue === "string" + ? dateValue.split("T")[0] + : dateValue.toISOString().split("T")[0]; + + logger.info( + { + userId: user.id, + dailyLogId: dailyLog.id, + lastSyncedAt, + }, + "Using most recent dailyLog as fallback", + ); + + biometrics = { + hrvStatus: dailyLog.hrvStatus, + bodyBatteryCurrent: + dailyLog.bodyBatteryCurrent ?? DEFAULT_BIOMETRICS.bodyBatteryCurrent, + bodyBatteryYesterdayLow: + dailyLog.bodyBatteryYesterdayLow ?? + DEFAULT_BIOMETRICS.bodyBatteryYesterdayLow, + weekIntensityMinutes: dailyLog.weekIntensityMinutes, + phaseLimit: dailyLog.phaseLimit, + }; + } catch { + // No logs at all - truly new user + logger.warn( + { userId: user.id }, + "No dailyLog found at all, using defaults", + ); + } } // Build DailyData for decision engine @@ -153,5 +197,6 @@ export const GET = withAuth(async (_request, user, pb) => { cycleLength, biometrics, nutrition, + lastSyncedAt, }); }); diff --git a/src/components/dashboard/data-panel.test.tsx b/src/components/dashboard/data-panel.test.tsx index 58f1703..95243c3 100644 --- a/src/components/dashboard/data-panel.test.tsx +++ b/src/components/dashboard/data-panel.test.tsx @@ -222,4 +222,52 @@ describe("DataPanel", () => { expect(progressFill).toHaveClass("bg-green-500"); }); }); + + describe("Last synced indicator", () => { + it("does not show indicator when lastSyncedAt is today", () => { + // Mock today's date + const today = new Date().toISOString().split("T")[0]; + render(); + + expect(screen.queryByText(/Last synced:/)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Waiting for first sync/), + ).not.toBeInTheDocument(); + }); + + it("shows 'Last synced: yesterday' when data is from yesterday", () => { + // Get yesterday's date + const yesterday = new Date(Date.now() - 86400000) + .toISOString() + .split("T")[0]; + render(); + + expect(screen.getByText(/Last synced: yesterday/)).toBeInTheDocument(); + }); + + it("shows 'Last synced: X days ago' when data is older", () => { + // Get date from 3 days ago + const threeDaysAgo = new Date(Date.now() - 3 * 86400000) + .toISOString() + .split("T")[0]; + render(); + + expect(screen.getByText(/Last synced: 3 days ago/)).toBeInTheDocument(); + }); + + it("shows 'Waiting for first sync' when lastSyncedAt is null", () => { + render(); + + expect(screen.getByText(/Waiting for first sync/)).toBeInTheDocument(); + }); + + it("does not show indicator when lastSyncedAt is undefined (backwards compatible)", () => { + render(); + + expect(screen.queryByText(/Last synced:/)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Waiting for first sync/), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/dashboard/data-panel.tsx b/src/components/dashboard/data-panel.tsx index 2379d9a..bc5dcb8 100644 --- a/src/components/dashboard/data-panel.tsx +++ b/src/components/dashboard/data-panel.tsx @@ -7,6 +7,26 @@ interface DataPanelProps { weekIntensity: number; phaseLimit: number; remainingMinutes: number; + lastSyncedAt?: string | null; +} + +// Calculate relative time description from a date string (YYYY-MM-DD) +function getRelativeTimeDescription(dateStr: string): string | null { + const today = new Date(); + const todayStr = today.toISOString().split("T")[0]; + + if (dateStr === todayStr) { + return null; // Don't show indicator for today + } + + const syncDate = new Date(dateStr); + const diffMs = today.getTime() - syncDate.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) { + return "yesterday"; + } + return `${diffDays} days ago`; } function getHrvColorClass(status: string): string { @@ -37,11 +57,23 @@ export function DataPanel({ weekIntensity, phaseLimit, remainingMinutes, + lastSyncedAt, }: DataPanelProps) { const intensityPercentage = phaseLimit > 0 ? (weekIntensity / phaseLimit) * 100 : 0; const displayPercentage = Math.min(intensityPercentage, 100); + // Determine what to show for sync status + let syncIndicator: string | null = null; + if (lastSyncedAt === null) { + syncIndicator = "Waiting for first sync"; + } else if (lastSyncedAt !== undefined) { + const relativeTime = getRelativeTimeDescription(lastSyncedAt); + if (relativeTime) { + syncIndicator = `Last synced: ${relativeTime}`; + } + } + return (

YOUR DATA

@@ -81,6 +113,11 @@ export function DataPanel({ ? `Remaining: ${remainingMinutes} min` : `Goal exceeded by ${Math.abs(remainingMinutes)} min`} + {syncIndicator && ( +
  • + {syncIndicator} +
  • + )}
    ); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index bb9339c..c899969 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -43,20 +43,21 @@ export async function register() { } } - // Schedule notifications at the top of every hour - cron.default.schedule("0 * * * *", () => { + // Schedule notifications every 15 minutes for finer-grained delivery times + cron.default.schedule("*/15 * * * *", () => { console.log("[cron] Triggering notifications..."); triggerCronEndpoint("notifications", "Notifications"); }); - // Schedule Garmin sync 3 times daily (8 AM, 2 PM, 10 PM UTC) - cron.default.schedule("0 8,14,22 * * *", () => { + // Schedule Garmin sync 4 times daily (every 6 hours) to ensure data is available + // before European morning notifications + cron.default.schedule("0 0,6,12,18 * * *", () => { console.log("[cron] Triggering Garmin sync..."); triggerCronEndpoint("garmin-sync", "Garmin sync"); }); console.log( - "[cron] Scheduler started - notifications hourly, Garmin sync at 08:00/14:00/22:00 UTC", + "[cron] Scheduler started - notifications every 15 min, Garmin sync at 00:00/06:00/12:00/18:00 UTC", ); } }