Fix email timing and show fallback data when Garmin sync pending
All checks were successful
Deploy / deploy (push) Successful in 2m31s

- Add 15-minute notification granularity (*/15 cron) so users get emails
  at their configured time instead of rounding to the nearest hour
- Add DailyLog fallback to most recent when today's log doesn't exist,
  preventing 100/100/Unknown default values before morning sync
- Show "Last synced" indicator when displaying stale data
- Change Garmin sync to 6-hour intervals (0,6,12,18 UTC) to ensure
  data is available before European morning notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 09:56:41 +00:00
parent 0d5785aaaa
commit 092d8bb3dd
7 changed files with 402 additions and 31 deletions

View File

@@ -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", () => { describe("DailyLog handling", () => {
it("does not send notification if no DailyLog exists for today", async () => { it("does not send notification if no DailyLog exists for today", async () => {
mockUsers = [ mockUsers = [

View File

@@ -17,19 +17,40 @@ interface NotificationResult {
timestamp: string; timestamp: string;
} }
// Get the current hour in a specific timezone // Get current quarter-hour slot (0, 15, 30, 45) in user's timezone
function getCurrentHourInTimezone(timezone: string): number { function getCurrentQuarterHourSlot(timezone: string): {
hour: number;
minute: number;
} {
const formatter = new Intl.DateTimeFormat("en-US", { const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone, timeZone: timezone,
hour: "numeric", hour: "numeric",
minute: "numeric",
hour12: false, 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 // Extract quarter-hour slot from "HH:MM" format
function getNotificationHour(notificationTime: string): number { function getNotificationSlot(notificationTime: string): {
return parseInt(notificationTime.split(":")[0], 10); 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 // Map decision status to icon
@@ -95,11 +116,14 @@ export async function POST(request: Request) {
for (const user of users) { for (const user of users) {
try { try {
// Check if current hour in user's timezone matches their notification time // Check if current quarter-hour slot in user's timezone matches their notification time
const currentHour = getCurrentHourInTimezone(user.timezone); const currentSlot = getCurrentQuarterHourSlot(user.timezone);
const notificationHour = getNotificationHour(user.notificationTime); const notificationSlot = getNotificationSlot(user.notificationTime);
if (currentHour !== notificationHour) { if (
currentSlot.hour !== notificationSlot.hour ||
currentSlot.minute !== notificationSlot.minute
) {
result.skippedWrongTime++; result.skippedWrongTime++;
continue; continue;
} }

View File

@@ -9,9 +9,12 @@ import type { DailyLog, User } from "@/types";
// Module-level variable to control mock user in tests // Module-level variable to control mock user in tests
let currentMockUser: User | null = null; 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; 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 // Track the filter string passed to getFirstListItem
let lastDailyLogFilter: string | null = null; let lastDailyLogFilter: string | null = null;
@@ -37,13 +40,33 @@ const mockPb = {
// Capture the filter for testing // Capture the filter for testing
if (collectionName === "dailyLogs") { if (collectionName === "dailyLogs") {
lastDailyLogFilter = filter; lastDailyLogFilter = filter;
}
// 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) { if (!currentMockDailyLog) {
const error = new Error("No DailyLog found"); const error = new Error("No DailyLog found for today");
(error as { status?: number }).status = 404; (error as { status?: number }).status = 404;
throw error; throw error;
} }
return currentMockDailyLog; 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;
}
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(); vi.clearAllMocks();
currentMockUser = null; currentMockUser = null;
currentMockDailyLog = null; currentMockDailyLog = null;
fallbackMockDailyLog = null;
lastDailyLogFilter = null; lastDailyLogFilter = null;
// Mock current date to 2025-01-10 for predictable testing // Mock current date to 2025-01-10 for predictable testing
vi.useFakeTimers(); vi.useFakeTimers();
@@ -592,4 +616,90 @@ describe("GET /api/today", () => {
expect(body.decision.status).toBe("TRAIN"); 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");
});
});
}); });

View File

@@ -72,18 +72,19 @@ export const GET = withAuth(async (_request, user, pb) => {
daysUntilNextPhase = cycleLength - 6 - cycleDay; 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 // Sort by date DESC to get the most recent record if multiple exist
let biometrics = { ...DEFAULT_BIOMETRICS, phaseLimit }; let biometrics = { ...DEFAULT_BIOMETRICS, phaseLimit };
try { let lastSyncedAt: string | null = null;
// Use YYYY-MM-DD format with >= and < operators for PocketBase date field // 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 today = new Date().toISOString().split("T")[0];
const tomorrow = new Date(Date.now() + 86400000) const tomorrow = new Date(Date.now() + 86400000).toISOString().split("T")[0];
.toISOString()
.split("T")[0];
logger.info({ userId: user.id, today, tomorrow }, "Fetching dailyLog"); logger.info({ userId: user.id, today, tomorrow }, "Fetching dailyLog");
try {
// First try to get today's log
const dailyLog = await pb const dailyLog = await pb
.collection("dailyLogs") .collection("dailyLogs")
.getFirstListItem<DailyLog>( .getFirstListItem<DailyLog>(
@@ -98,7 +99,7 @@ export const GET = withAuth(async (_request, user, pb) => {
hrvStatus: dailyLog.hrvStatus, hrvStatus: dailyLog.hrvStatus,
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent, bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
}, },
"Found dailyLog", "Found dailyLog for today",
); );
biometrics = { biometrics = {
@@ -111,8 +112,51 @@ export const GET = withAuth(async (_request, user, pb) => {
weekIntensityMinutes: dailyLog.weekIntensityMinutes, weekIntensityMinutes: dailyLog.weekIntensityMinutes,
phaseLimit: dailyLog.phaseLimit, phaseLimit: dailyLog.phaseLimit,
}; };
} catch (err) { lastSyncedAt = today;
logger.warn({ userId: user.id, err }, "No dailyLog found, using defaults"); } 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<DailyLog>(`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 // Build DailyData for decision engine
@@ -153,5 +197,6 @@ export const GET = withAuth(async (_request, user, pb) => {
cycleLength, cycleLength,
biometrics, biometrics,
nutrition, nutrition,
lastSyncedAt,
}); });
}); });

View File

@@ -222,4 +222,52 @@ describe("DataPanel", () => {
expect(progressFill).toHaveClass("bg-green-500"); 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(<DataPanel {...baseProps} lastSyncedAt={today} />);
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(<DataPanel {...baseProps} lastSyncedAt={yesterday} />);
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(<DataPanel {...baseProps} lastSyncedAt={threeDaysAgo} />);
expect(screen.getByText(/Last synced: 3 days ago/)).toBeInTheDocument();
});
it("shows 'Waiting for first sync' when lastSyncedAt is null", () => {
render(<DataPanel {...baseProps} lastSyncedAt={null} />);
expect(screen.getByText(/Waiting for first sync/)).toBeInTheDocument();
});
it("does not show indicator when lastSyncedAt is undefined (backwards compatible)", () => {
render(<DataPanel {...baseProps} />);
expect(screen.queryByText(/Last synced:/)).not.toBeInTheDocument();
expect(
screen.queryByText(/Waiting for first sync/),
).not.toBeInTheDocument();
});
});
}); });

View File

@@ -7,6 +7,26 @@ interface DataPanelProps {
weekIntensity: number; weekIntensity: number;
phaseLimit: number; phaseLimit: number;
remainingMinutes: 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 { function getHrvColorClass(status: string): string {
@@ -37,11 +57,23 @@ export function DataPanel({
weekIntensity, weekIntensity,
phaseLimit, phaseLimit,
remainingMinutes, remainingMinutes,
lastSyncedAt,
}: DataPanelProps) { }: DataPanelProps) {
const intensityPercentage = const intensityPercentage =
phaseLimit > 0 ? (weekIntensity / phaseLimit) * 100 : 0; phaseLimit > 0 ? (weekIntensity / phaseLimit) * 100 : 0;
const displayPercentage = Math.min(intensityPercentage, 100); 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 ( return (
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<h3 className="font-semibold mb-4">YOUR DATA</h3> <h3 className="font-semibold mb-4">YOUR DATA</h3>
@@ -81,6 +113,11 @@ export function DataPanel({
? `Remaining: ${remainingMinutes} min` ? `Remaining: ${remainingMinutes} min`
: `Goal exceeded by ${Math.abs(remainingMinutes)} min`} : `Goal exceeded by ${Math.abs(remainingMinutes)} min`}
</li> </li>
{syncIndicator && (
<li className="text-amber-600 dark:text-amber-400 text-xs pt-1">
{syncIndicator}
</li>
)}
</ul> </ul>
</div> </div>
); );

View File

@@ -43,20 +43,21 @@ export async function register() {
} }
} }
// Schedule notifications at the top of every hour // Schedule notifications every 15 minutes for finer-grained delivery times
cron.default.schedule("0 * * * *", () => { cron.default.schedule("*/15 * * * *", () => {
console.log("[cron] Triggering notifications..."); console.log("[cron] Triggering notifications...");
triggerCronEndpoint("notifications", "Notifications"); triggerCronEndpoint("notifications", "Notifications");
}); });
// Schedule Garmin sync 3 times daily (8 AM, 2 PM, 10 PM UTC) // Schedule Garmin sync 4 times daily (every 6 hours) to ensure data is available
cron.default.schedule("0 8,14,22 * * *", () => { // before European morning notifications
cron.default.schedule("0 0,6,12,18 * * *", () => {
console.log("[cron] Triggering Garmin sync..."); console.log("[cron] Triggering Garmin sync...");
triggerCronEndpoint("garmin-sync", "Garmin sync"); triggerCronEndpoint("garmin-sync", "Garmin sync");
}); });
console.log( 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",
); );
} }
} }