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(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /weekly intensity goals/i }), + ).toBeInTheDocument(); + }); + }); + + it("renders input for menstrual phase goal", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument(); + }); + }); + + it("renders input for follicular phase goal", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/follicular/i)).toBeInTheDocument(); + }); + }); + + it("renders input for ovulation phase goal", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/ovulation/i)).toBeInTheDocument(); + }); + }); + + it("renders input for early luteal phase goal", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/early luteal/i)).toBeInTheDocument(); + }); + }); + + it("renders input for late luteal phase goal", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/late luteal/i)).toBeInTheDocument(); + }); + }); + + it("pre-fills intensity goal inputs with current user values", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/menstrual/i)).toHaveValue(75); + expect(screen.getByLabelText(/follicular/i)).toHaveValue(150); + expect(screen.getByLabelText(/ovulation/i)).toHaveValue(100); + expect(screen.getByLabelText(/early luteal/i)).toHaveValue(120); + expect(screen.getByLabelText(/late luteal/i)).toHaveValue(50); + }); + }); + + it("includes intensity goals in PATCH request when saving", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockUser), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ ...mockUser, intensityGoalMenstrual: 80 }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument(); + }); + + const menstrualInput = screen.getByLabelText(/menstrual/i); + fireEvent.change(menstrualInput, { target: { value: "80" } }); + + const saveButton = screen.getByRole("button", { name: /save/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/user", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: expect.stringContaining('"intensityGoalMenstrual":80'), + }); + }); + }); + + it("has number type for all intensity goal inputs", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/menstrual/i)).toHaveAttribute( + "type", + "number", + ); + expect(screen.getByLabelText(/follicular/i)).toHaveAttribute( + "type", + "number", + ); + expect(screen.getByLabelText(/ovulation/i)).toHaveAttribute( + "type", + "number", + ); + expect(screen.getByLabelText(/early luteal/i)).toHaveAttribute( + "type", + "number", + ); + expect(screen.getByLabelText(/late luteal/i)).toHaveAttribute( + "type", + "number", + ); + }); + }); + + it("validates minimum value of 0 for intensity goals", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/menstrual/i)).toHaveAttribute("min", "0"); + }); + }); + + it("disables intensity goal inputs while saving", async () => { + let resolveSave: (value: unknown) => void = () => {}; + const savePromise = new Promise((resolve) => { + resolveSave = resolve; + }); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockUser), + }) + .mockReturnValueOnce({ + ok: true, + json: () => savePromise, + }); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument(); + }); + + const saveButton = screen.getByRole("button", { name: /save/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByLabelText(/menstrual/i)).toBeDisabled(); + expect(screen.getByLabelText(/follicular/i)).toBeDisabled(); + expect(screen.getByLabelText(/ovulation/i)).toBeDisabled(); + expect(screen.getByLabelText(/early luteal/i)).toBeDisabled(); + expect(screen.getByLabelText(/late luteal/i)).toBeDisabled(); + }); + + resolveSave(mockUser); + }); + }); }); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 6822700..6f67a05 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -16,6 +16,11 @@ interface UserData { garminConnected: boolean; activeOverrides: string[]; lastPeriodDate: string | null; + intensityGoalMenstrual: number; + intensityGoalFollicular: number; + intensityGoalOvulation: number; + intensityGoalEarlyLuteal: number; + intensityGoalLateLuteal: number; } export default function SettingsPage() { @@ -29,6 +34,11 @@ export default function SettingsPage() { const [cycleLength, setCycleLength] = useState(28); const [notificationTime, setNotificationTime] = useState("08:00"); const [timezone, setTimezone] = useState(""); + const [intensityGoalMenstrual, setIntensityGoalMenstrual] = useState(75); + const [intensityGoalFollicular, setIntensityGoalFollicular] = useState(150); + const [intensityGoalOvulation, setIntensityGoalOvulation] = useState(100); + const [intensityGoalEarlyLuteal, setIntensityGoalEarlyLuteal] = useState(120); + const [intensityGoalLateLuteal, setIntensityGoalLateLuteal] = useState(50); const fetchUserData = useCallback(async () => { setLoading(true); @@ -46,6 +56,11 @@ export default function SettingsPage() { setCycleLength(data.cycleLength); setNotificationTime(data.notificationTime); setTimezone(data.timezone); + setIntensityGoalMenstrual(data.intensityGoalMenstrual ?? 75); + setIntensityGoalFollicular(data.intensityGoalFollicular ?? 150); + setIntensityGoalOvulation(data.intensityGoalOvulation ?? 100); + setIntensityGoalEarlyLuteal(data.intensityGoalEarlyLuteal ?? 120); + setIntensityGoalLateLuteal(data.intensityGoalLateLuteal ?? 50); } catch (err) { const message = err instanceof Error ? err.message : "An error occurred"; setLoadError(message); @@ -79,6 +94,11 @@ export default function SettingsPage() { cycleLength, notificationTime, timezone, + intensityGoalMenstrual, + intensityGoalFollicular, + intensityGoalOvulation, + intensityGoalEarlyLuteal, + intensityGoalLateLuteal, }), }); @@ -250,6 +270,132 @@ export default function SettingsPage() {

+
+

+ Weekly Intensity Goals +

+

+ Target weekly intensity minutes for each cycle phase +

+ +
+
+ + + handleInputChange( + setIntensityGoalMenstrual, + Number(e.target.value), + ) + } + disabled={saving} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed" + /> +
+ +
+ + + handleInputChange( + setIntensityGoalFollicular, + Number(e.target.value), + ) + } + disabled={saving} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed" + /> +
+ +
+ + + handleInputChange( + setIntensityGoalOvulation, + Number(e.target.value), + ) + } + disabled={saving} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed" + /> +
+ +
+ + + handleInputChange( + setIntensityGoalEarlyLuteal, + Number(e.target.value), + ) + } + disabled={saving} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed" + /> +
+ +
+ + + handleInputChange( + setIntensityGoalLateLuteal, + Number(e.target.value), + ) + } + disabled={saving} + className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed" + /> +
+
+
+