diff --git a/scripts/check-code-health.mjs b/scripts/check-code-health.mjs
index f742088..1a623fd 100644
--- a/scripts/check-code-health.mjs
+++ b/scripts/check-code-health.mjs
@@ -18,7 +18,7 @@ const productionPaths = [
];
const sourceExtensions = new Set(['.js', '.jsx', '.mjs', '.mts', '.swift', '.ts', '.tsx']);
const baselines = {
- complexity: { violations: 32, maxCcn: 98, maxLength: 616, maxParams: 19 },
+ complexity: { violations: 30, maxCcn: 98, maxLength: 616, maxParams: 19 },
duplication: { clones: 18, duplicatedLines: 234 },
unused: {
files: 0,
diff --git a/src/lib/cycle-analytics.ts b/src/lib/cycle-analytics.ts
index 5aa68cb..9d5085a 100644
--- a/src/lib/cycle-analytics.ts
+++ b/src/lib/cycle-analytics.ts
@@ -61,6 +61,67 @@ export type CycleAnalysis = {
statusReason: string;
};
+function resolveCycleStatus(
+ enoughIntake: boolean,
+ enoughWeight: boolean,
+ hasCalorieRange: boolean,
+ calorieAligned: boolean,
+ directionAligned: boolean
+): { status: CycleAnalysis['status']; statusReason: string } {
+ if (!enoughIntake || !enoughWeight || !hasCalorieRange) {
+ if (!hasCalorieRange) {
+ return {
+ status: 'insufficient_data',
+ statusReason: 'Set a calorie range to compare this cycle with its plan.',
+ };
+ }
+ return {
+ status: 'insufficient_data',
+ statusReason: 'Log at least four food days and two weights spanning seven days.',
+ };
+ }
+ if (calorieAligned && directionAligned) {
+ return {
+ status: 'on_track',
+ statusReason:
+ 'Logged intake is inside your saved range and measured weight direction matches this cycle.',
+ };
+ }
+ if (!calorieAligned && !directionAligned) {
+ return {
+ status: 'review_target',
+ statusReason:
+ 'Logged intake is outside your saved range and measured weight direction differs from this cycle.',
+ };
+ }
+ return {
+ status: 'insufficient_data',
+ statusReason: 'Intake and weight signals are mixed, so more context is needed.',
+ };
+}
+
+function isCalorieAligned(
+ enoughIntake: boolean,
+ averageCalories: number | null,
+ range: [number, number] | null
+): boolean {
+ if (!enoughIntake || !range || averageCalories === null) return false;
+ return averageCalories >= range[0] && averageCalories <= range[1];
+}
+
+function calorieDeltaFromPlan(
+ averageCalories: number | null,
+ midpoint: number | null
+): number | null {
+ if (averageCalories === null || midpoint === null) return null;
+ return rounded(averageCalories - midpoint);
+}
+
+function proteinCoverage(averageProtein: number | null, floor: number | null): number | null {
+ if (averageProtein === null || floor === null) return null;
+ return rounded((averageProtein / floor) * 100);
+}
+
export function analyzeCyclePeriod(period: CyclePeriodData, today: string): CycleAnalysis {
const endBoundary = period.session.endOn ?? today;
const elapsedDays = Math.max(
@@ -82,34 +143,22 @@ export function analyzeCyclePeriod(period: CyclePeriodData, today: string): Cycl
const rate = weeklyWeightRate(orderedWeights);
const enoughIntake = loggedDays >= 4 && averageCaloriesValue !== null;
const enoughWeight = rate !== null;
- const calorieAligned = Boolean(
- enoughIntake &&
- period.session.calorieRange &&
- averageCaloriesValue !== null &&
- averageCaloriesValue >= period.session.calorieRange[0] &&
- averageCaloriesValue <= period.session.calorieRange[1]
+ const calorieAligned = isCalorieAligned(
+ enoughIntake,
+ averageCaloriesValue,
+ period.session.calorieRange
);
const directionAligned = enoughWeight
? weightDirectionAligned(period.session.cycle, rate)
: false;
- let status: CycleAnalysis['status'] = 'insufficient_data';
- let statusReason = 'Log at least four food days and two weights spanning seven days.';
- if (enoughIntake && enoughWeight && period.session.calorieRange) {
- if (calorieAligned && directionAligned) {
- status = 'on_track';
- statusReason =
- 'Logged intake is inside your saved range and measured weight direction matches this cycle.';
- } else if (!calorieAligned && !directionAligned) {
- status = 'review_target';
- statusReason =
- 'Logged intake is outside your saved range and measured weight direction differs from this cycle.';
- } else {
- statusReason = 'Intake and weight signals are mixed, so more context is needed.';
- }
- } else if (!period.session.calorieRange) {
- statusReason = 'Set a calorie range to compare this cycle with its plan.';
- }
+ const { status, statusReason } = resolveCycleStatus(
+ enoughIntake,
+ enoughWeight,
+ Boolean(period.session.calorieRange),
+ calorieAligned,
+ directionAligned
+ );
return {
cycle: period.session.cycle,
@@ -120,14 +169,8 @@ export function analyzeCyclePeriod(period: CyclePeriodData, today: string): Cycl
coveragePercent: rounded((loggedDays / elapsedDays) * 100),
averageCalories: averageCaloriesValue === null ? null : rounded(averageCaloriesValue),
averageProteinG: averageProteinValue === null ? null : rounded(averageProteinValue),
- calorieDeltaFromPlan:
- averageCaloriesValue === null || calorieMidpoint === null
- ? null
- : rounded(averageCaloriesValue - calorieMidpoint),
- proteinCoveragePercent:
- averageProteinValue === null || proteinFloor === null
- ? null
- : rounded((averageProteinValue / proteinFloor) * 100),
+ calorieDeltaFromPlan: calorieDeltaFromPlan(averageCaloriesValue, calorieMidpoint),
+ proteinCoveragePercent: proteinCoverage(averageProteinValue, proteinFloor),
weightChangeKg: weightChange === null ? null : rounded(weightChange, 1),
weeklyWeightRateKg: rate,
weightCount: orderedWeights.length,
diff --git a/src/lib/macro-completion.ts b/src/lib/macro-completion.ts
index 3a5e46d..6f54243 100644
--- a/src/lib/macro-completion.ts
+++ b/src/lib/macro-completion.ts
@@ -67,11 +67,13 @@ export function computeMacroCompletion(input: {
const suggestions: MacroCompletionSuggestion[] = [];
if (!complete && leading) {
const deficit = tracked.find((item) => item.macro === leading.macro)?.remaining ?? 0;
+ const leadingValue = (item: { proteinG: number; fibreG: number }) =>
+ leading.macro === 'protein' ? item.proteinG : item.fibreG;
suggestions.push(
...input.foods
.map((food) => {
const serving = scaleNutrients(food, food.servingMode, food.defaultAmount);
- const servingLeading = leading.macro === 'protein' ? serving.proteinG : serving.fibreG;
+ const servingLeading = leadingValue(serving);
return {
food,
calories: serving.calories,
@@ -80,15 +82,8 @@ export function computeMacroCompletion(input: {
covers: deficit > 0 ? round(servingLeading / deficit, 2) : 0,
};
})
- .filter((item) => {
- const servingLeading = leading.macro === 'protein' ? item.proteinG : item.fibreG;
- return servingLeading > 0;
- })
- .sort((a, b) => {
- const aLeading = leading.macro === 'protein' ? a.proteinG : a.fibreG;
- const bLeading = leading.macro === 'protein' ? b.proteinG : b.fibreG;
- return bLeading - aLeading;
- })
+ .filter((item) => leadingValue(item) > 0)
+ .sort((a, b) => leadingValue(b) - leadingValue(a))
.slice(0, MAX_SUGGESTIONS)
);
}
diff --git a/src/lib/recommendations.ts b/src/lib/recommendations.ts
index ff48d05..26aa8fa 100644
--- a/src/lib/recommendations.ts
+++ b/src/lib/recommendations.ts
@@ -62,15 +62,15 @@ export function round(value: number, precision = 0): number {
return Math.round(value * multiplier) / multiplier;
}
+function signedValue(value: number): string {
+ if (value > 0) return `+${value.toLocaleString()}`;
+ if (value < 0) return `−${Math.abs(value).toLocaleString()}`;
+ return '0';
+}
+
export function formatCalorieAdjustmentRange(range: [number, number] | null): string {
if (!range) return 'no goal adjustment';
- const signed = (value: number) =>
- value > 0
- ? `+${value.toLocaleString()}`
- : value < 0
- ? `−${Math.abs(value).toLocaleString()}`
- : '0';
- return `${signed(range[0])} to ${signed(range[1])}`;
+ return `${signedValue(range[0])} to ${signedValue(range[1])}`;
}
export function scaleNutrients(
@@ -188,11 +188,19 @@ export function calculateNutritionTarget(input: {
};
}
+function resolveWeightDirection(
+ distanceKg: number,
+ signedDifferenceKg: number
+): 'reached' | 'lose' | 'gain' {
+ if (distanceKg < 0.05) return 'reached';
+ if (signedDifferenceKg < 0) return 'lose';
+ return 'gain';
+}
+
export function calculateTargetWeightProgress(currentWeightKg: number, targetWeightKg: number) {
const signedDifferenceKg = round(targetWeightKg - currentWeightKg, 1);
const distanceKg = Math.abs(signedDifferenceKg);
- const direction =
- distanceKg < 0.05 ? 'reached' : signedDifferenceKg < 0 ? 'lose' : ('gain' as const);
+ const direction = resolveWeightDirection(distanceKg, signedDifferenceKg);
return {
direction,
@@ -260,10 +268,10 @@ export function calculateGymGuidance(entries: FoodEntry[], now = Date.now()): Gy
for (const entry of entries) {
if (entry.carbsG < 10 || entry.eatenAt > now) continue;
- const endMinutes = entry.carbsG <= 20 ? 90 : entry.carbsG <= 50 ? 150 : 240;
+ const endMinutes = gymWindowEndMinutes(entry.carbsG);
const endAt = entry.eatenAt + endMinutes * 60 * 1000;
if (endAt < now || (recentEntry && recentEntry.eatenAt >= entry.eatenAt)) continue;
- const startMinutes = entry.carbsG <= 20 ? 30 : entry.carbsG <= 50 ? 60 : 90;
+ const startMinutes = gymWindowStartMinutes(entry.carbsG);
recentEntry = entry;
recentStartAt = entry.eatenAt + startMinutes * 60 * 1000;
recentEndAt = endAt;
@@ -324,7 +332,7 @@ export function calculateSleepGuidance(input: {
};
}
- const settleGap = input.lastEntryCalories < 150 ? 60 : input.lastEntryCalories < 400 ? 120 : 180;
+ const settleGap = settleGapForCalories(input.lastEntryCalories);
let settleMinutes = input.lastEntryLocalMinutes + settleGap;
let comparableRoutine = routineMinutes;
if (comparableRoutine < input.lastEntryLocalMinutes - 12 * 60) comparableRoutine += 1440;
@@ -341,3 +349,21 @@ export function calculateSleepGuidance(input: {
: 'Your normal sleep schedule already leaves enough time after eating.',
};
}
+
+function settleGapForCalories(calories: number): number {
+ if (calories < 150) return 60;
+ if (calories < 400) return 120;
+ return 180;
+}
+
+function gymWindowEndMinutes(carbsG: number): number {
+ if (carbsG <= 20) return 90;
+ if (carbsG <= 50) return 150;
+ return 240;
+}
+
+function gymWindowStartMinutes(carbsG: number): number {
+ if (carbsG <= 20) return 30;
+ if (carbsG <= 50) return 60;
+ return 90;
+}
diff --git a/src/sitemap-canonical-parity.test.ts b/src/sitemap-canonical-parity.test.ts
new file mode 100644
index 0000000..3a38a0e
--- /dev/null
+++ b/src/sitemap-canonical-parity.test.ts
@@ -0,0 +1,47 @@
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+
+import {
+ PUBLIC_ENTRYPOINTS,
+ renderPublicEntrypoint,
+} from '../scripts/generate-public-entrypoints.mjs';
+
+const ORIGIN = 'https://calorie.significanthobbies.com';
+const indexHtml = readFileSync('index.html', 'utf8');
+const sitemapXml = readFileSync('public/sitemap.xml', 'utf8');
+
+const CANONICAL_PATTERN = //;
+const LOC_PATTERN = /([^<]+)<\/loc>/g;
+
+function canonicalFromHtml(html: string): string {
+ const match = html.match(CANONICAL_PATTERN);
+ if (!match) throw new Error('Missing tag');
+ return match[1];
+}
+
+function sitemapUrls(xml: string): string[] {
+ return [...xml.matchAll(LOC_PATTERN)].map((match) => match[1]);
+}
+
+describe('sitemap/canonical parity', () => {
+ // Regression guard for issue #37: every URL advertised in sitemap.xml must
+ // ship an exact self-canonical, and every public entrypoint canonical must be
+ // listed in the sitemap. Drift in either direction fails the build.
+ it('matches every sitemap URL to an exact self-canonical entrypoint', () => {
+ const canonicalUrls = new Set([canonicalFromHtml(indexHtml)]);
+ for (const entry of PUBLIC_ENTRYPOINTS) {
+ canonicalUrls.add(canonicalFromHtml(renderPublicEntrypoint(indexHtml, entry)));
+ }
+
+ expect([...canonicalUrls].sort()).toEqual(sitemapUrls(sitemapXml).sort());
+ });
+
+ it.each(PUBLIC_ENTRYPOINTS)(
+ 'serves $path with a route-specific self-canonical, not the homepage',
+ (entry) => {
+ const canonical = canonicalFromHtml(renderPublicEntrypoint(indexHtml, entry));
+ expect(canonical).toBe(`${ORIGIN}${entry.path}`);
+ expect(canonical).not.toBe(`${ORIGIN}/`);
+ }
+ );
+});