Fix email delivery and body battery null handling
All checks were successful
Deploy / deploy (push) Successful in 2m34s

- Add PocketBase admin auth to notifications endpoint (was returning 0 users)
- Store null instead of 100 for body battery when Garmin returns no data
- Update decision engine to skip body battery rules when values are null
- Dashboard and email already display "N/A" for null values

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-22 08:50:30 +00:00
parent 092d8bb3dd
commit d4b04a17be
9 changed files with 153 additions and 41 deletions

View File

@@ -560,11 +560,10 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.errors).toBe(1);
});
it("stores default value 100 when body battery is null from Garmin", async () => {
it("stores null when body battery is null from Garmin", async () => {
// When Garmin API returns null for body battery values (no data available),
// we store the default value 100 instead of null.
// This prevents PocketBase's number field null-to-0 coercion from causing
// the dashboard to display 0 instead of a meaningful value.
// we store null and the UI displays "N/A". The decision engine skips
// body battery rules when values are null.
mockUsers = [createMockUser()];
mockFetchBodyBattery.mockResolvedValue({
current: null,
@@ -575,8 +574,8 @@ describe("POST /api/cron/garmin-sync", () => {
expect(mockPbCreate).toHaveBeenCalledWith(
expect.objectContaining({
bodyBatteryCurrent: 100,
bodyBatteryYesterdayLow: 100,
bodyBatteryCurrent: null,
bodyBatteryYesterdayLow: null,
}),
);
});

View File

@@ -194,30 +194,30 @@ export async function POST(request: Request) {
const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes);
// Calculate training decision
// Pass null body battery values through - decision engine handles null gracefully
const decision = getDecisionWithOverrides(
{
hrvStatus,
bbYesterdayLow: bodyBattery.yesterdayLow ?? 100,
bbYesterdayLow: bodyBattery.yesterdayLow,
phase,
weekIntensity: weekIntensityMinutes,
phaseLimit,
bbCurrent: bodyBattery.current ?? 100,
bbCurrent: bodyBattery.current,
},
user.activeOverrides,
);
// Upsert DailyLog entry - update existing record for today or create new one
// Store default value 100 for body battery when Garmin returns null.
// This prevents PocketBase's number field null-to-0 coercion from
// causing the dashboard to display 0 instead of a meaningful value.
// Store null for body battery when Garmin returns null - the UI displays "N/A"
// and the decision engine skips body battery rules when values are null.
// Use YYYY-MM-DD format for PocketBase date field compatibility
const dailyLogData = {
user: user.id,
date: today,
cycleDay,
phase,
bodyBatteryCurrent: bodyBattery.current ?? 100,
bodyBatteryYesterdayLow: bodyBattery.yesterdayLow ?? 100,
bodyBatteryCurrent: bodyBattery.current,
bodyBatteryYesterdayLow: bodyBattery.yesterdayLow,
hrvStatus,
weekIntensityMinutes,
phaseLimit,

View File

@@ -9,7 +9,8 @@ let mockUsers: User[] = [];
let mockDailyLogs: DailyLog[] = [];
const mockPbUpdate = vi.fn().mockResolvedValue({ id: "log123" });
// Mock PocketBase
// Mock PocketBase with admin auth
const mockAuthWithPassword = vi.fn().mockResolvedValue({ id: "admin" });
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn((name: string) => ({
@@ -23,10 +24,21 @@ vi.mock("@/lib/pocketbase", () => ({
return [];
}),
update: mockPbUpdate,
authWithPassword: (email: string, password: string) =>
mockAuthWithPassword(email, password),
})),
})),
}));
// Mock logger
vi.mock("@/lib/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock email sending
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
@@ -110,6 +122,9 @@ describe("POST /api/cron/notifications", () => {
mockUsers = [];
mockDailyLogs = [];
process.env.CRON_SECRET = validSecret;
process.env.POCKETBASE_ADMIN_EMAIL = "admin@example.com";
process.env.POCKETBASE_ADMIN_PASSWORD = "admin-password";
mockAuthWithPassword.mockResolvedValue({ id: "admin" });
// Mock current time to 07:00 UTC
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
@@ -143,6 +158,36 @@ describe("POST /api/cron/notifications", () => {
expect(response.status).toBe(401);
});
it("returns 500 when POCKETBASE_ADMIN_EMAIL is not set", async () => {
process.env.POCKETBASE_ADMIN_EMAIL = "";
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe("Server misconfiguration");
});
it("returns 500 when POCKETBASE_ADMIN_PASSWORD is not set", async () => {
process.env.POCKETBASE_ADMIN_PASSWORD = "";
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe("Server misconfiguration");
});
it("returns 500 when PocketBase admin auth fails", async () => {
mockAuthWithPassword.mockRejectedValueOnce(new Error("Auth failed"));
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe("Database authentication failed");
});
});
describe("User time matching", () => {

View File

@@ -3,6 +3,7 @@
import { NextResponse } from "next/server";
import { sendDailyEmail } from "@/lib/email";
import { logger } from "@/lib/logger";
import { getNutritionGuidance } from "@/lib/nutrition";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { DailyLog, DecisionStatus, User } from "@/types";
@@ -90,8 +91,35 @@ export async function POST(request: Request) {
const pb = createPocketBaseClient();
// Authenticate as admin to bypass API rules and list all users
const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL;
const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
logger.error("Missing POCKETBASE_ADMIN_EMAIL or POCKETBASE_ADMIN_PASSWORD");
return NextResponse.json(
{ error: "Server misconfiguration" },
{ status: 500 },
);
}
try {
await pb
.collection("_superusers")
.authWithPassword(adminEmail, adminPassword);
} catch (authError) {
logger.error(
{ err: authError },
"Failed to authenticate as PocketBase admin",
);
return NextResponse.json(
{ error: "Database authentication failed" },
{ status: 500 },
);
}
// Fetch all users
const users = await pb.collection("users").getFullList<User>();
logger.info({ userCount: users.length }, "Fetched users for notifications");
// Get today's date for querying daily logs
const today = new Date().toISOString().split("T")[0];

View File

@@ -595,10 +595,10 @@ describe("GET /api/today", () => {
expect(response.status).toBe(200);
const body = await response.json();
// Defaults when no Garmin data
// Defaults when no Garmin data - null values indicate no data available
expect(body.biometrics.hrvStatus).toBe("Unknown");
expect(body.biometrics.bodyBatteryCurrent).toBe(100);
expect(body.biometrics.bodyBatteryYesterdayLow).toBe(100);
expect(body.biometrics.bodyBatteryCurrent).toBeNull();
expect(body.biometrics.bodyBatteryYesterdayLow).toBeNull();
expect(body.biometrics.weekIntensityMinutes).toBe(0);
});
@@ -611,8 +611,8 @@ describe("GET /api/today", () => {
expect(response.status).toBe(200);
const body = await response.json();
// With defaults (BB=100, HRV=Unknown), should allow training
// unless in restrictive phase
// With null body battery, decision engine skips BB rules
// and allows training unless in restrictive phase
expect(body.decision.status).toBe("TRAIN");
});
});
@@ -681,10 +681,10 @@ describe("GET /api/today", () => {
const body = await response.json();
expect(body.lastSyncedAt).toBeNull();
// Should use DEFAULT_BIOMETRICS
// Should use DEFAULT_BIOMETRICS with null for body battery
expect(body.biometrics.hrvStatus).toBe("Unknown");
expect(body.biometrics.bodyBatteryCurrent).toBe(100);
expect(body.biometrics.bodyBatteryYesterdayLow).toBe(100);
expect(body.biometrics.bodyBatteryCurrent).toBeNull();
expect(body.biometrics.bodyBatteryYesterdayLow).toBeNull();
});
it("handles fallback log with string date format", async () => {

View File

@@ -18,13 +18,13 @@ import type { DailyData, DailyLog, HrvStatus } from "@/types";
// Default biometrics when no Garmin data is available
const DEFAULT_BIOMETRICS: {
hrvStatus: HrvStatus;
bodyBatteryCurrent: number;
bodyBatteryYesterdayLow: number;
bodyBatteryCurrent: number | null;
bodyBatteryYesterdayLow: number | null;
weekIntensityMinutes: number;
} = {
hrvStatus: "Unknown",
bodyBatteryCurrent: 100,
bodyBatteryYesterdayLow: 100,
bodyBatteryCurrent: null,
bodyBatteryYesterdayLow: null,
weekIntensityMinutes: 0,
};
@@ -104,11 +104,8 @@ export const GET = withAuth(async (_request, user, pb) => {
biometrics = {
hrvStatus: dailyLog.hrvStatus,
bodyBatteryCurrent:
dailyLog.bodyBatteryCurrent ?? DEFAULT_BIOMETRICS.bodyBatteryCurrent,
bodyBatteryYesterdayLow:
dailyLog.bodyBatteryYesterdayLow ??
DEFAULT_BIOMETRICS.bodyBatteryYesterdayLow,
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
phaseLimit: dailyLog.phaseLimit,
};
@@ -142,11 +139,8 @@ export const GET = withAuth(async (_request, user, pb) => {
biometrics = {
hrvStatus: dailyLog.hrvStatus,
bodyBatteryCurrent:
dailyLog.bodyBatteryCurrent ?? DEFAULT_BIOMETRICS.bodyBatteryCurrent,
bodyBatteryYesterdayLow:
dailyLog.bodyBatteryYesterdayLow ??
DEFAULT_BIOMETRICS.bodyBatteryYesterdayLow,
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
phaseLimit: dailyLog.phaseLimit,
};

View File

@@ -127,6 +127,52 @@ describe("getTrainingDecision (algorithmic rules)", () => {
});
});
describe("null body battery handling", () => {
it("skips bbYesterdayLow check when null and allows TRAIN", () => {
const data = createHealthyData();
data.bbYesterdayLow = null;
const result = getTrainingDecision(data);
expect(result.status).toBe("TRAIN");
});
it("skips bbCurrent check when null and allows TRAIN", () => {
const data = createHealthyData();
data.bbCurrent = null;
const result = getTrainingDecision(data);
expect(result.status).toBe("TRAIN");
});
it("applies other rules when body battery is null", () => {
const data = createHealthyData();
data.bbYesterdayLow = null;
data.bbCurrent = null;
data.hrvStatus = "Unbalanced";
const result = getTrainingDecision(data);
expect(result.status).toBe("REST");
expect(result.reason).toContain("HRV");
});
it("applies phase rules when body battery is null", () => {
const data = createHealthyData();
data.bbYesterdayLow = null;
data.bbCurrent = null;
data.phase = "LATE_LUTEAL";
const result = getTrainingDecision(data);
expect(result.status).toBe("GENTLE");
});
it("applies weekly limit when body battery is null", () => {
const data = createHealthyData();
data.bbYesterdayLow = null;
data.bbCurrent = null;
data.weekIntensity = 120;
data.phaseLimit = 120;
const result = getTrainingDecision(data);
expect(result.status).toBe("REST");
expect(result.reason).toContain("LIMIT");
});
});
describe("getDecisionWithOverrides", () => {
describe("override types force appropriate decisions", () => {
it("flare override forces REST", () => {

View File

@@ -47,7 +47,7 @@ export function getTrainingDecision(data: DailyData): Decision {
return { status: "REST", reason: "HRV Unbalanced", icon: "🛑" };
}
if (bbYesterdayLow < 30) {
if (bbYesterdayLow !== null && bbYesterdayLow < 30) {
return { status: "REST", reason: "BB too depleted", icon: "🛑" };
}
@@ -75,7 +75,7 @@ export function getTrainingDecision(data: DailyData): Decision {
};
}
if (bbCurrent < 75) {
if (bbCurrent !== null && bbCurrent < 75) {
return {
status: "LIGHT",
reason: "Light activity only - BB not recovered",
@@ -83,7 +83,7 @@ export function getTrainingDecision(data: DailyData): Decision {
};
}
if (bbCurrent < 85) {
if (bbCurrent !== null && bbCurrent < 85) {
return { status: "REDUCED", reason: "Reduce intensity 25%", icon: "🟡" };
}

View File

@@ -84,11 +84,11 @@ export interface Decision {
export interface DailyData {
hrvStatus: HrvStatus;
bbYesterdayLow: number;
bbYesterdayLow: number | null;
phase: CyclePhase;
weekIntensity: number;
phaseLimit: number;
bbCurrent: number;
bbCurrent: number | null;
}
export interface GarminTokens {