Fix garmin-sync upsert and add Settings UI for intensity goals
All checks were successful
Deploy / deploy (push) Successful in 1m39s

- Fix dailyLog upsert to use range query (matches today route pattern)
- Properly distinguish 404 errors from other failures in upsert logic
- Add logging for dailyLog create/update operations
- Add Settings UI section for weekly intensity goals per phase
- Add unit tests for upsert behavior and intensity goals UI
- Add E2E tests for intensity goals settings flow

This fixes the issue where Garmin sync was creating new dailyLog
records instead of updating existing ones (322 vs 222 intensity
minutes bug, Unknown HRV bug).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 20:53:43 +00:00
parent 6cd0c06396
commit 8956e04eca
5 changed files with 630 additions and 4 deletions

View File

@@ -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");
});
});
});

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("heading", { name: /weekly intensity goals/i }),
).toBeInTheDocument();
});
});
it("renders input for menstrual phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
});
});
it("renders input for follicular phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/follicular/i)).toBeInTheDocument();
});
});
it("renders input for ovulation phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/ovulation/i)).toBeInTheDocument();
});
});
it("renders input for early luteal phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/early luteal/i)).toBeInTheDocument();
});
});
it("renders input for late luteal phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/late luteal/i)).toBeInTheDocument();
});
});
it("pre-fills intensity goal inputs with current user values", async () => {
render(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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);
});
});
});

View File

@@ -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() {
</p>
</div>
<div className="pt-6">
<h2 className="text-lg font-medium text-foreground mb-4">
Weekly Intensity Goals
</h2>
<p className="text-sm text-muted-foreground mb-4">
Target weekly intensity minutes for each cycle phase
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="intensityGoalMenstrual"
className="block text-sm font-medium text-foreground"
>
Menstrual
</label>
<input
id="intensityGoalMenstrual"
type="number"
min="0"
value={intensityGoalMenstrual}
onChange={(e) =>
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"
/>
</div>
<div>
<label
htmlFor="intensityGoalFollicular"
className="block text-sm font-medium text-foreground"
>
Follicular
</label>
<input
id="intensityGoalFollicular"
type="number"
min="0"
value={intensityGoalFollicular}
onChange={(e) =>
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"
/>
</div>
<div>
<label
htmlFor="intensityGoalOvulation"
className="block text-sm font-medium text-foreground"
>
Ovulation
</label>
<input
id="intensityGoalOvulation"
type="number"
min="0"
value={intensityGoalOvulation}
onChange={(e) =>
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"
/>
</div>
<div>
<label
htmlFor="intensityGoalEarlyLuteal"
className="block text-sm font-medium text-foreground"
>
Early Luteal
</label>
<input
id="intensityGoalEarlyLuteal"
type="number"
min="0"
value={intensityGoalEarlyLuteal}
onChange={(e) =>
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"
/>
</div>
<div className="col-span-2 sm:col-span-1">
<label
htmlFor="intensityGoalLateLuteal"
className="block text-sm font-medium text-foreground"
>
Late Luteal
</label>
<input
id="intensityGoalLateLuteal"
type="number"
min="0"
value={intensityGoalLateLuteal}
onChange={(e) =>
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"
/>
</div>
</div>
</div>
<div className="pt-4">
<button
type="submit"