Fix email timing and show fallback data when Garmin sync pending
All checks were successful
Deploy / deploy (push) Successful in 2m31s
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:
@@ -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 = [
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
|
||||||
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");
|
const error = new Error("No DailyLog found");
|
||||||
(error as { status?: number }).status = 404;
|
(error as { status?: number }).status = 404;
|
||||||
throw error;
|
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();
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
|
||||||
// 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];
|
|
||||||
|
|
||||||
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
|
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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user