Fix garmin-sync upsert and add Settings UI for intensity goals
All checks were successful
Deploy / deploy (push) Successful in 1m39s
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user