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", () => {
|
||||
it("does not send notification if no DailyLog exists for today", async () => {
|
||||
mockUsers = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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");
|
||||
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;
|
||||
}
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
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];
|
||||
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<DailyLog>(
|
||||
@@ -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<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
|
||||
@@ -153,5 +197,6 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
cycleLength,
|
||||
biometrics,
|
||||
nutrition,
|
||||
lastSyncedAt,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<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;
|
||||
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 (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold mb-4">YOUR DATA</h3>
|
||||
@@ -81,6 +113,11 @@ export function DataPanel({
|
||||
? `Remaining: ${remainingMinutes} min`
|
||||
: `Goal exceeded by ${Math.abs(remainingMinutes)} min`}
|
||||
</li>
|
||||
{syncIndicator && (
|
||||
<li className="text-amber-600 dark:text-amber-400 text-xs pt-1">
|
||||
{syncIndicator}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user