diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts
index fc91db5..51c5d2c 100644
--- a/e2e/settings.spec.ts
+++ b/e2e/settings.spec.ts
@@ -772,4 +772,198 @@ test.describe("settings", () => {
await page.waitForTimeout(500);
});
});
+
+ test.describe("intensity goals section", () => {
+ test.beforeEach(async ({ page }) => {
+ const email = process.env.TEST_USER_EMAIL;
+ const password = process.env.TEST_USER_PASSWORD;
+
+ if (!email || !password) {
+ test.skip();
+ return;
+ }
+
+ await page.goto("/login");
+ await page.waitForLoadState("networkidle");
+
+ const emailInput = page.getByLabel(/email/i);
+ const hasEmailForm = await emailInput.isVisible().catch(() => false);
+
+ if (!hasEmailForm) {
+ test.skip();
+ return;
+ }
+
+ await emailInput.fill(email);
+ await page.getByLabel(/password/i).fill(password);
+ await page.getByRole("button", { name: /sign in/i }).click();
+
+ await page.waitForURL("/", { timeout: 10000 });
+ await page.goto("/settings");
+ await page.waitForLoadState("networkidle");
+ });
+
+ test("displays Weekly Intensity Goals section", async ({ page }) => {
+ const sectionHeading = page.getByRole("heading", {
+ name: /weekly intensity goals/i,
+ });
+ await expect(sectionHeading).toBeVisible();
+ });
+
+ test("displays input for menstrual phase goal", async ({ page }) => {
+ const menstrualInput = page.getByLabel(/menstrual/i);
+ await expect(menstrualInput).toBeVisible();
+ });
+
+ test("displays input for follicular phase goal", async ({ page }) => {
+ const follicularInput = page.getByLabel(/follicular/i);
+ await expect(follicularInput).toBeVisible();
+ });
+
+ test("displays input for ovulation phase goal", async ({ page }) => {
+ const ovulationInput = page.getByLabel(/ovulation/i);
+ await expect(ovulationInput).toBeVisible();
+ });
+
+ test("displays input for early luteal phase goal", async ({ page }) => {
+ const earlyLutealInput = page.getByLabel(/early luteal/i);
+ await expect(earlyLutealInput).toBeVisible();
+ });
+
+ test("displays input for late luteal phase goal", async ({ page }) => {
+ const lateLutealInput = page.getByLabel(/late luteal/i);
+ await expect(lateLutealInput).toBeVisible();
+ });
+
+ test("can modify menstrual phase goal and save", async ({ page }) => {
+ const menstrualInput = page.getByLabel(/menstrual/i);
+ const isVisible = await menstrualInput.isVisible().catch(() => false);
+
+ if (!isVisible) {
+ test.skip();
+ return;
+ }
+
+ // Get original value
+ const originalValue = await menstrualInput.inputValue();
+
+ // Set a different value
+ const newValue = originalValue === "75" ? "80" : "75";
+ await menstrualInput.fill(newValue);
+
+ // Save and wait for success toast
+ const saveButton = page.getByRole("button", { name: /save/i });
+ await saveButton.click();
+ await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
+ timeout: 10000,
+ });
+
+ // Restore original value
+ await menstrualInput.fill(originalValue);
+ await saveButton.click();
+ await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
+ timeout: 10000,
+ });
+ });
+
+ test("persists intensity goal value after page reload", async ({
+ page,
+ }) => {
+ const menstrualInput = page.getByLabel(/menstrual/i);
+ const isVisible = await menstrualInput.isVisible().catch(() => false);
+
+ if (!isVisible) {
+ test.skip();
+ return;
+ }
+
+ // Get original value
+ const originalValue = await menstrualInput.inputValue();
+
+ // Set a different value
+ const newValue = originalValue === "75" ? "85" : "75";
+ await menstrualInput.fill(newValue);
+
+ // Save and wait for success toast
+ const saveButton = page.getByRole("button", { name: /save/i });
+ await saveButton.click();
+ await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
+ timeout: 10000,
+ });
+
+ // Reload the page
+ await page.reload();
+ await page.waitForLoadState("networkidle");
+
+ // Check the value persisted
+ const menstrualAfter = page.getByLabel(/menstrual/i);
+ const afterValue = await menstrualAfter.inputValue();
+
+ expect(afterValue).toBe(newValue);
+
+ // Restore original value
+ await menstrualAfter.fill(originalValue);
+ await saveButton.click();
+ await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
+ timeout: 10000,
+ });
+ });
+
+ test("intensity goal inputs have number type and min attribute", async ({
+ page,
+ }) => {
+ const menstrualInput = page.getByLabel(/menstrual/i);
+ const isVisible = await menstrualInput.isVisible().catch(() => false);
+
+ if (!isVisible) {
+ test.skip();
+ return;
+ }
+
+ // Check type attribute
+ const inputType = await menstrualInput.getAttribute("type");
+ expect(inputType).toBe("number");
+
+ // Check min attribute
+ const inputMin = await menstrualInput.getAttribute("min");
+ expect(inputMin).toBe("0");
+ });
+
+ test("all intensity goal inputs are disabled while saving", async ({
+ page,
+ }) => {
+ const menstrualInput = page.getByLabel(/menstrual/i);
+ const isVisible = await menstrualInput.isVisible().catch(() => false);
+
+ if (!isVisible) {
+ test.skip();
+ return;
+ }
+
+ // Start saving (slow down the response to catch disabled state)
+ await page.route("**/api/user", async (route) => {
+ if (route.request().method() === "PATCH") {
+ // Delay response to allow testing disabled state
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ await route.continue();
+ } else {
+ await route.continue();
+ }
+ });
+
+ const saveButton = page.getByRole("button", { name: /save/i });
+ await saveButton.click();
+
+ // Check inputs are disabled during save
+ await expect(menstrualInput).toBeDisabled();
+
+ // Wait for save to complete
+ await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
+ timeout: 10000,
+ });
+
+ // Clean up route interception
+ await page.unroute("**/api/user");
+ });
+ });
});
diff --git a/src/app/api/cron/garmin-sync/route.test.ts b/src/app/api/cron/garmin-sync/route.test.ts
index 80d4823..a82b03d 100644
--- a/src/app/api/cron/garmin-sync/route.test.ts
+++ b/src/app/api/cron/garmin-sync/route.test.ts
@@ -10,6 +10,10 @@ let mockUsers: User[] = [];
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
// Track user updates
const mockPbUpdate = vi.fn().mockResolvedValue({});
+// Track DailyLog queries for upsert
+const mockGetFirstListItem = vi.fn();
+// Track the filter string passed to getFirstListItem
+let lastDailyLogFilter: string | null = null;
// Helper to parse date values - handles both Date objects and ISO strings
function parseDate(value: unknown): Date | null {
@@ -30,6 +34,12 @@ vi.mock("@/lib/pocketbase", () => ({
}
return [];
}),
+ getFirstListItem: vi.fn(async (filter: string) => {
+ if (name === "dailyLogs") {
+ lastDailyLogFilter = filter;
+ }
+ return mockGetFirstListItem(filter);
+ }),
create: mockPbCreate,
update: mockPbUpdate,
authWithPassword: vi.fn().mockResolvedValue({ token: "admin-token" }),
@@ -168,8 +178,13 @@ describe("POST /api/cron/garmin-sync", () => {
vi.clearAllMocks();
vi.resetModules();
mockUsers = [];
+ lastDailyLogFilter = null;
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation
+ // Default: no existing dailyLog found (404)
+ const notFoundError = new Error("Record not found");
+ (notFoundError as { status?: number }).status = 404;
+ mockGetFirstListItem.mockRejectedValue(notFoundError);
process.env.CRON_SECRET = validSecret;
process.env.POCKETBASE_ADMIN_EMAIL = "admin@test.com";
process.env.POCKETBASE_ADMIN_PASSWORD = "test-password";
@@ -441,6 +456,78 @@ describe("POST /api/cron/garmin-sync", () => {
});
});
+ describe("DailyLog upsert behavior", () => {
+ it("uses range query to find existing dailyLog", async () => {
+ mockUsers = [createMockUser()];
+ const today = new Date().toISOString().split("T")[0];
+ const tomorrow = new Date(Date.now() + 86400000)
+ .toISOString()
+ .split("T")[0];
+
+ await POST(createMockRequest(`Bearer ${validSecret}`));
+
+ // Should use range query with >= and < operators, not exact match
+ expect(lastDailyLogFilter).toContain(`date>="${today}"`);
+ expect(lastDailyLogFilter).toContain(`date<"${tomorrow}"`);
+ expect(lastDailyLogFilter).toContain('user="user123"');
+ });
+
+ it("updates existing dailyLog when found", async () => {
+ mockUsers = [createMockUser()];
+ // Existing dailyLog found
+ mockGetFirstListItem.mockResolvedValue({ id: "existing-log-123" });
+
+ await POST(createMockRequest(`Bearer ${validSecret}`));
+
+ // Should update, not create
+ expect(mockPbUpdate).toHaveBeenCalledWith(
+ "existing-log-123",
+ expect.objectContaining({
+ user: "user123",
+ hrvStatus: "Balanced",
+ }),
+ );
+ expect(mockPbCreate).not.toHaveBeenCalled();
+ });
+
+ it("creates new dailyLog only when not found (404)", async () => {
+ mockUsers = [createMockUser()];
+ // No existing dailyLog (404 error)
+ const notFoundError = new Error("Record not found");
+ (notFoundError as { status?: number }).status = 404;
+ mockGetFirstListItem.mockRejectedValue(notFoundError);
+
+ await POST(createMockRequest(`Bearer ${validSecret}`));
+
+ // Should create, not update
+ expect(mockPbCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user: "user123",
+ }),
+ );
+ expect(mockPbUpdate).not.toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ user: "user123" }),
+ );
+ });
+
+ it("propagates non-404 errors from getFirstListItem", async () => {
+ mockUsers = [createMockUser()];
+ // Database error (not 404)
+ const dbError = new Error("Database connection failed");
+ (dbError as { status?: number }).status = 500;
+ mockGetFirstListItem.mockRejectedValue(dbError);
+
+ const response = await POST(createMockRequest(`Bearer ${validSecret}`));
+
+ // Should not try to create a new record
+ expect(mockPbCreate).not.toHaveBeenCalled();
+ // Should count as error
+ const body = await response.json();
+ expect(body.errors).toBe(1);
+ });
+ });
+
describe("Error handling", () => {
it("continues processing other users when one fails", async () => {
mockUsers = [
diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts
index bdadb7e..b2b577d 100644
--- a/src/app/api/cron/garmin-sync/route.ts
+++ b/src/app/api/cron/garmin-sync/route.ts
@@ -228,14 +228,35 @@ export async function POST(request: Request) {
};
// Check if record already exists for this user today
+ // Use range query (>= and <) to match the today route query pattern
+ // This ensures we find records regardless of how the date was stored
+ const tomorrow = new Date(Date.now() + 86400000)
+ .toISOString()
+ .split("T")[0];
try {
const existing = await pb
.collection("dailyLogs")
- .getFirstListItem(`user="${user.id}" && date="${today}"`);
+ .getFirstListItem(
+ `user="${user.id}" && date>="${today}" && date<"${tomorrow}"`,
+ );
await pb.collection("dailyLogs").update(existing.id, dailyLogData);
- } catch {
- // No existing record - create new one
- await pb.collection("dailyLogs").create(dailyLogData);
+ logger.info(
+ { userId: user.id, dailyLogId: existing.id },
+ "DailyLog updated",
+ );
+ } catch (err) {
+ // Check if it's a 404 (not found) vs other error
+ if ((err as { status?: number }).status === 404) {
+ const created = await pb.collection("dailyLogs").create(dailyLogData);
+ logger.info(
+ { userId: user.id, dailyLogId: created.id },
+ "DailyLog created",
+ );
+ } else {
+ // Propagate non-404 errors
+ logger.error({ userId: user.id, err }, "Failed to upsert dailyLog");
+ throw err;
+ }
}
// Log sync complete with metrics
diff --git a/src/app/settings/page.test.tsx b/src/app/settings/page.test.tsx
index 68f4359..9b7afce 100644
--- a/src/app/settings/page.test.tsx
+++ b/src/app/settings/page.test.tsx
@@ -37,6 +37,11 @@ describe("SettingsPage", () => {
garminConnected: false,
activeOverrides: [],
lastPeriodDate: "2024-01-01",
+ intensityGoalMenstrual: 75,
+ intensityGoalFollicular: 150,
+ intensityGoalOvulation: 100,
+ intensityGoalEarlyLuteal: 120,
+ intensityGoalLateLuteal: 50,
};
beforeEach(() => {
@@ -240,6 +245,11 @@ describe("SettingsPage", () => {
cycleLength: 30,
notificationTime: "08:00",
timezone: "America/New_York",
+ intensityGoalMenstrual: 75,
+ intensityGoalFollicular: 150,
+ intensityGoalOvulation: 100,
+ intensityGoalEarlyLuteal: 120,
+ intensityGoalLateLuteal: 50,
}),
});
});
@@ -639,4 +649,172 @@ describe("SettingsPage", () => {
});
});
});
+
+ describe("intensity goals section", () => {
+ it("renders Weekly Intensity Goals section heading", async () => {
+ render(
+ Target weekly intensity minutes for each cycle phase +
+ +