Compare commits
12 Commits
a1495ff23f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec3d341e51 | |||
| 1a9a327a30 | |||
| d4b04a17be | |||
| 092d8bb3dd | |||
| 0d5785aaaa | |||
| 9c4fcd8b52 | |||
| 140de56450 | |||
| ccbc86016d | |||
| 6543c79a04 | |||
| ed14aea0ea | |||
| 8956e04eca | |||
| 6cd0c06396 |
@@ -11,8 +11,10 @@ NODE_ENV=development
|
||||
POCKETBASE_URL=http://localhost:8090
|
||||
NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
|
||||
|
||||
# Email (Resend)
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||
# Email (Mailgun)
|
||||
MAILGUN_API_KEY=key-xxxxxxxxxxxx
|
||||
MAILGUN_DOMAIN=yourdomain.com
|
||||
MAILGUN_URL=https://api.eu.mailgun.net # Use https://api.mailgun.net for US region
|
||||
EMAIL_FROM=phaseflow@yourdomain.com
|
||||
|
||||
# Encryption (for Garmin tokens)
|
||||
|
||||
@@ -18,6 +18,7 @@ Run these after implementing to get immediate feedback:
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Production URL: https://phaseflow.v.paler.net
|
||||
- Database: PocketBase at `NEXT_PUBLIC_POCKETBASE_URL` env var
|
||||
- Deployment config: `../alo-cluster/services/phaseflow.hcl` (Nomad job)
|
||||
- Garmin tokens encrypted with AES-256 using `ENCRYPTION_KEY` (32 chars)
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"form-data": "^4.0.1",
|
||||
"ics": "^3.8.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mailgun.js": "^11.1.0",
|
||||
"next": "16.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
@@ -29,7 +31,6 @@
|
||||
"prom-client": "^15.1.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"resend": "^6.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
259
pnpm-lock.yaml
generated
259
pnpm-lock.yaml
generated
@@ -17,12 +17,18 @@ importers:
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.1
|
||||
version: 0.45.1(@opentelemetry/api@1.9.0)
|
||||
form-data:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.5
|
||||
ics:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1
|
||||
lucide-react:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(react@19.2.3)
|
||||
mailgun.js:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
next:
|
||||
specifier: 16.1.1
|
||||
version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -47,9 +53,6 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
resend:
|
||||
specifier: ^6.7.0
|
||||
version: 6.7.0
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -1119,9 +1122,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -1337,10 +1337,19 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
|
||||
base-64@1.0.0:
|
||||
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
|
||||
|
||||
baseline-browser-mapping@2.9.14:
|
||||
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
||||
hasBin: true
|
||||
@@ -1359,6 +1368,10 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
caniuse-lite@1.0.30001763:
|
||||
resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==}
|
||||
|
||||
@@ -1376,6 +1389,10 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
@@ -1409,6 +1426,10 @@ packages:
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1519,6 +1540,10 @@ packages:
|
||||
sqlite3:
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
electron-to-chromium@1.5.267:
|
||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||
|
||||
@@ -1530,9 +1555,25 @@ packages:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild-register@3.6.0:
|
||||
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
||||
peerDependencies:
|
||||
@@ -1564,9 +1605,6 @@ packages:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-sha256@1.3.0:
|
||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1576,6 +1614,19 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
follow-redirects@1.15.11:
|
||||
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -1586,16 +1637,43 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
@@ -1733,9 +1811,25 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
mailgun.js@11.1.0:
|
||||
resolution: {integrity: sha512-pXYcQT3nU32gMjUjZpl2FdQN4Vv2iobqYiXqyyevk0vXTKQj8Or0ifLXLNAGqMHnymTjV0OphBpurkchvHsRAg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdn-data@2.12.2:
|
||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
min-indent@1.0.1:
|
||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1844,6 +1938,9 @@ packages:
|
||||
property-expr@2.0.6:
|
||||
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1879,15 +1976,6 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
resend@6.7.0:
|
||||
resolution: {integrity: sha512-2ZV0NDZsh4Gh+Nd1hvluZIitmGJ59O4+OxMufymG6Y8uz1Jgt2uS1seSENnkIUlmwg7/dwmfIJC9rAufByz7wA==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@react-email/render': '*'
|
||||
peerDependenciesMeta:
|
||||
'@react-email/render':
|
||||
optional: true
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
@@ -1953,9 +2041,6 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
@@ -1976,9 +2061,6 @@ packages:
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
svix@1.84.1:
|
||||
resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==}
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
@@ -2059,9 +2141,8 @@ packages:
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
hasBin: true
|
||||
url-join@4.0.1:
|
||||
resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}
|
||||
|
||||
vite@7.3.1:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
@@ -2851,8 +2932,6 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
@@ -3071,8 +3150,20 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
axios@1.13.2:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
base-64@1.0.0: {}
|
||||
|
||||
baseline-browser-mapping@2.9.14: {}
|
||||
|
||||
bidi-js@1.0.3:
|
||||
@@ -3091,6 +3182,11 @@ snapshots:
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
caniuse-lite@1.0.30001763: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
@@ -3103,6 +3199,10 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
css-tree@3.1.0:
|
||||
@@ -3132,6 +3232,8 @@ snapshots:
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
@@ -3153,6 +3255,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
electron-to-chromium@1.5.267: {}
|
||||
|
||||
enhanced-resolve@5.18.4:
|
||||
@@ -3162,8 +3270,23 @@ snapshots:
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
esbuild-register@3.6.0(esbuild@0.25.12):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -3262,26 +3385,66 @@ snapshots:
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fast-sha256@1.3.0: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.2
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.8.0
|
||||
@@ -3413,8 +3576,24 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
mailgun.js@11.1.0:
|
||||
dependencies:
|
||||
axios: 1.13.2
|
||||
base-64: 1.0.0
|
||||
url-join: 4.0.1
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
@@ -3524,6 +3703,8 @@ snapshots:
|
||||
|
||||
property-expr@2.0.6: {}
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
@@ -3548,10 +3729,6 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
resend@6.7.0:
|
||||
dependencies:
|
||||
svix: 1.84.1
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
rollup@4.55.1:
|
||||
@@ -3656,11 +3833,6 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
dependencies:
|
||||
'@stablelib/base64': 1.0.1
|
||||
fast-sha256: 1.3.0
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
strip-indent@3.0.0:
|
||||
@@ -3674,11 +3846,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.5
|
||||
|
||||
svix@1.84.1:
|
||||
dependencies:
|
||||
standardwebhooks: 1.0.0
|
||||
uuid: 10.0.0
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tailwind-merge@3.4.0: {}
|
||||
@@ -3740,7 +3907,7 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uuid@10.0.0: {}
|
||||
url-join@4.0.1: {}
|
||||
|
||||
vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2):
|
||||
dependencies:
|
||||
|
||||
@@ -156,6 +156,12 @@ export const USER_CUSTOM_FIELDS: CollectionField[] = [
|
||||
{ name: "notificationTime", type: "text" },
|
||||
{ name: "timezone", type: "text" },
|
||||
{ name: "activeOverrides", type: "json" },
|
||||
// Phase-specific intensity goals (weekly minutes)
|
||||
{ name: "intensityGoalMenstrual", type: "number" },
|
||||
{ name: "intensityGoalFollicular", type: "number" },
|
||||
{ name: "intensityGoalOvulation", type: "number" },
|
||||
{ name: "intensityGoalEarlyLuteal", type: "number" },
|
||||
{ name: "intensityGoalLateLuteal", type: "number" },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,6 +86,11 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -48,6 +48,11 @@ describe("POST /api/calendar/regenerate-token", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -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" }),
|
||||
@@ -49,6 +59,12 @@ vi.mock("@/lib/pocketbase", () => ({
|
||||
notificationTime: record.notificationTime,
|
||||
timezone: record.timezone,
|
||||
activeOverrides: record.activeOverrides || [],
|
||||
intensityGoalMenstrual: (record.intensityGoalMenstrual as number) ?? 75,
|
||||
intensityGoalFollicular: (record.intensityGoalFollicular as number) ?? 150,
|
||||
intensityGoalOvulation: (record.intensityGoalOvulation as number) ?? 100,
|
||||
intensityGoalEarlyLuteal:
|
||||
(record.intensityGoalEarlyLuteal as number) ?? 120,
|
||||
intensityGoalLateLuteal: (record.intensityGoalLateLuteal as number) ?? 50,
|
||||
created: new Date(record.created as string),
|
||||
updated: new Date(record.updated as string),
|
||||
})),
|
||||
@@ -136,6 +152,11 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
@@ -157,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";
|
||||
@@ -430,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 = [
|
||||
@@ -462,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,
|
||||
@@ -477,8 +574,8 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
|
||||
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bodyBatteryCurrent: 100,
|
||||
bodyBatteryYesterdayLow: 100,
|
||||
bodyBatteryCurrent: null,
|
||||
bodyBatteryYesterdayLow: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
|
||||
import { getCycleDay, getPhase, getUserPhaseLimit } from "@/lib/cycle";
|
||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||
import { sendTokenExpirationWarning } from "@/lib/email";
|
||||
import { decrypt, encrypt } from "@/lib/encryption";
|
||||
@@ -190,34 +190,34 @@ export async function POST(request: Request) {
|
||||
new Date(),
|
||||
);
|
||||
const phase = getPhase(cycleDay, user.cycleLength);
|
||||
const phaseLimit = getPhaseLimit(phase);
|
||||
const phaseLimit = getUserPhaseLimit(phase, user);
|
||||
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,
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -61,6 +73,11 @@ describe("POST /api/cron/notifications", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
@@ -105,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"));
|
||||
@@ -138,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", () => {
|
||||
@@ -200,6 +250,112 @@ describe("POST /api/cron/notifications", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quarter-hour time matching", () => {
|
||||
it("sends notification at exact 15-minute slot (07:15)", async () => {
|
||||
// Current time is 07:15 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:15:00Z"));
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:15", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rounds down notification time to nearest 15-minute slot (07:10 -> 07:00)", async () => {
|
||||
// Current time is 07:00 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
|
||||
// User set 07:10, which rounds down to 07:00 slot
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:10", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rounds down notification time (07:29 -> 07:15)", async () => {
|
||||
// Current time is 07:15 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:15:00Z"));
|
||||
// User set 07:29, which rounds down to 07:15 slot
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:29", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send notification when minute slot does not match", async () => {
|
||||
// Current time is 07:00 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
|
||||
// User wants 07:15, but current slot is 07:00
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:15", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles 30-minute slot correctly", async () => {
|
||||
// Current time is 07:30 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:30:00Z"));
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:30", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles 45-minute slot correctly", async () => {
|
||||
// Current time is 07:45 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:45:00Z"));
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:45", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles timezone with 15-minute matching", async () => {
|
||||
// Current time is 07:15 UTC = 02:15 America/New_York (EST is UTC-5)
|
||||
vi.setSystemTime(new Date("2025-01-15T07:15:00Z"));
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
notificationTime: "02:15",
|
||||
timezone: "America/New_York",
|
||||
}),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DailyLog handling", () => {
|
||||
it("does not send notification if no DailyLog exists for today", async () => {
|
||||
mockUsers = [
|
||||
|
||||
@@ -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";
|
||||
@@ -17,19 +18,40 @@ interface NotificationResult {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Get the current hour in a specific timezone
|
||||
function getCurrentHourInTimezone(timezone: string): number {
|
||||
// Get current quarter-hour slot (0, 15, 30, 45) in user's timezone
|
||||
function getCurrentQuarterHourSlot(timezone: string): {
|
||||
hour: number;
|
||||
minute: number;
|
||||
} {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: false,
|
||||
});
|
||||
return parseInt(formatter.format(new Date()), 10);
|
||||
const parts = formatter.formatToParts(new Date());
|
||||
const hour = Number.parseInt(
|
||||
parts.find((p) => p.type === "hour")?.value ?? "0",
|
||||
10,
|
||||
);
|
||||
const minute = Number.parseInt(
|
||||
parts.find((p) => p.type === "minute")?.value ?? "0",
|
||||
10,
|
||||
);
|
||||
// Round down to nearest 15-min slot
|
||||
const slot = Math.floor(minute / 15) * 15;
|
||||
return { hour, minute: slot };
|
||||
}
|
||||
|
||||
// Extract hour from "HH:MM" format
|
||||
function getNotificationHour(notificationTime: string): number {
|
||||
return parseInt(notificationTime.split(":")[0], 10);
|
||||
// Extract quarter-hour slot from "HH:MM" format
|
||||
function getNotificationSlot(notificationTime: string): {
|
||||
hour: number;
|
||||
minute: number;
|
||||
} {
|
||||
const [h, m] = notificationTime.split(":").map(Number);
|
||||
// Round down to nearest 15-min slot
|
||||
const slot = Math.floor(m / 15) * 15;
|
||||
return { hour: h, minute: slot };
|
||||
}
|
||||
|
||||
// Map decision status to icon
|
||||
@@ -69,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];
|
||||
@@ -95,11 +144,14 @@ export async function POST(request: Request) {
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
// Check if current hour in user's timezone matches their notification time
|
||||
const currentHour = getCurrentHourInTimezone(user.timezone);
|
||||
const notificationHour = getNotificationHour(user.notificationTime);
|
||||
// Check if current quarter-hour slot in user's timezone matches their notification time
|
||||
const currentSlot = getCurrentQuarterHourSlot(user.timezone);
|
||||
const notificationSlot = getNotificationSlot(user.notificationTime);
|
||||
|
||||
if (currentHour !== notificationHour) {
|
||||
if (
|
||||
currentSlot.hour !== notificationSlot.hour ||
|
||||
currentSlot.minute !== notificationSlot.minute
|
||||
) {
|
||||
result.skippedWrongTime++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@ describe("GET /api/cycle/current", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
@@ -137,11 +142,11 @@ describe("GET /api/cycle/current", () => {
|
||||
|
||||
expect(body.phaseConfig).toBeDefined();
|
||||
expect(body.phaseConfig.name).toBe("FOLLICULAR");
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(120);
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(150);
|
||||
expect(body.phaseConfig.trainingType).toBe("Strength + rebounding");
|
||||
// Phase configs days are for reference; actual boundaries are calculated dynamically
|
||||
expect(body.phaseConfig.days).toEqual([4, 15]);
|
||||
expect(body.phaseConfig.dailyAvg).toBe(17);
|
||||
expect(body.phaseConfig.dailyAvg).toBe(21);
|
||||
});
|
||||
|
||||
it("calculates daysUntilNextPhase correctly", async () => {
|
||||
@@ -174,7 +179,7 @@ describe("GET /api/cycle/current", () => {
|
||||
|
||||
expect(body.cycleDay).toBe(3);
|
||||
expect(body.phase).toBe("MENSTRUAL");
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(30);
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(75);
|
||||
expect(body.daysUntilNextPhase).toBe(1); // Day 4 is FOLLICULAR
|
||||
});
|
||||
|
||||
@@ -194,7 +199,7 @@ describe("GET /api/cycle/current", () => {
|
||||
|
||||
expect(body.cycleDay).toBe(16);
|
||||
expect(body.phase).toBe("OVULATION");
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(80);
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(100);
|
||||
expect(body.daysUntilNextPhase).toBe(2); // Day 18 is EARLY_LUTEAL
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ describe("POST /api/cycle/period", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -78,6 +78,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -108,6 +113,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -138,6 +148,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -166,6 +181,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -196,6 +216,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -228,6 +253,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -258,6 +288,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -288,6 +323,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -318,6 +358,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -345,6 +390,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -60,6 +60,11 @@ describe("POST /api/garmin/tokens", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -276,6 +281,11 @@ describe("DELETE /api/garmin/tokens", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -48,6 +48,11 @@ describe("GET /api/history", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -62,6 +62,11 @@ describe("POST /api/overrides", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: overrides,
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
});
|
||||
@@ -195,6 +200,11 @@ describe("DELETE /api/overrides", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: overrides,
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
});
|
||||
|
||||
@@ -48,6 +48,11 @@ describe("GET /api/period-history", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -57,6 +57,11 @@ describe("PATCH /api/period-logs/[id]", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -284,6 +289,11 @@ describe("DELETE /api/period-logs/[id]", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
59
src/app/api/test/email/route.ts
Normal file
59
src/app/api/test/email/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// ABOUTME: Test endpoint for verifying email configuration.
|
||||
// ABOUTME: Sends a test email to verify Mailgun integration works.
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DailyEmailData } from "@/lib/email";
|
||||
import { sendDailyEmail } from "@/lib/email";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Verify cron secret (reuse same auth as cron endpoints)
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const expectedSecret = process.env.CRON_SECRET;
|
||||
|
||||
if (!expectedSecret || authHeader !== `Bearer ${expectedSecret}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const to = body.to as string;
|
||||
|
||||
if (!to) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing 'to' email address in request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const testData: DailyEmailData = {
|
||||
to,
|
||||
cycleDay: 15,
|
||||
phase: "OVULATION",
|
||||
decision: {
|
||||
status: "TRAIN",
|
||||
reason: "This is a test email to verify Mailgun configuration works!",
|
||||
icon: "🧪",
|
||||
},
|
||||
bodyBatteryCurrent: 85,
|
||||
bodyBatteryYesterdayLow: 45,
|
||||
hrvStatus: "Balanced",
|
||||
weekIntensity: 60,
|
||||
phaseLimit: 80,
|
||||
remainingMinutes: 20,
|
||||
seeds: "Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
|
||||
carbRange: "100-150g",
|
||||
ketoGuidance: "No - exit keto, need carbs for ovulation",
|
||||
};
|
||||
|
||||
try {
|
||||
await sendDailyEmail(testData, "test-user");
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Test email sent to ${to}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,12 @@ import type { DailyLog, User } from "@/types";
|
||||
// Module-level variable to control mock user in tests
|
||||
let currentMockUser: User | null = null;
|
||||
|
||||
// Module-level variable to control mock daily log in tests
|
||||
// Module-level variable to control mock daily log for today in tests
|
||||
let currentMockDailyLog: DailyLog | null = null;
|
||||
|
||||
// Module-level variable to control mock daily log for fallback (most recent)
|
||||
let fallbackMockDailyLog: DailyLog | null = null;
|
||||
|
||||
// Track the filter string passed to getFirstListItem
|
||||
let lastDailyLogFilter: string | null = null;
|
||||
|
||||
@@ -37,13 +40,33 @@ const mockPb = {
|
||||
// Capture the filter for testing
|
||||
if (collectionName === "dailyLogs") {
|
||||
lastDailyLogFilter = filter;
|
||||
}
|
||||
|
||||
// Check if this is a query for today's log (has date range filter)
|
||||
const isTodayQuery =
|
||||
filter.includes("date>=") && filter.includes("date<");
|
||||
if (isTodayQuery) {
|
||||
if (!currentMockDailyLog) {
|
||||
const error = new Error("No DailyLog found");
|
||||
const error = new Error("No DailyLog found for today");
|
||||
(error as { status?: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
return currentMockDailyLog;
|
||||
}
|
||||
|
||||
// This is the fallback query for most recent log
|
||||
if (fallbackMockDailyLog) {
|
||||
return fallbackMockDailyLog;
|
||||
}
|
||||
if (currentMockDailyLog) {
|
||||
return currentMockDailyLog;
|
||||
}
|
||||
const error = new Error("No DailyLog found");
|
||||
(error as { status?: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
const error = new Error("No DailyLog found");
|
||||
(error as { status?: number }).status = 404;
|
||||
throw error;
|
||||
}),
|
||||
})),
|
||||
};
|
||||
@@ -77,6 +100,11 @@ describe("GET /api/today", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
@@ -107,6 +135,7 @@ describe("GET /api/today", () => {
|
||||
vi.clearAllMocks();
|
||||
currentMockUser = null;
|
||||
currentMockDailyLog = null;
|
||||
fallbackMockDailyLog = null;
|
||||
lastDailyLogFilter = null;
|
||||
// Mock current date to 2025-01-10 for predictable testing
|
||||
vi.useFakeTimers();
|
||||
@@ -369,7 +398,7 @@ describe("GET /api/today", () => {
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.phaseConfig.name).toBe("FOLLICULAR");
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(120);
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(150);
|
||||
});
|
||||
|
||||
it("returns days until next phase", async () => {
|
||||
@@ -566,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);
|
||||
});
|
||||
|
||||
@@ -582,9 +611,95 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DailyLog fallback to most recent", () => {
|
||||
it("returns lastSyncedAt as today when today's DailyLog exists", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = createMockDailyLog();
|
||||
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.lastSyncedAt).toBe("2025-01-10");
|
||||
});
|
||||
|
||||
it("uses yesterday's DailyLog when today's does not exist", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = null; // No today's log
|
||||
// Yesterday's log with different biometrics
|
||||
fallbackMockDailyLog = createMockDailyLog({
|
||||
date: new Date("2025-01-09"),
|
||||
hrvStatus: "Balanced",
|
||||
bodyBatteryCurrent: 72,
|
||||
bodyBatteryYesterdayLow: 38,
|
||||
weekIntensityMinutes: 90,
|
||||
phaseLimit: 150,
|
||||
});
|
||||
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
// Should use fallback data
|
||||
expect(body.biometrics.hrvStatus).toBe("Balanced");
|
||||
expect(body.biometrics.bodyBatteryCurrent).toBe(72);
|
||||
expect(body.biometrics.bodyBatteryYesterdayLow).toBe(38);
|
||||
expect(body.biometrics.weekIntensityMinutes).toBe(90);
|
||||
});
|
||||
|
||||
it("returns lastSyncedAt as yesterday's date when using fallback", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = null;
|
||||
fallbackMockDailyLog = createMockDailyLog({
|
||||
date: new Date("2025-01-09"),
|
||||
});
|
||||
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.lastSyncedAt).toBe("2025-01-09");
|
||||
});
|
||||
|
||||
it("returns null lastSyncedAt when no logs exist at all", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = null;
|
||||
fallbackMockDailyLog = null;
|
||||
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.lastSyncedAt).toBeNull();
|
||||
// Should use DEFAULT_BIOMETRICS with null for body battery
|
||||
expect(body.biometrics.hrvStatus).toBe("Unknown");
|
||||
expect(body.biometrics.bodyBatteryCurrent).toBeNull();
|
||||
expect(body.biometrics.bodyBatteryYesterdayLow).toBeNull();
|
||||
});
|
||||
|
||||
it("handles fallback log with string date format", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = null;
|
||||
fallbackMockDailyLog = createMockDailyLog({
|
||||
date: "2025-01-08T10:00:00Z" as unknown as Date,
|
||||
});
|
||||
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.lastSyncedAt).toBe("2025-01-08");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,23 +7,24 @@ import {
|
||||
getCycleDay,
|
||||
getPhase,
|
||||
getPhaseConfig,
|
||||
getPhaseLimit,
|
||||
getUserPhaseLimit,
|
||||
} from "@/lib/cycle";
|
||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition";
|
||||
import { mapRecordToUser } from "@/lib/pocketbase";
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -31,7 +32,8 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
// Fetch fresh user data from database to get latest values
|
||||
// The user param from withAuth is from auth store cache which may be stale
|
||||
// (e.g., after logging a period, the cookie still has old data)
|
||||
const freshUser = await pb.collection("users").getOne(user.id);
|
||||
const freshUserRecord = await pb.collection("users").getOne(user.id);
|
||||
const freshUser = mapRecordToUser(freshUserRecord);
|
||||
|
||||
// Validate required user data
|
||||
if (!freshUser.lastPeriodDate) {
|
||||
@@ -43,15 +45,15 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const lastPeriodDate = new Date(freshUser.lastPeriodDate as string);
|
||||
const cycleLength = freshUser.cycleLength as number;
|
||||
const activeOverrides = (freshUser.activeOverrides as string[]) || [];
|
||||
const lastPeriodDate = freshUser.lastPeriodDate;
|
||||
const cycleLength = freshUser.cycleLength;
|
||||
const activeOverrides = freshUser.activeOverrides || [];
|
||||
|
||||
// Calculate cycle information
|
||||
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
|
||||
const phase = getPhase(cycleDay, cycleLength);
|
||||
const phaseConfig = getPhaseConfig(phase);
|
||||
const phaseLimit = getPhaseLimit(phase);
|
||||
const phaseLimit = getUserPhaseLimit(phase, freshUser);
|
||||
|
||||
// Calculate days until next phase using dynamic boundaries
|
||||
// Phase boundaries: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14),
|
||||
@@ -70,18 +72,19 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
daysUntilNextPhase = cycleLength - 6 - cycleDay;
|
||||
}
|
||||
|
||||
// Try to fetch today's DailyLog for biometrics
|
||||
// Try to fetch today's DailyLog for biometrics, fall back to most recent
|
||||
// Sort by date DESC to get the most recent record if multiple exist
|
||||
let biometrics = { ...DEFAULT_BIOMETRICS, phaseLimit };
|
||||
try {
|
||||
let lastSyncedAt: string | null = null;
|
||||
|
||||
// Use YYYY-MM-DD format with >= and < operators for PocketBase date field
|
||||
// PocketBase accepts simple date strings in comparison operators
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const tomorrow = new Date(Date.now() + 86400000)
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
const tomorrow = new Date(Date.now() + 86400000).toISOString().split("T")[0];
|
||||
|
||||
logger.info({ userId: user.id, today, tomorrow }, "Fetching dailyLog");
|
||||
|
||||
try {
|
||||
// First try to get today's log
|
||||
const dailyLog = await pb
|
||||
.collection("dailyLogs")
|
||||
.getFirstListItem<DailyLog>(
|
||||
@@ -96,21 +99,58 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
hrvStatus: dailyLog.hrvStatus,
|
||||
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
|
||||
},
|
||||
"Found dailyLog",
|
||||
"Found dailyLog for today",
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn({ userId: user.id, err }, "No dailyLog found, using defaults");
|
||||
lastSyncedAt = today;
|
||||
} catch {
|
||||
// No today's log - try to get most recent
|
||||
logger.info(
|
||||
{ userId: user.id },
|
||||
"No dailyLog for today, trying most recent",
|
||||
);
|
||||
try {
|
||||
const dailyLog = await pb
|
||||
.collection("dailyLogs")
|
||||
.getFirstListItem<DailyLog>(`user="${user.id}"`, { sort: "-date" });
|
||||
|
||||
// Extract date from the log for "last synced" indicator
|
||||
const dateValue = dailyLog.date as unknown as string | Date;
|
||||
lastSyncedAt =
|
||||
typeof dateValue === "string"
|
||||
? dateValue.split("T")[0]
|
||||
: dateValue.toISOString().split("T")[0];
|
||||
|
||||
logger.info(
|
||||
{
|
||||
userId: user.id,
|
||||
dailyLogId: dailyLog.id,
|
||||
lastSyncedAt,
|
||||
},
|
||||
"Using most recent dailyLog as fallback",
|
||||
);
|
||||
|
||||
biometrics = {
|
||||
hrvStatus: dailyLog.hrvStatus,
|
||||
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
|
||||
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
|
||||
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
|
||||
phaseLimit: dailyLog.phaseLimit,
|
||||
};
|
||||
} catch {
|
||||
// No logs at all - truly new user
|
||||
logger.warn(
|
||||
{ userId: user.id },
|
||||
"No dailyLog found at all, using defaults",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build DailyData for decision engine
|
||||
@@ -151,5 +191,6 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
cycleLength,
|
||||
biometrics,
|
||||
nutrition,
|
||||
lastSyncedAt,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,11 @@ describe("GET /api/user", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: ["flare"],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -157,6 +162,11 @@ describe("PATCH /api/user", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: ["flare"],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -142,10 +142,10 @@ describe("DataPanel", () => {
|
||||
expect(screen.getByText(/Remaining: 0 min/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays negative remaining minutes", () => {
|
||||
it("displays goal exceeded message for negative remaining minutes", () => {
|
||||
render(<DataPanel {...baseProps} remainingMinutes={-50} />);
|
||||
|
||||
expect(screen.getByText(/Remaining: -50 min/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Goal exceeded by 50 min/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,4 +222,52 @@ describe("DataPanel", () => {
|
||||
expect(progressFill).toHaveClass("bg-green-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Last synced indicator", () => {
|
||||
it("does not show indicator when lastSyncedAt is today", () => {
|
||||
// Mock today's date
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
render(<DataPanel {...baseProps} lastSyncedAt={today} />);
|
||||
|
||||
expect(screen.queryByText(/Last synced:/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Waiting for first sync/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Last synced: yesterday' when data is from yesterday", () => {
|
||||
// Get yesterday's date
|
||||
const yesterday = new Date(Date.now() - 86400000)
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
render(<DataPanel {...baseProps} lastSyncedAt={yesterday} />);
|
||||
|
||||
expect(screen.getByText(/Last synced: yesterday/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Last synced: X days ago' when data is older", () => {
|
||||
// Get date from 3 days ago
|
||||
const threeDaysAgo = new Date(Date.now() - 3 * 86400000)
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
render(<DataPanel {...baseProps} lastSyncedAt={threeDaysAgo} />);
|
||||
|
||||
expect(screen.getByText(/Last synced: 3 days ago/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Waiting for first sync' when lastSyncedAt is null", () => {
|
||||
render(<DataPanel {...baseProps} lastSyncedAt={null} />);
|
||||
|
||||
expect(screen.getByText(/Waiting for first sync/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show indicator when lastSyncedAt is undefined (backwards compatible)", () => {
|
||||
render(<DataPanel {...baseProps} />);
|
||||
|
||||
expect(screen.queryByText(/Last synced:/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Waiting for first sync/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,26 @@ interface DataPanelProps {
|
||||
weekIntensity: number;
|
||||
phaseLimit: number;
|
||||
remainingMinutes: number;
|
||||
lastSyncedAt?: string | null;
|
||||
}
|
||||
|
||||
// Calculate relative time description from a date string (YYYY-MM-DD)
|
||||
function getRelativeTimeDescription(dateStr: string): string | null {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split("T")[0];
|
||||
|
||||
if (dateStr === todayStr) {
|
||||
return null; // Don't show indicator for today
|
||||
}
|
||||
|
||||
const syncDate = new Date(dateStr);
|
||||
const diffMs = today.getTime() - syncDate.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
return "yesterday";
|
||||
}
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
|
||||
function getHrvColorClass(status: string): string {
|
||||
@@ -37,11 +57,23 @@ export function DataPanel({
|
||||
weekIntensity,
|
||||
phaseLimit,
|
||||
remainingMinutes,
|
||||
lastSyncedAt,
|
||||
}: DataPanelProps) {
|
||||
const intensityPercentage =
|
||||
phaseLimit > 0 ? (weekIntensity / phaseLimit) * 100 : 0;
|
||||
const displayPercentage = Math.min(intensityPercentage, 100);
|
||||
|
||||
// Determine what to show for sync status
|
||||
let syncIndicator: string | null = null;
|
||||
if (lastSyncedAt === null) {
|
||||
syncIndicator = "Waiting for first sync";
|
||||
} else if (lastSyncedAt !== undefined) {
|
||||
const relativeTime = getRelativeTimeDescription(lastSyncedAt);
|
||||
if (relativeTime) {
|
||||
syncIndicator = `Last synced: ${relativeTime}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold mb-4">YOUR DATA</h3>
|
||||
@@ -76,7 +108,16 @@ export function DataPanel({
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li>Remaining: {remainingMinutes} min</li>
|
||||
<li>
|
||||
{remainingMinutes >= 0
|
||||
? `Remaining: ${remainingMinutes} min`
|
||||
: `Goal exceeded by ${Math.abs(remainingMinutes)} min`}
|
||||
</li>
|
||||
{syncIndicator && (
|
||||
<li className="text-amber-600 dark:text-amber-400 text-xs pt-1">
|
||||
{syncIndicator}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
62
src/instrumentation.ts
Normal file
62
src/instrumentation.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// ABOUTME: Next.js instrumentation file for server startup initialization.
|
||||
// ABOUTME: Schedules cron jobs for notifications and Garmin sync using node-cron.
|
||||
|
||||
export async function register() {
|
||||
// Only run on the server side
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const cron = await import("node-cron");
|
||||
|
||||
const APP_URL = process.env.APP_URL || "http://localhost:3000";
|
||||
const CRON_SECRET = process.env.CRON_SECRET;
|
||||
|
||||
// Log startup
|
||||
console.log("[cron] Scheduler starting...");
|
||||
|
||||
if (!CRON_SECRET) {
|
||||
console.warn(
|
||||
"[cron] CRON_SECRET not set - cron jobs will fail authentication",
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to call cron endpoints
|
||||
async function triggerCronEndpoint(endpoint: string, name: string) {
|
||||
try {
|
||||
const response = await fetch(`${APP_URL}/api/cron/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${CRON_SECRET}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(
|
||||
`[cron] ${name} failed: ${response.status} ${errorText}`,
|
||||
);
|
||||
} else {
|
||||
const result = await response.json();
|
||||
console.log(`[cron] ${name} completed:`, result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[cron] ${name} error:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule notifications every 15 minutes for finer-grained delivery times
|
||||
cron.default.schedule("*/15 * * * *", () => {
|
||||
console.log("[cron] Triggering notifications...");
|
||||
triggerCronEndpoint("notifications", "Notifications");
|
||||
});
|
||||
|
||||
// Schedule Garmin sync 8 times daily (every 3 hours) to keep data fresh
|
||||
cron.default.schedule("0 */3 * * *", () => {
|
||||
console.log("[cron] Triggering Garmin sync...");
|
||||
triggerCronEndpoint("garmin-sync", "Garmin sync");
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[cron] Scheduler started - notifications every 15 min, Garmin sync every 3 hours",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,11 @@ describe("withAuth", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date(),
|
||||
updated: new Date(),
|
||||
};
|
||||
|
||||
@@ -157,10 +157,11 @@ describe("getPhase", () => {
|
||||
|
||||
describe("getPhaseLimit", () => {
|
||||
it("returns correct weekly limits for each phase", () => {
|
||||
expect(getPhaseLimit("MENSTRUAL")).toBe(30);
|
||||
expect(getPhaseLimit("FOLLICULAR")).toBe(120);
|
||||
expect(getPhaseLimit("OVULATION")).toBe(80);
|
||||
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(100);
|
||||
// Default intensity goals (can be overridden per user)
|
||||
expect(getPhaseLimit("MENSTRUAL")).toBe(75);
|
||||
expect(getPhaseLimit("FOLLICULAR")).toBe(150);
|
||||
expect(getPhaseLimit("OVULATION")).toBe(100);
|
||||
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(120);
|
||||
expect(getPhaseLimit("LATE_LUTEAL")).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,33 +5,34 @@ import type { CyclePhase, PhaseConfig } from "@/types";
|
||||
// Base phase configurations with weekly limits and training guidance.
|
||||
// Note: The 'days' field is for the default 31-day cycle; actual boundaries
|
||||
// are calculated dynamically by getPhaseBoundaries() based on cycleLength.
|
||||
// Weekly limits are defaults that can be overridden per user.
|
||||
export const PHASE_CONFIGS: PhaseConfig[] = [
|
||||
{
|
||||
name: "MENSTRUAL",
|
||||
days: [1, 3],
|
||||
weeklyLimit: 30,
|
||||
dailyAvg: 10,
|
||||
weeklyLimit: 75,
|
||||
dailyAvg: 11,
|
||||
trainingType: "Gentle rebounding only",
|
||||
},
|
||||
{
|
||||
name: "FOLLICULAR",
|
||||
days: [4, 15],
|
||||
weeklyLimit: 120,
|
||||
dailyAvg: 17,
|
||||
weeklyLimit: 150,
|
||||
dailyAvg: 21,
|
||||
trainingType: "Strength + rebounding",
|
||||
},
|
||||
{
|
||||
name: "OVULATION",
|
||||
days: [16, 17],
|
||||
weeklyLimit: 80,
|
||||
dailyAvg: 40,
|
||||
weeklyLimit: 100,
|
||||
dailyAvg: 50,
|
||||
trainingType: "Peak performance",
|
||||
},
|
||||
{
|
||||
name: "EARLY_LUTEAL",
|
||||
days: [18, 24],
|
||||
weeklyLimit: 100,
|
||||
dailyAvg: 14,
|
||||
weeklyLimit: 120,
|
||||
dailyAvg: 17,
|
||||
trainingType: "Moderate training",
|
||||
},
|
||||
{
|
||||
@@ -96,3 +97,38 @@ export function getPhaseConfig(phase: CyclePhase): PhaseConfig {
|
||||
export function getPhaseLimit(phase: CyclePhase): number {
|
||||
return getPhaseConfig(phase).weeklyLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-specific intensity goals for phase limits.
|
||||
*/
|
||||
export interface UserIntensityGoals {
|
||||
intensityGoalMenstrual: number;
|
||||
intensityGoalFollicular: number;
|
||||
intensityGoalOvulation: number;
|
||||
intensityGoalEarlyLuteal: number;
|
||||
intensityGoalLateLuteal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the phase limit using user-specific goals if available.
|
||||
* Falls back to default phase limits if user goals are not set.
|
||||
*/
|
||||
export function getUserPhaseLimit(
|
||||
phase: CyclePhase,
|
||||
userGoals: UserIntensityGoals,
|
||||
): number {
|
||||
switch (phase) {
|
||||
case "MENSTRUAL":
|
||||
return userGoals.intensityGoalMenstrual;
|
||||
case "FOLLICULAR":
|
||||
return userGoals.intensityGoalFollicular;
|
||||
case "OVULATION":
|
||||
return userGoals.intensityGoalOvulation;
|
||||
case "EARLY_LUTEAL":
|
||||
return userGoals.intensityGoalEarlyLuteal;
|
||||
case "LATE_LUTEAL":
|
||||
return userGoals.intensityGoalLateLuteal;
|
||||
default:
|
||||
return getPhaseLimit(phase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: "🟡" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
// ABOUTME: Unit tests for email sending utilities.
|
||||
// ABOUTME: Tests email composition, subject lines, and Resend integration.
|
||||
// ABOUTME: Tests email composition, subject lines, and Mailgun integration.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockSend, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({
|
||||
mockSend: vi.fn().mockResolvedValue({ id: "mock-email-id" }),
|
||||
const { mockCreate, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({
|
||||
mockCreate: vi.fn().mockResolvedValue({ id: "mock-email-id" }),
|
||||
mockLoggerInfo: vi.fn(),
|
||||
mockLoggerError: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the resend module before importing email utilities
|
||||
vi.mock("resend", () => ({
|
||||
Resend: class MockResend {
|
||||
emails = { send: mockSend };
|
||||
// Mock the mailgun.js module before importing email utilities
|
||||
vi.mock("mailgun.js", () => ({
|
||||
default: class MockMailgun {
|
||||
client() {
|
||||
return {
|
||||
messages: { create: mockCreate },
|
||||
};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock form-data (required by mailgun.js)
|
||||
vi.mock("form-data", () => ({
|
||||
default: class MockFormData {},
|
||||
}));
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: {
|
||||
@@ -57,7 +66,9 @@ describe("sendDailyEmail", () => {
|
||||
|
||||
it("sends email with correct subject line per spec", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
// Mailgun create takes (domain, messageData) - check second param
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
subject: "PhaseFlow: 💪 TRAIN - Day 15 (OVULATION)",
|
||||
}),
|
||||
@@ -66,13 +77,13 @@ describe("sendDailyEmail", () => {
|
||||
|
||||
it("includes cycle day and phase in email body", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("📅 CYCLE DAY: 15 (OVULATION)");
|
||||
});
|
||||
|
||||
it("includes decision icon and reason", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain(
|
||||
"💪 Body battery high, HRV balanced - great day for training!",
|
||||
);
|
||||
@@ -80,7 +91,7 @@ describe("sendDailyEmail", () => {
|
||||
|
||||
it("includes biometric data in email body", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Body Battery Now: 85");
|
||||
expect(call.text).toContain("Yesterday's Low: 45");
|
||||
expect(call.text).toContain("HRV Status: Balanced");
|
||||
@@ -90,7 +101,7 @@ describe("sendDailyEmail", () => {
|
||||
|
||||
it("includes nutrition guidance in email body", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain(
|
||||
"🌱 SEEDS: Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
|
||||
);
|
||||
@@ -107,30 +118,32 @@ describe("sendDailyEmail", () => {
|
||||
bodyBatteryYesterdayLow: null,
|
||||
};
|
||||
await sendDailyEmail(dataWithNulls);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Body Battery Now: N/A");
|
||||
expect(call.text).toContain("Yesterday's Low: N/A");
|
||||
});
|
||||
|
||||
it("sends email to correct recipient", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
// Mailgun uses an array for recipients
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
to: "user@example.com",
|
||||
to: ["user@example.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes auto-generated footer", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||
});
|
||||
|
||||
it("includes seed switch alert on day 15", async () => {
|
||||
// sampleData already has cycleDay: 15
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("🌱 SWITCH TODAY! Start Sesame + Sunflower");
|
||||
});
|
||||
|
||||
@@ -140,7 +153,7 @@ describe("sendDailyEmail", () => {
|
||||
cycleDay: 10,
|
||||
};
|
||||
await sendDailyEmail(day10Data);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).not.toContain("SWITCH TODAY");
|
||||
});
|
||||
});
|
||||
@@ -156,7 +169,8 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
subject: "🔵 Period Tracking Updated",
|
||||
}),
|
||||
@@ -169,7 +183,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
// Date formatting depends on locale, so check for key parts
|
||||
expect(call.text).toContain("Your cycle has been reset");
|
||||
expect(call.text).toContain("Last period:");
|
||||
@@ -181,7 +195,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
28,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Phase calendar updated for next 28 days");
|
||||
});
|
||||
|
||||
@@ -191,9 +205,10 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
to: "test@example.com",
|
||||
to: ["test@example.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -204,7 +219,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||
});
|
||||
|
||||
@@ -214,7 +229,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain(
|
||||
"Your calendar will update automatically within 24 hours",
|
||||
);
|
||||
@@ -229,7 +244,8 @@ describe("sendTokenExpirationWarning", () => {
|
||||
describe("14-day warning", () => {
|
||||
it("sends email with correct subject for 14-day warning", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 14);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
subject: "⚠️ PhaseFlow: Garmin tokens expire in 14 days",
|
||||
}),
|
||||
@@ -238,29 +254,30 @@ describe("sendTokenExpirationWarning", () => {
|
||||
|
||||
it("sends to correct recipient", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 14);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
to: "user@example.com",
|
||||
to: ["user@example.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes days until expiry in body", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 14);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("14 days");
|
||||
});
|
||||
|
||||
it("includes instructions to refresh tokens", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 14);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Settings");
|
||||
expect(call.text).toContain("Garmin");
|
||||
});
|
||||
|
||||
it("includes auto-generated footer", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 14);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||
});
|
||||
});
|
||||
@@ -268,7 +285,8 @@ describe("sendTokenExpirationWarning", () => {
|
||||
describe("7-day warning", () => {
|
||||
it("sends email with urgent subject for 7-day warning", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 7);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
subject:
|
||||
"🚨 PhaseFlow: Garmin tokens expire in 7 days - action required",
|
||||
@@ -278,28 +296,29 @@ describe("sendTokenExpirationWarning", () => {
|
||||
|
||||
it("sends to correct recipient", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 7);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
to: "user@example.com",
|
||||
to: ["user@example.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes days until expiry in body", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 7);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("7 days");
|
||||
});
|
||||
|
||||
it("uses more urgent tone than 14-day warning", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 7);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("urgent");
|
||||
});
|
||||
|
||||
it("includes auto-generated footer", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 7);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||
});
|
||||
});
|
||||
@@ -344,12 +363,12 @@ describe("email structured logging", () => {
|
||||
});
|
||||
|
||||
it("logs email failed with error level on failure", async () => {
|
||||
const error = new Error("Resend API failed");
|
||||
mockSend.mockRejectedValueOnce(error);
|
||||
const error = new Error("Mailgun API failed");
|
||||
mockCreate.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
sendDailyEmail(sampleDailyEmailData, "user-123"),
|
||||
).rejects.toThrow("Resend API failed");
|
||||
).rejects.toThrow("Mailgun API failed");
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -381,8 +400,8 @@ describe("email structured logging", () => {
|
||||
});
|
||||
|
||||
it("logs email failed with error level on failure", async () => {
|
||||
const error = new Error("Resend API failed");
|
||||
mockSend.mockRejectedValueOnce(error);
|
||||
const error = new Error("Mailgun API failed");
|
||||
mockCreate.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
sendPeriodConfirmationEmail(
|
||||
@@ -391,7 +410,7 @@ describe("email structured logging", () => {
|
||||
31,
|
||||
"user-456",
|
||||
),
|
||||
).rejects.toThrow("Resend API failed");
|
||||
).rejects.toThrow("Mailgun API failed");
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -418,12 +437,12 @@ describe("email structured logging", () => {
|
||||
});
|
||||
|
||||
it("logs email failed with error level on failure", async () => {
|
||||
const error = new Error("Resend API failed");
|
||||
mockSend.mockRejectedValueOnce(error);
|
||||
const error = new Error("Mailgun API failed");
|
||||
mockCreate.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
sendTokenExpirationWarning("user@example.com", 14, "user-789"),
|
||||
).rejects.toThrow("Resend API failed");
|
||||
).rejects.toThrow("Mailgun API failed");
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
// ABOUTME: Email sending utilities using Resend.
|
||||
// ABOUTME: Email sending utilities using Mailgun.
|
||||
// ABOUTME: Sends daily training notifications and period confirmation emails.
|
||||
import { Resend } from "resend";
|
||||
import FormData from "form-data";
|
||||
import Mailgun from "mailgun.js";
|
||||
import type { IMailgunClient } from "mailgun.js/Interfaces";
|
||||
|
||||
import { logger } from "@/lib/logger";
|
||||
import { emailSentTotal } from "@/lib/metrics";
|
||||
import { getSeedSwitchAlert } from "@/lib/nutrition";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
// Lazy-initialize Mailgun client to avoid build-time errors when env vars aren't set
|
||||
let mg: IMailgunClient | null = null;
|
||||
|
||||
function getMailgunClient(): IMailgunClient {
|
||||
if (!mg) {
|
||||
const mailgun = new Mailgun(FormData);
|
||||
mg = mailgun.client({
|
||||
username: "api",
|
||||
key: process.env.MAILGUN_API_KEY || "",
|
||||
url: process.env.MAILGUN_URL || "https://api.mailgun.net",
|
||||
});
|
||||
}
|
||||
return mg;
|
||||
}
|
||||
|
||||
const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN || "paler.net";
|
||||
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
|
||||
|
||||
export interface DailyEmailData {
|
||||
@@ -64,9 +80,9 @@ ${data.decision.icon} ${data.decision.reason}
|
||||
Auto-generated by PhaseFlow`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||
from: EMAIL_FROM,
|
||||
to: data.to,
|
||||
to: [data.to],
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
@@ -96,9 +112,9 @@ Your calendar will update automatically within 24 hours.
|
||||
Auto-generated by PhaseFlow`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||
from: EMAIL_FROM,
|
||||
to,
|
||||
to: [to],
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
@@ -141,9 +157,9 @@ This will ensure your training recommendations continue to use fresh Garmin data
|
||||
Auto-generated by PhaseFlow`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||
from: EMAIL_FROM,
|
||||
to,
|
||||
to: [to],
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
|
||||
@@ -319,6 +319,55 @@ describe("fetchHrvStatus", () => {
|
||||
|
||||
expect(result).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("falls back to yesterday's HRV when today returns empty response", async () => {
|
||||
// First call (today) returns empty, second call (yesterday) returns BALANCED
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(""),
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
hrvSummary: { lastNightAvg: 45, weeklyAvg: 42, status: "BALANCED" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchHrvStatus("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe("Balanced");
|
||||
// Verify both today and yesterday were called
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://connectapi.garmin.com/hrv-service/hrv/2024-01-15",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://connectapi.garmin.com/hrv-service/hrv/2024-01-14",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns Unknown when both today and yesterday HRV are unavailable", async () => {
|
||||
// Both calls return empty responses
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(""),
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const result = await fetchHrvStatus("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe("Unknown");
|
||||
// Verify both today and yesterday were tried
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchBodyBattery", () => {
|
||||
@@ -455,7 +504,9 @@ describe("fetchIntensityMinutes", () => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns 7-day intensity minutes total on success", async () => {
|
||||
it("counts vigorous minutes as 2x (Garmin algorithm)", async () => {
|
||||
// Garmin counts vigorous minutes at 2x multiplier for weekly goals
|
||||
// 45 moderate + (30 vigorous × 2) = 45 + 60 = 105
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
@@ -469,9 +520,54 @@ describe("fetchIntensityMinutes", () => {
|
||||
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe(75);
|
||||
expect(result).toBe(105); // 45 + (30 × 2) = 105
|
||||
});
|
||||
|
||||
it("uses calendar week starting from Monday", async () => {
|
||||
// 2024-01-17 is a Wednesday, so calendar week starts Monday 2024-01-15
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
calendarDate: "2024-01-17",
|
||||
weeklyGoal: 150,
|
||||
moderateValue: 60,
|
||||
vigorousValue: 20,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await fetchIntensityMinutes("2024-01-17", "test-token");
|
||||
|
||||
// Should call with Monday of the current week as start date
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://connectapi.garmin.com/usersummary-service/stats/im/weekly/2024-01-08/2024-01-15",
|
||||
"https://connectapi.garmin.com/usersummary-service/stats/im/weekly/2024-01-15/2024-01-17",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns intensity minutes total on success", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
calendarDate: "2024-01-15",
|
||||
weeklyGoal: 150,
|
||||
moderateValue: 45,
|
||||
vigorousValue: 30,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
// 45 moderate + (30 vigorous × 2) = 105
|
||||
expect(result).toBe(105);
|
||||
// 2024-01-15 is Monday, so start date is same day (Monday of that week)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://connectapi.garmin.com/usersummary-service/stats/im/weekly/2024-01-15/2024-01-15",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-token",
|
||||
@@ -509,10 +605,11 @@ describe("fetchIntensityMinutes", () => {
|
||||
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
// 60 moderate + (0 × 2) = 60
|
||||
expect(result).toBe(60);
|
||||
});
|
||||
|
||||
it("handles only vigorous intensity minutes", async () => {
|
||||
it("handles only vigorous intensity minutes with 2x multiplier", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
@@ -525,7 +622,8 @@ describe("fetchIntensityMinutes", () => {
|
||||
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe(45);
|
||||
// 0 moderate + (45 × 2) = 90
|
||||
expect(result).toBe(90);
|
||||
});
|
||||
|
||||
it("returns 0 when API request fails", async () => {
|
||||
|
||||
@@ -54,46 +54,77 @@ export function daysUntilExpiry(tokens: GarminTokens): number {
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
export async function fetchHrvStatus(
|
||||
// Helper to fetch HRV for a specific date
|
||||
async function fetchHrvForDate(
|
||||
date: string,
|
||||
oauth2Token: string,
|
||||
): Promise<HrvStatus> {
|
||||
try {
|
||||
): Promise<HrvStatus | null> {
|
||||
const response = await fetch(`${GARMIN_API_URL}/hrv-service/hrv/${date}`, {
|
||||
headers: getGarminHeaders(oauth2Token),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
{ status: response.status, endpoint: "hrv-service" },
|
||||
{ status: response.status, endpoint: "hrv-service", date },
|
||||
"Garmin HRV API error",
|
||||
);
|
||||
return "Unknown";
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.startsWith("{") && !text.startsWith("[")) {
|
||||
logger.error(
|
||||
{ endpoint: "hrv-service", responseBody: text.slice(0, 1000) },
|
||||
"Garmin returned non-JSON response",
|
||||
logger.warn(
|
||||
{ endpoint: "hrv-service", date, isEmpty: text === "" },
|
||||
"Garmin HRV returned non-JSON response",
|
||||
);
|
||||
return "Unknown";
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(text);
|
||||
const status = data?.hrvSummary?.status;
|
||||
|
||||
if (status === "BALANCED") {
|
||||
logger.info({ status: "BALANCED" }, "Garmin HRV data received");
|
||||
logger.info({ status: "BALANCED", date }, "Garmin HRV data received");
|
||||
return "Balanced";
|
||||
}
|
||||
if (status === "UNBALANCED") {
|
||||
logger.info({ status: "UNBALANCED" }, "Garmin HRV data received");
|
||||
logger.info({ status: "UNBALANCED", date }, "Garmin HRV data received");
|
||||
return "Unbalanced";
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ rawStatus: status, hasData: !!data?.hrvSummary },
|
||||
{ rawStatus: status, hasData: !!data?.hrvSummary, date },
|
||||
"Garmin HRV returned unknown status",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchHrvStatus(
|
||||
date: string,
|
||||
oauth2Token: string,
|
||||
): Promise<HrvStatus> {
|
||||
try {
|
||||
// Try fetching today's HRV
|
||||
const todayResult = await fetchHrvForDate(date, oauth2Token);
|
||||
if (todayResult) {
|
||||
return todayResult;
|
||||
}
|
||||
|
||||
// Fallback: try yesterday's HRV (common at 6 AM before sleep data processed)
|
||||
const dateObj = new Date(date);
|
||||
dateObj.setDate(dateObj.getDate() - 1);
|
||||
const yesterday = dateObj.toISOString().split("T")[0];
|
||||
|
||||
logger.info(
|
||||
{ today: date, yesterday },
|
||||
"HRV unavailable today, trying yesterday",
|
||||
);
|
||||
const yesterdayResult = await fetchHrvForDate(yesterday, oauth2Token);
|
||||
if (yesterdayResult) {
|
||||
logger.info({ date: yesterday }, "Using yesterday's HRV data");
|
||||
return yesterdayResult;
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -185,11 +216,15 @@ export async function fetchIntensityMinutes(
|
||||
oauth2Token: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
// Calculate 7 days before the date for weekly range
|
||||
// Calculate Monday of the current calendar week for Garmin's weekly tracking
|
||||
const endDate = date;
|
||||
const startDateObj = new Date(date);
|
||||
startDateObj.setDate(startDateObj.getDate() - 7);
|
||||
const startDate = startDateObj.toISOString().split("T")[0];
|
||||
const dateObj = new Date(date);
|
||||
const dayOfWeek = dateObj.getDay(); // 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
// Calculate days to subtract to get to Monday (if Sunday, go back 6 days)
|
||||
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const mondayObj = new Date(dateObj);
|
||||
mondayObj.setDate(dateObj.getDate() - daysToMonday);
|
||||
const startDate = mondayObj.toISOString().split("T")[0];
|
||||
|
||||
const response = await fetch(
|
||||
`${GARMIN_API_URL}/usersummary-service/stats/im/weekly/${startDate}/${endDate}`,
|
||||
@@ -231,10 +266,11 @@ export async function fetchIntensityMinutes(
|
||||
|
||||
const moderate = entry.moderateValue ?? 0;
|
||||
const vigorous = entry.vigorousValue ?? 0;
|
||||
const total = moderate + vigorous;
|
||||
// Garmin counts vigorous minutes at 2x multiplier for weekly intensity goal
|
||||
const total = moderate + vigorous * 2;
|
||||
|
||||
logger.info(
|
||||
{ moderate, vigorous, total },
|
||||
{ moderate, vigorous, total, vigorousMultiplied: vigorous * 2 },
|
||||
"Garmin intensity minutes data received",
|
||||
);
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createPocketBaseClient,
|
||||
DEFAULT_INTENSITY_GOALS,
|
||||
getCurrentUser,
|
||||
isAuthenticated,
|
||||
loadAuthFromCookies,
|
||||
mapRecordToUser,
|
||||
} from "./pocketbase";
|
||||
|
||||
describe("isAuthenticated", () => {
|
||||
@@ -221,3 +223,76 @@ describe("createPocketBaseClient", () => {
|
||||
expect(client.authStore).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRecordToUser intensity goal defaults", () => {
|
||||
const createMockRecord = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
garminConnected: false,
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: "",
|
||||
garminRefreshTokenExpiresAt: "",
|
||||
calendarToken: "token",
|
||||
lastPeriodDate: "2025-01-01",
|
||||
cycleLength: 28,
|
||||
notificationTime: "08:00",
|
||||
timezone: "UTC",
|
||||
activeOverrides: [],
|
||||
created: "2024-01-01T00:00:00Z",
|
||||
updated: "2024-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("uses default when intensityGoalMenstrual is 0", () => {
|
||||
const record = createMockRecord({ intensityGoalMenstrual: 0 });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalMenstrual).toBe(DEFAULT_INTENSITY_GOALS.menstrual);
|
||||
});
|
||||
|
||||
it("uses default when intensityGoalFollicular is 0", () => {
|
||||
const record = createMockRecord({ intensityGoalFollicular: 0 });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalFollicular).toBe(
|
||||
DEFAULT_INTENSITY_GOALS.follicular,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses default when intensityGoalOvulation is 0", () => {
|
||||
const record = createMockRecord({ intensityGoalOvulation: 0 });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalOvulation).toBe(DEFAULT_INTENSITY_GOALS.ovulation);
|
||||
});
|
||||
|
||||
it("uses default when intensityGoalEarlyLuteal is 0", () => {
|
||||
const record = createMockRecord({ intensityGoalEarlyLuteal: 0 });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalEarlyLuteal).toBe(
|
||||
DEFAULT_INTENSITY_GOALS.earlyLuteal,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses default when intensityGoalLateLuteal is 0", () => {
|
||||
const record = createMockRecord({ intensityGoalLateLuteal: 0 });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalLateLuteal).toBe(
|
||||
DEFAULT_INTENSITY_GOALS.lateLuteal,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves non-zero intensity goal values", () => {
|
||||
const record = createMockRecord({
|
||||
intensityGoalMenstrual: 100,
|
||||
intensityGoalFollicular: 200,
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalMenstrual).toBe(100);
|
||||
expect(user.intensityGoalFollicular).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,15 @@ function parseDate(value: unknown): Date | null {
|
||||
/**
|
||||
* Maps a PocketBase record to our typed User interface.
|
||||
*/
|
||||
// Default intensity goals for each phase (weekly minutes)
|
||||
export const DEFAULT_INTENSITY_GOALS = {
|
||||
menstrual: 75,
|
||||
follicular: 150,
|
||||
ovulation: 100,
|
||||
earlyLuteal: 120,
|
||||
lateLuteal: 50,
|
||||
};
|
||||
|
||||
export function mapRecordToUser(record: RecordModel): User {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -100,6 +109,23 @@ export function mapRecordToUser(record: RecordModel): User {
|
||||
calendarToken: record.calendarToken as string,
|
||||
lastPeriodDate: parseDate(record.lastPeriodDate),
|
||||
cycleLength: record.cycleLength as number,
|
||||
// Intensity goals with defaults for existing users
|
||||
// Using || instead of ?? because PocketBase defaults number fields to 0
|
||||
intensityGoalMenstrual:
|
||||
(record.intensityGoalMenstrual as number) ||
|
||||
DEFAULT_INTENSITY_GOALS.menstrual,
|
||||
intensityGoalFollicular:
|
||||
(record.intensityGoalFollicular as number) ||
|
||||
DEFAULT_INTENSITY_GOALS.follicular,
|
||||
intensityGoalOvulation:
|
||||
(record.intensityGoalOvulation as number) ||
|
||||
DEFAULT_INTENSITY_GOALS.ovulation,
|
||||
intensityGoalEarlyLuteal:
|
||||
(record.intensityGoalEarlyLuteal as number) ||
|
||||
DEFAULT_INTENSITY_GOALS.earlyLuteal,
|
||||
intensityGoalLateLuteal:
|
||||
(record.intensityGoalLateLuteal as number) ||
|
||||
DEFAULT_INTENSITY_GOALS.lateLuteal,
|
||||
notificationTime: record.notificationTime as string,
|
||||
timezone: record.timezone as string,
|
||||
activeOverrides: (record.activeOverrides as OverrideType[]) || [],
|
||||
|
||||
@@ -32,6 +32,13 @@ export interface User {
|
||||
lastPeriodDate: Date | null;
|
||||
cycleLength: number; // default: 31
|
||||
|
||||
// Phase-specific intensity goals (weekly minutes)
|
||||
intensityGoalMenstrual: number; // default: 75
|
||||
intensityGoalFollicular: number; // default: 150
|
||||
intensityGoalOvulation: number; // default: 100
|
||||
intensityGoalEarlyLuteal: number; // default: 120
|
||||
intensityGoalLateLuteal: number; // default: 50
|
||||
|
||||
// Preferences
|
||||
notificationTime: string; // "07:00"
|
||||
timezone: string;
|
||||
@@ -77,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 {
|
||||
|
||||
Reference in New Issue
Block a user