Compare commits

...

12 Commits

Author SHA1 Message Date
ec3d341e51 Document production URL in CLAUDE.md
All checks were successful
Deploy / deploy (push) Successful in 1m43s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:59:42 +00:00
1a9a327a30 Increase Garmin sync frequency to every 3 hours
Some checks failed
Deploy / deploy (push) Has been cancelled
Change cron schedule from 4x daily (0,6,12,18 UTC) to 8x daily
(every 3 hours) to keep body battery and other Garmin data fresher.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:57:59 +00:00
d4b04a17be Fix email delivery and body battery null handling
All checks were successful
Deploy / deploy (push) Successful in 2m34s
- Add PocketBase admin auth to notifications endpoint (was returning 0 users)
- Store null instead of 100 for body battery when Garmin returns no data
- Update decision engine to skip body battery rules when values are null
- Dashboard and email already display "N/A" for null values

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 08:50:30 +00:00
092d8bb3dd Fix email timing and show fallback data when Garmin sync pending
All checks were successful
Deploy / deploy (push) Successful in 2m31s
- Add 15-minute notification granularity (*/15 cron) so users get emails
  at their configured time instead of rounding to the nearest hour
- Add DailyLog fallback to most recent when today's log doesn't exist,
  preventing 100/100/Unknown default values before morning sync
- Show "Last synced" indicator when displaying stale data
- Change Garmin sync to 6-hour intervals (0,6,12,18 UTC) to ensure
  data is available before European morning notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 09:56:41 +00:00
0d5785aaaa Add MAILGUN_URL for EU region support
All checks were successful
Deploy / deploy (push) Successful in 1m41s
Mailgun EU accounts require api.eu.mailgun.net endpoint.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:28:50 +00:00
9c4fcd8b52 Add test endpoint for verifying email configuration
All checks were successful
Deploy / deploy (push) Successful in 2m46s
POST /api/test/email with {"to": "email@example.com"} to send a test email.
Protected by CRON_SECRET.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:22:19 +00:00
140de56450 Reduce Garmin sync frequency to 3 times daily
All checks were successful
Deploy / deploy (push) Successful in 2m38s
Sync at 08:00, 14:00, and 22:00 UTC instead of every hour.
Garmin data updates once daily so hourly was excessive.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:17:46 +00:00
ccbc86016d Switch email provider from Resend to Mailgun and add cron scheduler
Some checks failed
Deploy / deploy (push) Failing after 1m43s
- Replace resend package with mailgun.js and form-data
- Update email.ts to use Mailgun API with lazy client initialization
- Add instrumentation.ts to schedule cron jobs (notifications :00, Garmin :30)
- Update tests for Mailgun mock structure
- Update .env.example with MAILGUN_API_KEY and MAILGUN_DOMAIN

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:37:24 +00:00
6543c79a04 Show "Goal exceeded" instead of negative remaining minutes
All checks were successful
Deploy / deploy (push) Successful in 1m38s
When weekly intensity exceeds the phase goal, display "Goal exceeded by X min"
instead of the confusing "Remaining: -X min".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 07:27:32 +00:00
ed14aea0ea Fix intensity goal defaults for PocketBase 0 values
All checks were successful
Deploy / deploy (push) Successful in 2m39s
PocketBase number fields default to 0, not null. Using ?? (nullish
coalescing) caused 0 to be preserved instead of using the default value.
Changed to || so 0 is treated as falsy and falls back to defaults.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 07:14:39 +00:00
8956e04eca Fix garmin-sync upsert and add Settings UI for intensity goals
All checks were successful
Deploy / deploy (push) Successful in 1m39s
- Fix dailyLog upsert to use range query (matches today route pattern)
- Properly distinguish 404 errors from other failures in upsert logic
- Add logging for dailyLog create/update operations
- Add Settings UI section for weekly intensity goals per phase
- Add unit tests for upsert behavior and intensity goals UI
- Add E2E tests for intensity goals settings flow

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:53:43 +00:00
6cd0c06396 Fix Garmin intensity minutes and add user-configurable phase goals
All checks were successful
Deploy / deploy (push) Successful in 2m38s
- Apply 2x multiplier for vigorous intensity minutes (matches Garmin)
- Use calendar week (Mon-Sun) instead of trailing 7 days for intensity
- Add HRV yesterday fallback when today's data returns empty
- Add user-configurable phase intensity goals with new defaults:
  - Menstrual: 75, Follicular: 150, Ovulation: 100
  - Early Luteal: 120, Late Luteal: 50
- Update garmin-sync and today routes to use user-specific phase limits

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:18:20 +00:00
41 changed files with 2108 additions and 236 deletions

View File

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

View File

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

View File

@@ -772,4 +772,198 @@ test.describe("settings", () => {
await page.waitForTimeout(500);
});
});
test.describe("intensity goals section", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/settings");
await page.waitForLoadState("networkidle");
});
test("displays Weekly Intensity Goals section", async ({ page }) => {
const sectionHeading = page.getByRole("heading", {
name: /weekly intensity goals/i,
});
await expect(sectionHeading).toBeVisible();
});
test("displays input for menstrual phase goal", async ({ page }) => {
const menstrualInput = page.getByLabel(/menstrual/i);
await expect(menstrualInput).toBeVisible();
});
test("displays input for follicular phase goal", async ({ page }) => {
const follicularInput = page.getByLabel(/follicular/i);
await expect(follicularInput).toBeVisible();
});
test("displays input for ovulation phase goal", async ({ page }) => {
const ovulationInput = page.getByLabel(/ovulation/i);
await expect(ovulationInput).toBeVisible();
});
test("displays input for early luteal phase goal", async ({ page }) => {
const earlyLutealInput = page.getByLabel(/early luteal/i);
await expect(earlyLutealInput).toBeVisible();
});
test("displays input for late luteal phase goal", async ({ page }) => {
const lateLutealInput = page.getByLabel(/late luteal/i);
await expect(lateLutealInput).toBeVisible();
});
test("can modify menstrual phase goal and save", async ({ page }) => {
const menstrualInput = page.getByLabel(/menstrual/i);
const isVisible = await menstrualInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Get original value
const originalValue = await menstrualInput.inputValue();
// Set a different value
const newValue = originalValue === "75" ? "80" : "75";
await menstrualInput.fill(newValue);
// Save and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Restore original value
await menstrualInput.fill(originalValue);
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
});
test("persists intensity goal value after page reload", async ({
page,
}) => {
const menstrualInput = page.getByLabel(/menstrual/i);
const isVisible = await menstrualInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Get original value
const originalValue = await menstrualInput.inputValue();
// Set a different value
const newValue = originalValue === "75" ? "85" : "75";
await menstrualInput.fill(newValue);
// Save and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Reload the page
await page.reload();
await page.waitForLoadState("networkidle");
// Check the value persisted
const menstrualAfter = page.getByLabel(/menstrual/i);
const afterValue = await menstrualAfter.inputValue();
expect(afterValue).toBe(newValue);
// Restore original value
await menstrualAfter.fill(originalValue);
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
});
test("intensity goal inputs have number type and min attribute", async ({
page,
}) => {
const menstrualInput = page.getByLabel(/menstrual/i);
const isVisible = await menstrualInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Check type attribute
const inputType = await menstrualInput.getAttribute("type");
expect(inputType).toBe("number");
// Check min attribute
const inputMin = await menstrualInput.getAttribute("min");
expect(inputMin).toBe("0");
});
test("all intensity goal inputs are disabled while saving", async ({
page,
}) => {
const menstrualInput = page.getByLabel(/menstrual/i);
const isVisible = await menstrualInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Start saving (slow down the response to catch disabled state)
await page.route("**/api/user", async (route) => {
if (route.request().method() === "PATCH") {
// Delay response to allow testing disabled state
await new Promise((resolve) => setTimeout(resolve, 500));
await route.continue();
} else {
await route.continue();
}
});
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
// Check inputs are disabled during save
await expect(menstrualInput).toBeDisabled();
// Wait for save to complete
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Clean up route interception
await page.unroute("**/api/user");
});
});
});

View File

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

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

View File

@@ -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" },
];
/**

View File

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

View File

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

View File

@@ -10,6 +10,10 @@ let mockUsers: User[] = [];
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
// Track user updates
const mockPbUpdate = vi.fn().mockResolvedValue({});
// Track DailyLog queries for upsert
const mockGetFirstListItem = vi.fn();
// Track the filter string passed to getFirstListItem
let lastDailyLogFilter: string | null = null;
// Helper to parse date values - handles both Date objects and ISO strings
function parseDate(value: unknown): Date | null {
@@ -30,6 +34,12 @@ vi.mock("@/lib/pocketbase", () => ({
}
return [];
}),
getFirstListItem: vi.fn(async (filter: string) => {
if (name === "dailyLogs") {
lastDailyLogFilter = filter;
}
return mockGetFirstListItem(filter);
}),
create: mockPbCreate,
update: mockPbUpdate,
authWithPassword: vi.fn().mockResolvedValue({ token: "admin-token" }),
@@ -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,
}),
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 },
);
}
}

View File

@@ -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;
}
if (!currentMockDailyLog) {
// 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 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;
}
return currentMockDailyLog;
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");
});
});
});

View File

@@ -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 {
// 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];
let lastSyncedAt: string | null = null;
logger.info({ userId: user.id, today, tomorrow }, "Fetching dailyLog");
// Use YYYY-MM-DD format with >= and < operators for PocketBase date field
const today = new Date().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,
});
});

View File

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

View File

@@ -37,6 +37,11 @@ describe("SettingsPage", () => {
garminConnected: false,
activeOverrides: [],
lastPeriodDate: "2024-01-01",
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
};
beforeEach(() => {
@@ -240,6 +245,11 @@ describe("SettingsPage", () => {
cycleLength: 30,
notificationTime: "08:00",
timezone: "America/New_York",
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
}),
});
});
@@ -639,4 +649,172 @@ describe("SettingsPage", () => {
});
});
});
describe("intensity goals section", () => {
it("renders Weekly Intensity Goals section heading", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("heading", { name: /weekly intensity goals/i }),
).toBeInTheDocument();
});
});
it("renders input for menstrual phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
});
});
it("renders input for follicular phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/follicular/i)).toBeInTheDocument();
});
});
it("renders input for ovulation phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/ovulation/i)).toBeInTheDocument();
});
});
it("renders input for early luteal phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/early luteal/i)).toBeInTheDocument();
});
});
it("renders input for late luteal phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/late luteal/i)).toBeInTheDocument();
});
});
it("pre-fills intensity goal inputs with current user values", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toHaveValue(75);
expect(screen.getByLabelText(/follicular/i)).toHaveValue(150);
expect(screen.getByLabelText(/ovulation/i)).toHaveValue(100);
expect(screen.getByLabelText(/early luteal/i)).toHaveValue(120);
expect(screen.getByLabelText(/late luteal/i)).toHaveValue(50);
});
});
it("includes intensity goals in PATCH request when saving", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({ ...mockUser, intensityGoalMenstrual: 80 }),
});
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
});
const menstrualInput = screen.getByLabelText(/menstrual/i);
fireEvent.change(menstrualInput, { target: { value: "80" } });
const saveButton = screen.getByRole("button", { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/user", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: expect.stringContaining('"intensityGoalMenstrual":80'),
});
});
});
it("has number type for all intensity goal inputs", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toHaveAttribute(
"type",
"number",
);
expect(screen.getByLabelText(/follicular/i)).toHaveAttribute(
"type",
"number",
);
expect(screen.getByLabelText(/ovulation/i)).toHaveAttribute(
"type",
"number",
);
expect(screen.getByLabelText(/early luteal/i)).toHaveAttribute(
"type",
"number",
);
expect(screen.getByLabelText(/late luteal/i)).toHaveAttribute(
"type",
"number",
);
});
});
it("validates minimum value of 0 for intensity goals", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toHaveAttribute("min", "0");
});
});
it("disables intensity goal inputs while saving", async () => {
let resolveSave: (value: unknown) => void = () => {};
const savePromise = new Promise((resolve) => {
resolveSave = resolve;
});
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockReturnValueOnce({
ok: true,
json: () => savePromise,
});
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toBeDisabled();
expect(screen.getByLabelText(/follicular/i)).toBeDisabled();
expect(screen.getByLabelText(/ovulation/i)).toBeDisabled();
expect(screen.getByLabelText(/early luteal/i)).toBeDisabled();
expect(screen.getByLabelText(/late luteal/i)).toBeDisabled();
});
resolveSave(mockUser);
});
});
});

View File

@@ -16,6 +16,11 @@ interface UserData {
garminConnected: boolean;
activeOverrides: string[];
lastPeriodDate: string | null;
intensityGoalMenstrual: number;
intensityGoalFollicular: number;
intensityGoalOvulation: number;
intensityGoalEarlyLuteal: number;
intensityGoalLateLuteal: number;
}
export default function SettingsPage() {
@@ -29,6 +34,11 @@ export default function SettingsPage() {
const [cycleLength, setCycleLength] = useState(28);
const [notificationTime, setNotificationTime] = useState("08:00");
const [timezone, setTimezone] = useState("");
const [intensityGoalMenstrual, setIntensityGoalMenstrual] = useState(75);
const [intensityGoalFollicular, setIntensityGoalFollicular] = useState(150);
const [intensityGoalOvulation, setIntensityGoalOvulation] = useState(100);
const [intensityGoalEarlyLuteal, setIntensityGoalEarlyLuteal] = useState(120);
const [intensityGoalLateLuteal, setIntensityGoalLateLuteal] = useState(50);
const fetchUserData = useCallback(async () => {
setLoading(true);
@@ -46,6 +56,11 @@ export default function SettingsPage() {
setCycleLength(data.cycleLength);
setNotificationTime(data.notificationTime);
setTimezone(data.timezone);
setIntensityGoalMenstrual(data.intensityGoalMenstrual ?? 75);
setIntensityGoalFollicular(data.intensityGoalFollicular ?? 150);
setIntensityGoalOvulation(data.intensityGoalOvulation ?? 100);
setIntensityGoalEarlyLuteal(data.intensityGoalEarlyLuteal ?? 120);
setIntensityGoalLateLuteal(data.intensityGoalLateLuteal ?? 50);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setLoadError(message);
@@ -79,6 +94,11 @@ export default function SettingsPage() {
cycleLength,
notificationTime,
timezone,
intensityGoalMenstrual,
intensityGoalFollicular,
intensityGoalOvulation,
intensityGoalEarlyLuteal,
intensityGoalLateLuteal,
}),
});
@@ -250,6 +270,132 @@ export default function SettingsPage() {
</p>
</div>
<div className="pt-6">
<h2 className="text-lg font-medium text-foreground mb-4">
Weekly Intensity Goals
</h2>
<p className="text-sm text-muted-foreground mb-4">
Target weekly intensity minutes for each cycle phase
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="intensityGoalMenstrual"
className="block text-sm font-medium text-foreground"
>
Menstrual
</label>
<input
id="intensityGoalMenstrual"
type="number"
min="0"
value={intensityGoalMenstrual}
onChange={(e) =>
handleInputChange(
setIntensityGoalMenstrual,
Number(e.target.value),
)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
/>
</div>
<div>
<label
htmlFor="intensityGoalFollicular"
className="block text-sm font-medium text-foreground"
>
Follicular
</label>
<input
id="intensityGoalFollicular"
type="number"
min="0"
value={intensityGoalFollicular}
onChange={(e) =>
handleInputChange(
setIntensityGoalFollicular,
Number(e.target.value),
)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
/>
</div>
<div>
<label
htmlFor="intensityGoalOvulation"
className="block text-sm font-medium text-foreground"
>
Ovulation
</label>
<input
id="intensityGoalOvulation"
type="number"
min="0"
value={intensityGoalOvulation}
onChange={(e) =>
handleInputChange(
setIntensityGoalOvulation,
Number(e.target.value),
)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
/>
</div>
<div>
<label
htmlFor="intensityGoalEarlyLuteal"
className="block text-sm font-medium text-foreground"
>
Early Luteal
</label>
<input
id="intensityGoalEarlyLuteal"
type="number"
min="0"
value={intensityGoalEarlyLuteal}
onChange={(e) =>
handleInputChange(
setIntensityGoalEarlyLuteal,
Number(e.target.value),
)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
/>
</div>
<div className="col-span-2 sm:col-span-1">
<label
htmlFor="intensityGoalLateLuteal"
className="block text-sm font-medium text-foreground"
>
Late Luteal
</label>
<input
id="intensityGoalLateLuteal"
type="number"
min="0"
value={intensityGoalLateLuteal}
onChange={(e) =>
handleInputChange(
setIntensityGoalLateLuteal,
Number(e.target.value),
)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
/>
</div>
</div>
</div>
<div className="pt-4">
<button
type="submit"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

@@ -54,46 +54,77 @@ export function daysUntilExpiry(tokens: GarminTokens): number {
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}
// Helper to fetch HRV for a specific date
async function fetchHrvForDate(
date: string,
oauth2Token: string,
): 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", date },
"Garmin HRV API error",
);
return null;
}
const text = await response.text();
if (!text.startsWith("{") && !text.startsWith("[")) {
logger.warn(
{ endpoint: "hrv-service", date, isEmpty: text === "" },
"Garmin HRV returned non-JSON response",
);
return null;
}
const data = JSON.parse(text);
const status = data?.hrvSummary?.status;
if (status === "BALANCED") {
logger.info({ status: "BALANCED", date }, "Garmin HRV data received");
return "Balanced";
}
if (status === "UNBALANCED") {
logger.info({ status: "UNBALANCED", date }, "Garmin HRV data received");
return "Unbalanced";
}
logger.info(
{ rawStatus: status, hasData: !!data?.hrvSummary, date },
"Garmin HRV returned unknown status",
);
return null;
}
export async function fetchHrvStatus(
date: string,
oauth2Token: string,
): Promise<HrvStatus> {
try {
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" },
"Garmin HRV API error",
);
return "Unknown";
// Try fetching today's HRV
const todayResult = await fetchHrvForDate(date, oauth2Token);
if (todayResult) {
return todayResult;
}
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",
);
return "Unknown";
}
const data = JSON.parse(text);
const status = data?.hrvSummary?.status;
// 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];
if (status === "BALANCED") {
logger.info({ status: "BALANCED" }, "Garmin HRV data received");
return "Balanced";
}
if (status === "UNBALANCED") {
logger.info({ status: "UNBALANCED" }, "Garmin HRV data received");
return "Unbalanced";
}
logger.info(
{ rawStatus: status, hasData: !!data?.hrvSummary },
"Garmin HRV returned unknown status",
{ 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",
);

View File

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

View File

@@ -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[]) || [],

View File

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