feat(api): NomGap fasting modules — sessions CRUD + stats, 14 built-in protocols + custom CRUD, body stages + autophagy confidence (47 new tests, 717 total)

This commit is contained in:
saravanakumardb1 2026-02-27 22:32:28 -08:00
parent d333b23326
commit 1744bcf63f
13 changed files with 2181 additions and 0 deletions

View File

@ -34,6 +34,9 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
daily_briefs: { partitionKeyPath: '/userId' },
reflections: { partitionKeyPath: '/userId' },
brain_insights: { partitionKeyPath: '/userId' },
// NomGap fasting modules
fasting_sessions: { partitionKeyPath: '/userId' },
fasting_protocols: { partitionKeyPath: '/userId' },
// Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md)
telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 },

View File

@ -0,0 +1,264 @@
/**
* Body stages module unit tests validates stage definitions and autophagy confidence calculation.
*/
import { describe, it, expect } from 'vitest';
import { AutophagyConfidenceRequestSchema, BODY_STAGES, getStageForDuration } from './types.js';
import { calculateAutophagyConfidence } from './routes.js';
// ── Stage definitions ──
describe('BODY_STAGES', () => {
it('has exactly 6 stages', () => {
expect(BODY_STAGES).toHaveLength(6);
});
it('stages are in chronological order', () => {
for (let i = 1; i < BODY_STAGES.length; i++) {
expect(BODY_STAGES[i].timeRangeHours.min).toBeGreaterThanOrEqual(
BODY_STAGES[i - 1].timeRangeHours.min
);
}
});
it('all stages have unique IDs', () => {
const ids = BODY_STAGES.map(s => s.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('all stages have visualization data', () => {
for (const stage of BODY_STAGES) {
expect(stage.visualizationData).toBeDefined();
expect(stage.visualizationData.primaryColor).toMatch(/^#[0-9A-Fa-f]{6}$/);
expect(stage.visualizationData.secondaryColor).toMatch(/^#[0-9A-Fa-f]{6}$/);
expect(stage.visualizationData.glowIntensity).toBeGreaterThanOrEqual(0);
expect(stage.visualizationData.glowIntensity).toBeLessThanOrEqual(1);
expect(stage.visualizationData.animationStyle).toBeTruthy();
}
});
it('all stages have organ systems', () => {
for (const stage of BODY_STAGES) {
expect(stage.organSystems.length).toBeGreaterThan(0);
for (const organ of stage.organSystems) {
expect(organ.name).toBeTruthy();
expect(organ.description).toBeTruthy();
}
}
});
it('all stages have status labels', () => {
for (const stage of BODY_STAGES) {
expect(stage.statusLabel).toBeTruthy();
}
});
it('first stage is fed (04h)', () => {
expect(BODY_STAGES[0].id).toBe('fed');
expect(BODY_STAGES[0].timeRangeHours.min).toBe(0);
expect(BODY_STAGES[0].timeRangeHours.max).toBe(4);
});
it('last stage is extended (48168h)', () => {
const last = BODY_STAGES[BODY_STAGES.length - 1];
expect(last.id).toBe('extended');
expect(last.timeRangeHours.min).toBe(48);
});
});
// ── getStageForDuration ──
describe('getStageForDuration', () => {
it('returns fed for 0 hours', () => {
expect(getStageForDuration(0).id).toBe('fed');
});
it('returns fed for 3 hours', () => {
expect(getStageForDuration(3).id).toBe('fed');
});
it('returns early_fast for 6 hours', () => {
expect(getStageForDuration(6).id).toBe('early_fast');
});
it('returns fasted for 14 hours', () => {
expect(getStageForDuration(14).id).toBe('fasted');
});
it('returns ketosis for 20 hours', () => {
expect(getStageForDuration(20).id).toBe('ketosis');
});
it('returns deep_autophagy for 30 hours', () => {
expect(getStageForDuration(30).id).toBe('deep_autophagy');
});
it('returns extended for 60 hours', () => {
expect(getStageForDuration(60).id).toBe('extended');
});
it('returns extended for 100 hours', () => {
expect(getStageForDuration(100).id).toBe('extended');
});
});
// ── AutophagyConfidenceRequestSchema ──
describe('AutophagyConfidenceRequestSchema', () => {
it('accepts minimal input', () => {
const result = AutophagyConfidenceRequestSchema.safeParse({ durationHours: 16 });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.activityLevel).toBe('sedentary');
}
});
it('accepts full input', () => {
const result = AutophagyConfidenceRequestSchema.safeParse({
durationHours: 24,
lastMealCarbs: 50,
activityLevel: 'moderate',
sleepHours: 8,
completionHistory: { totalFasts: 20, completionRate: 0.85 },
hrvData: { restingHR: 58, hrv: 55 },
});
expect(result.success).toBe(true);
});
it('rejects negative durationHours', () => {
const result = AutophagyConfidenceRequestSchema.safeParse({ durationHours: -5 });
expect(result.success).toBe(false);
});
it('rejects durationHours > 168', () => {
const result = AutophagyConfidenceRequestSchema.safeParse({ durationHours: 200 });
expect(result.success).toBe(false);
});
it('rejects invalid activityLevel', () => {
const result = AutophagyConfidenceRequestSchema.safeParse({
durationHours: 16,
activityLevel: 'extreme',
});
expect(result.success).toBe(false);
});
});
// ── calculateAutophagyConfidence ──
describe('calculateAutophagyConfidence', () => {
it('returns low confidence for 0 hours', () => {
const result = calculateAutophagyConfidence({
durationHours: 0,
activityLevel: 'sedentary',
});
expect(result.confidence).toBeLessThan(30);
expect(['unlikely', 'possible']).toContain(result.label);
expect(result.currentStage).toBe('fed');
});
it('returns moderate confidence for 16 hours with defaults', () => {
const result = calculateAutophagyConfidence({
durationHours: 16,
activityLevel: 'sedentary',
});
expect(result.confidence).toBeGreaterThan(20);
expect(result.confidence).toBeLessThan(70);
expect(result.currentStage).toBe('fasted');
});
it('returns high confidence for 24+ hours with good inputs', () => {
const result = calculateAutophagyConfidence({
durationHours: 30,
lastMealCarbs: 15,
activityLevel: 'active',
sleepHours: 8,
completionHistory: { totalFasts: 60, completionRate: 0.9 },
hrvData: { restingHR: 55, hrv: 60 },
});
expect(result.confidence).toBeGreaterThan(70);
expect(['very_likely', 'near_certain']).toContain(result.label);
});
it('returns near_certain for 72 hours with optimal inputs', () => {
const result = calculateAutophagyConfidence({
durationHours: 72,
lastMealCarbs: 10,
activityLevel: 'very_active',
sleepHours: 8,
completionHistory: { totalFasts: 100, completionRate: 1.0 },
hrvData: { restingHR: 50, hrv: 70 },
});
expect(result.confidence).toBeGreaterThanOrEqual(85);
expect(result.label).toBe('near_certain');
});
it('confidence never exceeds 100', () => {
const result = calculateAutophagyConfidence({
durationHours: 168,
lastMealCarbs: 0,
activityLevel: 'very_active',
sleepHours: 8,
completionHistory: { totalFasts: 1000, completionRate: 1.0 },
hrvData: { restingHR: 40, hrv: 200 },
});
expect(result.confidence).toBeLessThanOrEqual(100);
});
it('breakdown components sum to confidence', () => {
const result = calculateAutophagyConfidence({
durationHours: 20,
lastMealCarbs: 60,
activityLevel: 'moderate',
sleepHours: 7,
completionHistory: { totalFasts: 10, completionRate: 0.8 },
hrvData: { restingHR: 65, hrv: 40 },
});
const sum =
result.breakdown.duration +
result.breakdown.meal +
result.breakdown.activity +
result.breakdown.sleep +
result.breakdown.history +
result.breakdown.hrv;
// Confidence = min(100, sum), so sum >= confidence
expect(sum).toBeGreaterThanOrEqual(result.confidence);
});
it('low carb last meal gives higher meal score', () => {
const lowCarb = calculateAutophagyConfidence({
durationHours: 20,
lastMealCarbs: 15,
activityLevel: 'sedentary',
});
const highCarb = calculateAutophagyConfidence({
durationHours: 20,
lastMealCarbs: 250,
activityLevel: 'sedentary',
});
expect(lowCarb.breakdown.meal).toBeGreaterThan(highCarb.breakdown.meal);
});
it('more active gives higher activity score', () => {
const active = calculateAutophagyConfidence({
durationHours: 20,
activityLevel: 'very_active',
});
const sedentary = calculateAutophagyConfidence({
durationHours: 20,
activityLevel: 'sedentary',
});
expect(active.breakdown.activity).toBeGreaterThan(sedentary.breakdown.activity);
});
it('returns correct current stage', () => {
const r1 = calculateAutophagyConfidence({ durationHours: 2, activityLevel: 'sedentary' });
expect(r1.currentStage).toBe('fed');
const r2 = calculateAutophagyConfidence({ durationHours: 8, activityLevel: 'sedentary' });
expect(r2.currentStage).toBe('early_fast');
const r3 = calculateAutophagyConfidence({ durationHours: 50, activityLevel: 'sedentary' });
expect(r3.currentStage).toBe('extended');
});
});

View File

@ -0,0 +1,178 @@
/**
* Body stages REST endpoints NomGap.
*
* GET /fasting/stages all 6 stage definitions with visualization metadata
* POST /fasting/autophagy-confidence calculate personalized autophagy confidence score
*
* Both routes are public (no auth) stage info is educational.
*/
import type { FastifyInstance } from 'fastify';
import { BadRequestError } from '../../lib/errors.js';
import {
AutophagyConfidenceRequestSchema,
BODY_STAGES,
getStageForDuration,
type AutophagyConfidenceRequest,
type AutophagyConfidenceBreakdown,
type AutophagyConfidenceResponse,
} from './types.js';
export async function bodyStageRoutes(app: FastifyInstance) {
// Get all stage definitions
app.get('/fasting/stages', async () => {
return { stages: BODY_STAGES, total: BODY_STAGES.length };
});
// Calculate autophagy confidence
app.post('/fasting/autophagy-confidence', async req => {
const parsed = AutophagyConfidenceRequestSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const result = calculateAutophagyConfidence(parsed.data);
return result;
});
}
/**
* Calculate personalized autophagy confidence score.
*
* Weights (from PRD §5.3):
* Duration: 40%
* Last meal (carbs): 20%
* Activity level: 15%
* Sleep quality: 10%
* Completion history: 10%
* HRV data: 5%
*/
export function calculateAutophagyConfidence(
input: AutophagyConfidenceRequest
): AutophagyConfidenceResponse {
const breakdown: AutophagyConfidenceBreakdown = {
duration: 0,
meal: 0,
activity: 0,
sleep: 0,
history: 0,
hrv: 0,
};
// Duration score (40% weight, max 40 points)
// Autophagy starts around 16-18h, peaks 24-48h
if (input.durationHours <= 12) {
breakdown.duration = Math.round((input.durationHours / 12) * 10);
} else if (input.durationHours <= 18) {
breakdown.duration = Math.round(10 + ((input.durationHours - 12) / 6) * 10);
} else if (input.durationHours <= 24) {
breakdown.duration = Math.round(20 + ((input.durationHours - 18) / 6) * 10);
} else if (input.durationHours <= 48) {
breakdown.duration = Math.round(30 + ((input.durationHours - 24) / 24) * 10);
} else {
breakdown.duration = 40;
}
// Last meal carbs score (20% weight, max 20 points)
// Lower carbs = faster glycogen depletion = earlier autophagy
if (input.lastMealCarbs !== undefined) {
if (input.lastMealCarbs <= 20) {
breakdown.meal = 20; // Very low carb
} else if (input.lastMealCarbs <= 50) {
breakdown.meal = 15;
} else if (input.lastMealCarbs <= 100) {
breakdown.meal = 10;
} else if (input.lastMealCarbs <= 200) {
breakdown.meal = 5;
} else {
breakdown.meal = 2;
}
} else {
// Default to moderate if unknown
breakdown.meal = 10;
}
// Activity level score (15% weight, max 15 points)
// More activity = faster glycogen depletion
const activityScores: Record<string, number> = {
sedentary: 5,
light: 8,
moderate: 11,
active: 13,
very_active: 15,
};
breakdown.activity = activityScores[input.activityLevel] ?? 5;
// Sleep score (10% weight, max 10 points)
if (input.sleepHours !== undefined) {
if (input.sleepHours >= 7 && input.sleepHours <= 9) {
breakdown.sleep = 10; // Optimal sleep
} else if (input.sleepHours >= 6) {
breakdown.sleep = 7;
} else if (input.sleepHours >= 5) {
breakdown.sleep = 4;
} else {
breakdown.sleep = 2;
}
} else {
breakdown.sleep = 5; // Default moderate
}
// Completion history score (10% weight, max 10 points)
if (input.completionHistory) {
const { totalFasts, completionRate } = input.completionHistory;
// Experienced fasters may enter autophagy more efficiently
const experienceBonus = Math.min(totalFasts / 50, 1) * 5; // Up to 5 points for 50+ fasts
const rateBonus = completionRate * 5; // Up to 5 points for 100% rate
breakdown.history = Math.round(experienceBonus + rateBonus);
} else {
breakdown.history = 3; // Default for new users
}
// HRV data score (5% weight, max 5 points)
if (input.hrvData) {
let hrvScore = 0;
// Lower resting HR generally indicates better metabolic health
if (input.hrvData.restingHR !== undefined) {
if (input.hrvData.restingHR < 60) hrvScore += 2;
else if (input.hrvData.restingHR < 70) hrvScore += 1.5;
else hrvScore += 1;
}
// Higher HRV generally indicates better parasympathetic tone
if (input.hrvData.hrv !== undefined) {
if (input.hrvData.hrv > 50) hrvScore += 3;
else if (input.hrvData.hrv > 30) hrvScore += 2;
else hrvScore += 1;
}
breakdown.hrv = Math.round(Math.min(hrvScore, 5));
} else {
breakdown.hrv = 2; // Default moderate
}
const confidence = Math.min(
100,
breakdown.duration +
breakdown.meal +
breakdown.activity +
breakdown.sleep +
breakdown.history +
breakdown.hrv
);
// Label
let label: AutophagyConfidenceResponse['label'];
if (confidence < 20) label = 'unlikely';
else if (confidence < 40) label = 'possible';
else if (confidence < 65) label = 'likely';
else if (confidence < 85) label = 'very_likely';
else label = 'near_certain';
const currentStage = getStageForDuration(input.durationHours);
return {
confidence,
label,
breakdown,
currentStage: currentStage.id,
};
}

View File

@ -0,0 +1,216 @@
/**
* Body stages types NomGap body visualization stages + autophagy confidence.
*
* No Cosmos container needed this module is pure computation.
*/
import { z } from 'zod';
// ── Body Stage definition ──
export interface OrganSystem {
name: string;
description: string;
active: boolean;
}
export interface BodyStageDefinition {
id: string;
name: string;
timeRangeHours: { min: number; max: number };
description: string;
detailedDescription: string;
organSystems: OrganSystem[];
visualizationData: {
primaryColor: string;
secondaryColor: string;
glowIntensity: number;
animationStyle: string;
};
statusLabel: string;
}
// ── Autophagy confidence ──
export const AutophagyConfidenceRequestSchema = z.object({
durationHours: z.number().min(0).max(168),
lastMealCarbs: z.number().min(0).max(1000).optional(),
activityLevel: z
.enum(['sedentary', 'light', 'moderate', 'active', 'very_active'])
.default('sedentary'),
sleepHours: z.number().min(0).max(24).optional(),
completionHistory: z
.object({
totalFasts: z.number().int().min(0).default(0),
completionRate: z.number().min(0).max(1).default(0),
})
.optional(),
hrvData: z
.object({
restingHR: z.number().min(20).max(250).optional(),
hrv: z.number().min(0).max(300).optional(),
})
.optional(),
});
export type AutophagyConfidenceRequest = z.infer<typeof AutophagyConfidenceRequestSchema>;
export interface AutophagyConfidenceBreakdown {
duration: number;
meal: number;
activity: number;
sleep: number;
history: number;
hrv: number;
}
export interface AutophagyConfidenceResponse {
confidence: number;
label: 'unlikely' | 'possible' | 'likely' | 'very_likely' | 'near_certain';
breakdown: AutophagyConfidenceBreakdown;
currentStage: string;
}
// ── 6 body stages (aligned with PRD §5.2) ──
export const BODY_STAGES: BodyStageDefinition[] = [
{
id: 'fed',
name: 'Fed State',
timeRangeHours: { min: 0, max: 4 },
description: 'Your body is actively digesting food and absorbing nutrients.',
detailedDescription:
'Insulin levels are elevated as your body processes the meal. Glucose is being absorbed into the bloodstream and distributed to cells. Excess glucose is converted to glycogen and stored in the liver and muscles.',
organSystems: [
{ name: 'Stomach', description: 'Actively breaking down food', active: true },
{ name: 'Pancreas', description: 'Releasing insulin to manage blood sugar', active: true },
{ name: 'Liver', description: 'Storing glucose as glycogen', active: true },
{ name: 'Small Intestine', description: 'Absorbing nutrients', active: true },
],
visualizationData: {
primaryColor: '#FF8C42',
secondaryColor: '#FFB366',
glowIntensity: 0.6,
animationStyle: 'warm_pulse',
},
statusLabel: 'Digesting',
},
{
id: 'early_fast',
name: 'Early Fasting',
timeRangeHours: { min: 4, max: 12 },
description: 'Insulin is dropping and your body begins tapping glycogen stores.',
detailedDescription:
'Blood sugar normalizes as insulin drops. Your liver begins releasing stored glycogen to maintain blood glucose levels. The migrating motor complex (gut cleaning wave) activates, sweeping debris from the intestines.',
organSystems: [
{ name: 'Liver', description: 'Releasing stored glycogen', active: true },
{ name: 'Pancreas', description: 'Insulin production decreasing', active: true },
{ name: 'Gut', description: 'Cleaning wave (MMC) active', active: true },
],
visualizationData: {
primaryColor: '#4ECDC4',
secondaryColor: '#7EDDD6',
glowIntensity: 0.4,
animationStyle: 'gentle_transition',
},
statusLabel: 'Transitioning',
},
{
id: 'fasted',
name: 'Fasted State',
timeRangeHours: { min: 12, max: 18 },
description: 'Fat burning begins as glycogen depletes. Early ketones appear.',
detailedDescription:
'Glycogen stores are running low. Your body shifts to burning fat for energy. The liver begins converting fatty acids into ketone bodies. Early, mild autophagy processes start activating in cells. Growth hormone begins to rise.',
organSystems: [
{ name: 'Fat Cells', description: 'Beginning to release fatty acids', active: true },
{ name: 'Liver', description: 'Converting fat to ketones', active: true },
{ name: 'Cells', description: 'Early autophagy activation', active: true },
],
visualizationData: {
primaryColor: '#45B7D1',
secondaryColor: '#6DC8E0',
glowIntensity: 0.5,
animationStyle: 'sparkle_glow',
},
statusLabel: 'Fat Burning Begins',
},
{
id: 'ketosis',
name: 'Ketosis',
timeRangeHours: { min: 18, max: 24 },
description: 'Deep fat burning. Brain shifts to ketones. Autophagy ramps up.',
detailedDescription:
'Your body is now in full ketosis. The brain is using ketone bodies for fuel, often producing a sense of mental clarity. Fat cells are actively shrinking as stored fat is mobilized. Autophagy cleaning crews are actively recycling damaged cellular components.',
organSystems: [
{ name: 'Brain', description: 'Running on ketones — mental clarity', active: true },
{ name: 'Fat Cells', description: 'Actively shrinking', active: true },
{ name: 'Cells', description: 'Autophagy cleaning crews active', active: true },
{ name: 'Pituitary', description: 'Growth hormone elevated', active: true },
],
visualizationData: {
primaryColor: '#5A8CFF',
secondaryColor: '#8AB4FF',
glowIntensity: 0.7,
animationStyle: 'blue_energy',
},
statusLabel: 'Deep Fat Burn + Autophagy',
},
{
id: 'deep_autophagy',
name: 'Deep Autophagy',
timeRangeHours: { min: 24, max: 48 },
description: 'Peak cellular renewal. Old proteins consumed, new cells emerge.',
detailedDescription:
'Dramatic cellular transformation is underway. Damaged proteins and organelles are being broken down and recycled. New, healthy cellular components are being built. Growth hormone levels are significantly elevated, protecting muscle mass while fat continues to burn.',
organSystems: [
{
name: 'All Cells',
description: 'Peak autophagy — recycling damaged components',
active: true,
},
{ name: 'Fat Cells', description: 'Sustained fat burning', active: true },
{ name: 'Pituitary', description: 'Growth hormone surge', active: true },
{ name: 'Immune System', description: 'Beginning to regenerate', active: true },
],
visualizationData: {
primaryColor: '#A855F7',
secondaryColor: '#C084FC',
glowIntensity: 0.85,
animationStyle: 'transformation_pulse',
},
statusLabel: 'Peak Renewal',
},
{
id: 'extended',
name: 'Extended Fast',
timeRangeHours: { min: 48, max: 168 },
description: 'Immune system reboot. Stem cell regeneration. Full body renewal.',
detailedDescription:
'The immune system undergoes a significant regeneration process. Old white blood cells are broken down and new ones are produced from stem cells. This is often referred to as an "immune reset." Consult your doctor before fasting this long.',
organSystems: [
{ name: 'Immune System', description: 'White blood cell regeneration', active: true },
{ name: 'Bone Marrow', description: 'Stem cell division active', active: true },
{ name: 'All Cells', description: 'Deep renewal continuing', active: true },
{ name: 'Fat Cells', description: 'Continued fat mobilization', active: true },
],
visualizationData: {
primaryColor: '#F59E0B',
secondaryColor: '#FBBF24',
glowIntensity: 1.0,
animationStyle: 'golden_renewal',
},
statusLabel: 'Immune Reset',
},
];
// ── Helper: get current stage by hours ──
export function getStageForDuration(durationHours: number): BodyStageDefinition {
for (let i = BODY_STAGES.length - 1; i >= 0; i--) {
if (durationHours >= BODY_STAGES[i].timeRangeHours.min) {
return BODY_STAGES[i];
}
}
return BODY_STAGES[0];
}

View File

@ -0,0 +1,200 @@
/**
* Fasting protocols module unit tests validates schemas, built-in protocols, and type guards.
*/
import { describe, it, expect } from 'vitest';
import {
CreateCustomProtocolSchema,
UpdateCustomProtocolSchema,
FastingProtocolSchema,
BUILT_IN_PROTOCOLS,
PROTOCOL_TYPES,
DIFFICULTY_LEVELS,
} from './types.js';
// ── Built-in protocols ──
describe('BUILT_IN_PROTOCOLS', () => {
it('has exactly 14 built-in protocols', () => {
expect(BUILT_IN_PROTOCOLS).toHaveLength(14);
});
it('all built-in protocols have isCustom = false', () => {
for (const p of BUILT_IN_PROTOCOLS) {
expect(p.isCustom).toBe(false);
}
});
it('all built-in protocols have unique IDs', () => {
const ids = BUILT_IN_PROTOCOLS.map(p => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('all built-in protocols have valid types', () => {
for (const p of BUILT_IN_PROTOCOLS) {
expect(PROTOCOL_TYPES).toContain(p.type);
}
});
it('all built-in protocols have valid difficulty', () => {
for (const p of BUILT_IN_PROTOCOLS) {
expect(DIFFICULTY_LEVELS).toContain(p.difficulty);
}
});
it('all built-in protocols pass FastingProtocolSchema', () => {
for (const p of BUILT_IN_PROTOCOLS) {
const result = FastingProtocolSchema.safeParse(p);
expect(result.success).toBe(true);
}
});
it('includes 16:8 protocol', () => {
const found = BUILT_IN_PROTOCOLS.find(p => p.name === '16:8');
expect(found).toBeDefined();
expect(found!.fastHours).toBe(16);
expect(found!.eatHours).toBe(8);
expect(found!.difficulty).toBe('moderate');
});
it('includes OMAD protocol', () => {
const found = BUILT_IN_PROTOCOLS.find(p => p.name === 'OMAD');
expect(found).toBeDefined();
expect(found!.fastHours).toBe(23);
expect(found!.eatHours).toBe(1);
});
it('includes Ramadan as religious protocol with locationAware', () => {
const found = BUILT_IN_PROTOCOLS.find(p => p.name === 'Ramadan');
expect(found).toBeDefined();
expect(found!.type).toBe('religious');
expect(found!.religionId).toBe('islam');
expect(found!.locationAware).toBe(true);
});
it('includes Ekadashi as religious protocol', () => {
const found = BUILT_IN_PROTOCOLS.find(p => p.name === 'Ekadashi');
expect(found).toBeDefined();
expect(found!.type).toBe('religious');
expect(found!.religionId).toBe('hinduism');
});
it('has extended fasts (36h, 48h, 72h)', () => {
const extended = BUILT_IN_PROTOCOLS.filter(p => p.type === 'extended');
expect(extended.length).toBeGreaterThanOrEqual(3);
const hours = extended.map(p => p.fastHours);
expect(hours).toContain(36);
expect(hours).toContain(48);
expect(hours).toContain(72);
});
});
// ── CreateCustomProtocolSchema ──
describe('CreateCustomProtocolSchema', () => {
const validMinimal = {
name: 'My Custom Fast',
fastHours: 20,
eatHours: 4,
};
it('accepts minimal valid input with defaults', () => {
const result = CreateCustomProtocolSchema.safeParse(validMinimal);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('My Custom Fast');
expect(result.data.type).toBe('custom');
expect(result.data.difficulty).toBe('moderate');
expect(result.data.description).toBe('');
}
});
it('accepts full input with all optional fields', () => {
const result = CreateCustomProtocolSchema.safeParse({
...validMinimal,
type: 'religious',
description: 'A special spiritual fast',
difficulty: 'hard',
religionId: 'buddhism',
locationAware: true,
});
expect(result.success).toBe(true);
});
it('rejects missing name', () => {
const result = CreateCustomProtocolSchema.safeParse({
fastHours: 20,
eatHours: 4,
});
expect(result.success).toBe(false);
});
it('rejects missing fastHours', () => {
const result = CreateCustomProtocolSchema.safeParse({
name: 'Test',
eatHours: 4,
});
expect(result.success).toBe(false);
});
it('rejects fastHours > 168 (one week)', () => {
const result = CreateCustomProtocolSchema.safeParse({
name: 'Too Long',
fastHours: 200,
eatHours: 0,
});
expect(result.success).toBe(false);
});
it('rejects invalid difficulty', () => {
const result = CreateCustomProtocolSchema.safeParse({
...validMinimal,
difficulty: 'impossible',
});
expect(result.success).toBe(false);
});
it('rejects invalid type', () => {
const result = CreateCustomProtocolSchema.safeParse({
...validMinimal,
type: 'invalid',
});
expect(result.success).toBe(false);
});
});
// ── UpdateCustomProtocolSchema ──
describe('UpdateCustomProtocolSchema', () => {
it('accepts empty object (no updates)', () => {
const result = UpdateCustomProtocolSchema.safeParse({});
expect(result.success).toBe(true);
});
it('accepts partial name update', () => {
const result = UpdateCustomProtocolSchema.safeParse({ name: 'Renamed Protocol' });
expect(result.success).toBe(true);
});
it('accepts partial fastHours update', () => {
const result = UpdateCustomProtocolSchema.safeParse({ fastHours: 22 });
expect(result.success).toBe(true);
});
it('rejects name > 128 chars', () => {
const result = UpdateCustomProtocolSchema.safeParse({ name: 'x'.repeat(200) });
expect(result.success).toBe(false);
});
});
// ── Constants ──
describe('type constants', () => {
it('has expected protocol types', () => {
expect(PROTOCOL_TYPES).toEqual(['interval', 'extended', 'alternate', 'religious', 'custom']);
});
it('has expected difficulty levels', () => {
expect(DIFFICULTY_LEVELS).toEqual(['easy', 'moderate', 'hard', 'very_hard', 'expert']);
});
});

View File

@ -0,0 +1,74 @@
/**
* Fasting protocols repository Cosmos DB CRUD for custom protocols.
*
* Built-in protocols are hardcoded in types.ts (no DB).
* Custom protocols stored in container: fasting_protocols (partition key: /userId)
*/
import { getContainer } from '../../lib/cosmos.js';
import type { FastingProtocolDoc } from './types.js';
function container() {
return getContainer('fasting_protocols');
}
export async function getCustomProtocols(userId: string): Promise<FastingProtocolDoc[]> {
const { resources } = await container()
.items.query<FastingProtocolDoc>({
query:
'SELECT * FROM c WHERE c.userId = @userId AND c.deleted = false ORDER BY c.createdAt DESC',
parameters: [{ name: '@userId', value: userId }],
})
.fetchAll();
return resources;
}
export async function getCustomProtocol(
userId: string,
protocolId: string
): Promise<FastingProtocolDoc | null> {
try {
const { resource } = await container().item(protocolId, userId).read<FastingProtocolDoc>();
if (!resource || resource.deleted) return null;
return resource;
} catch {
return null;
}
}
export async function createCustomProtocol(doc: FastingProtocolDoc): Promise<FastingProtocolDoc> {
const { resource } = await container().items.create(doc);
return resource as FastingProtocolDoc;
}
export async function updateCustomProtocol(
userId: string,
protocolId: string,
updates: Partial<FastingProtocolDoc>
): Promise<FastingProtocolDoc | null> {
try {
const { resource: existing } = await container()
.item(protocolId, userId)
.read<FastingProtocolDoc>();
if (!existing || existing.deleted) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await container().item(protocolId, userId).replace(merged);
return resource as FastingProtocolDoc;
} catch {
return null;
}
}
export async function deleteCustomProtocol(userId: string, protocolId: string): Promise<boolean> {
try {
const { resource: existing } = await container()
.item(protocolId, userId)
.read<FastingProtocolDoc>();
if (!existing || existing.deleted) return false;
const merged = { ...existing, deleted: true, updatedAt: new Date().toISOString() };
await container().item(protocolId, userId).replace(merged);
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,145 @@
/**
* Fasting protocols REST endpoints NomGap.
*
* GET /fasting/protocols list all (built-in + user custom)
* GET /fasting/protocols/:id single protocol
* POST /fasting/protocols create custom protocol (auth required)
* PUT /fasting/protocols/:id update custom (auth, owner only)
* DELETE /fasting/protocols/:id delete custom (auth, owner only)
*/
import type { FastifyInstance } from 'fastify';
import { getRequestProductId } from '../../lib/request-context.js';
import { BadRequestError, ForbiddenError, NotFoundError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js';
import {
CreateCustomProtocolSchema,
UpdateCustomProtocolSchema,
BUILT_IN_PROTOCOLS,
type FastingProtocolDoc,
type FastingProtocol,
} from './types.js';
export async function fastingProtocolRoutes(app: FastifyInstance) {
// List all protocols (built-in + user custom)
app.get('/fasting/protocols', async req => {
const auth = await extractAuth(req);
const customProtocols = await repo.getCustomProtocols(auth.sub);
// Merge built-in + custom, return as FastingProtocol shape
const all: FastingProtocol[] = [...BUILT_IN_PROTOCOLS, ...customProtocols.map(toProtocol)];
return { protocols: all, total: all.length };
});
// Get single protocol
app.get('/fasting/protocols/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
// Check built-in first
const builtIn = BUILT_IN_PROTOCOLS.find(p => p.id === id);
if (builtIn) return builtIn;
// Check custom
const custom = await repo.getCustomProtocol(auth.sub, id);
if (!custom) throw new NotFoundError('Protocol not found');
return toProtocol(custom);
});
// Create custom protocol
app.post('/fasting/protocols', async (req, reply) => {
const auth = await extractAuth(req);
const pid = getRequestProductId(req);
const parsed = CreateCustomProtocolSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const input = parsed.data;
const now = new Date().toISOString();
const doc: FastingProtocolDoc = {
id: `proto_${crypto.randomUUID()}`,
userId: auth.sub,
productId: pid,
name: input.name,
type: input.type,
fastHours: input.fastHours,
eatHours: input.eatHours,
description: input.description,
difficulty: input.difficulty,
isCustom: true,
religionId: input.religionId,
locationAware: input.locationAware,
deleted: false,
createdAt: now,
updatedAt: now,
};
req.log.info({ protocolId: doc.id, name: doc.name }, 'Creating custom fasting protocol');
const created = await repo.createCustomProtocol(doc);
reply.code(201);
return toProtocol(created);
});
// Update custom protocol (owner only)
app.put('/fasting/protocols/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
// Cannot update built-in protocols
if (BUILT_IN_PROTOCOLS.some(p => p.id === id)) {
throw new ForbiddenError('Cannot modify built-in protocols');
}
const existing = await repo.getCustomProtocol(auth.sub, id);
if (!existing) throw new NotFoundError('Protocol not found');
if (existing.userId !== auth.sub) throw new ForbiddenError('Not the protocol owner');
const parsed = UpdateCustomProtocolSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
req.log.info({ protocolId: id, updates: Object.keys(parsed.data) }, 'Updating custom protocol');
const updated = await repo.updateCustomProtocol(auth.sub, id, parsed.data);
if (!updated) throw new NotFoundError('Protocol update failed');
return toProtocol(updated);
});
// Delete custom protocol (owner only, soft delete)
app.delete('/fasting/protocols/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
// Cannot delete built-in protocols
if (BUILT_IN_PROTOCOLS.some(p => p.id === id)) {
throw new ForbiddenError('Cannot delete built-in protocols');
}
const existing = await repo.getCustomProtocol(auth.sub, id);
if (!existing) throw new NotFoundError('Protocol not found');
if (existing.userId !== auth.sub) throw new ForbiddenError('Not the protocol owner');
req.log.info({ protocolId: id }, 'Deleting custom protocol');
const success = await repo.deleteCustomProtocol(auth.sub, id);
if (!success) throw new NotFoundError('Protocol deletion failed');
return { success: true };
});
}
/** Strip Cosmos-specific fields to return a clean FastingProtocol. */
function toProtocol(doc: FastingProtocolDoc): FastingProtocol {
return {
id: doc.id,
name: doc.name,
type: doc.type,
fastHours: doc.fastHours,
eatHours: doc.eatHours,
description: doc.description,
difficulty: doc.difficulty,
isCustom: doc.isCustom,
religionId: doc.religionId,
locationAware: doc.locationAware,
};
}

View File

@ -0,0 +1,232 @@
/**
* Fasting protocol types NomGap protocol definitions.
*
* Cosmos container: `fasting_protocols` (partition key: `/userId`)
* Built-in protocols are hardcoded; custom protocols stored in Cosmos.
* Product-agnostic: every custom protocol document includes `productId`.
*/
import { z } from 'zod';
// ── Enums / constants ──
export const PROTOCOL_TYPES = ['interval', 'extended', 'alternate', 'religious', 'custom'] as const;
export type ProtocolType = (typeof PROTOCOL_TYPES)[number];
export const DIFFICULTY_LEVELS = ['easy', 'moderate', 'hard', 'very_hard', 'expert'] as const;
export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number];
// ── Main interface ──
export interface FastingProtocol {
id: string;
name: string;
type: ProtocolType;
fastHours: number;
eatHours: number;
description: string;
difficulty: DifficultyLevel;
isCustom: boolean;
religionId?: string;
locationAware?: boolean;
}
// ── Cosmos document (custom protocols only) ──
export interface FastingProtocolDoc extends FastingProtocol {
userId: string;
productId: string;
deleted: boolean;
createdAt: string;
updatedAt: string;
}
// ── Zod schemas ──
export const FastingProtocolSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(128),
type: z.enum(PROTOCOL_TYPES),
fastHours: z.number().min(0).max(168),
eatHours: z.number().min(0).max(168),
description: z.string().max(2000),
difficulty: z.enum(DIFFICULTY_LEVELS),
isCustom: z.boolean(),
religionId: z.string().max(64).optional(),
locationAware: z.boolean().optional(),
});
export const CreateCustomProtocolSchema = z.object({
name: z.string().min(1).max(128),
type: z.enum(PROTOCOL_TYPES).default('custom'),
fastHours: z.number().min(1).max(168),
eatHours: z.number().min(0).max(168),
description: z.string().max(2000).default(''),
difficulty: z.enum(DIFFICULTY_LEVELS).default('moderate'),
religionId: z.string().max(64).optional(),
locationAware: z.boolean().optional(),
});
export const UpdateCustomProtocolSchema = z.object({
name: z.string().min(1).max(128).optional(),
fastHours: z.number().min(1).max(168).optional(),
eatHours: z.number().min(0).max(168).optional(),
description: z.string().max(2000).optional(),
difficulty: z.enum(DIFFICULTY_LEVELS).optional(),
religionId: z.string().max(64).optional(),
locationAware: z.boolean().optional(),
});
// ── Inferred types ──
export type CreateCustomProtocolInput = z.infer<typeof CreateCustomProtocolSchema>;
export type UpdateCustomProtocolInput = z.infer<typeof UpdateCustomProtocolSchema>;
// ── Built-in protocols (14 total, aligned with PRD §5.1) ──
export const BUILT_IN_PROTOCOLS: FastingProtocol[] = [
{
id: 'protocol_12_12',
name: '12:12',
type: 'interval',
fastHours: 12,
eatHours: 12,
description: 'Beginner-friendly, gentle start. Equal fasting and eating windows.',
difficulty: 'easy',
isCustom: false,
},
{
id: 'protocol_14_10',
name: '14:10',
type: 'interval',
fastHours: 14,
eatHours: 10,
description: 'Transition protocol. A gentle step up from 12:12.',
difficulty: 'easy',
isCustom: false,
},
{
id: 'protocol_16_8',
name: '16:8',
type: 'interval',
fastHours: 16,
eatHours: 8,
description: 'Most popular IF protocol. Skip breakfast or dinner.',
difficulty: 'moderate',
isCustom: false,
},
{
id: 'protocol_18_6',
name: '18:6',
type: 'interval',
fastHours: 18,
eatHours: 6,
description: 'Enhanced fat burning with a narrower eating window.',
difficulty: 'moderate',
isCustom: false,
},
{
id: 'protocol_20_4',
name: '20:4 (Warrior)',
type: 'interval',
fastHours: 20,
eatHours: 4,
description: 'One large meal plus a small snack in a 4-hour window.',
difficulty: 'hard',
isCustom: false,
},
{
id: 'protocol_omad',
name: 'OMAD',
type: 'interval',
fastHours: 23,
eatHours: 1,
description: 'One Meal A Day. Maximum daily fasting window.',
difficulty: 'hard',
isCustom: false,
},
{
id: 'protocol_5_2',
name: '5:2',
type: 'alternate',
fastHours: 24,
eatHours: 0,
description: 'Five normal eating days plus two restricted days (500-600 cal).',
difficulty: 'moderate',
isCustom: false,
},
{
id: 'protocol_adf',
name: 'ADF (Alternate Day)',
type: 'alternate',
fastHours: 24,
eatHours: 24,
description: 'Alternate day fasting: eat one day, fast the next.',
difficulty: 'hard',
isCustom: false,
},
{
id: 'protocol_24h',
name: '24h Fast',
type: 'extended',
fastHours: 24,
eatHours: 0,
description: 'Full day fast. Typically done weekly or bi-weekly.',
difficulty: 'hard',
isCustom: false,
},
{
id: 'protocol_36h',
name: '36h Fast',
type: 'extended',
fastHours: 36,
eatHours: 0,
description: 'Day and a half fast. Intermediate extended fasting.',
difficulty: 'very_hard',
isCustom: false,
},
{
id: 'protocol_48h',
name: '48h Fast',
type: 'extended',
fastHours: 48,
eatHours: 0,
description: 'Two day fast. Deep autophagy window.',
difficulty: 'very_hard',
isCustom: false,
},
{
id: 'protocol_72h',
name: '72h Fast',
type: 'extended',
fastHours: 72,
eatHours: 0,
description: 'Three day fast. Immune system reset fast. Consult a doctor.',
difficulty: 'expert',
isCustom: false,
},
{
id: 'protocol_ramadan',
name: 'Ramadan',
type: 'religious',
fastHours: 14,
eatHours: 10,
description: 'Islamic fasting from dawn to sunset. Duration adjusts by location and date.',
difficulty: 'moderate',
isCustom: false,
religionId: 'islam',
locationAware: true,
},
{
id: 'protocol_ekadashi',
name: 'Ekadashi',
type: 'religious',
fastHours: 24,
eatHours: 0,
description: 'Hindu fasting on the 11th day of each lunar cycle, sunrise to sunrise.',
difficulty: 'hard',
isCustom: false,
religionId: 'hinduism',
locationAware: true,
},
];

View File

@ -0,0 +1,282 @@
/**
* Fasting sessions module unit tests validates schema parsing, type guards, and constants.
*/
import { describe, it, expect } from 'vitest';
import {
CreateFastingSessionSchema,
UpdateFastingSessionSchema,
FastingSessionQuerySchema,
SESSION_STATUSES,
BODY_STAGES,
MEAL_TYPES,
} from './types.js';
// ── CreateFastingSessionSchema ──
describe('CreateFastingSessionSchema', () => {
const validMinimal = {
protocolId: '16:8',
startedAt: 1709000000000,
targetDurationMs: 57600000, // 16h
};
it('accepts minimal valid input', () => {
const result = CreateFastingSessionSchema.safeParse(validMinimal);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.protocolId).toBe('16:8');
expect(result.data.status).toBe('active');
expect(result.data.waterIntake).toBe(0);
expect(result.data.notes).toBe('');
expect(result.data.stages).toEqual([]);
expect(result.data.moodCheckins).toEqual([]);
}
});
it('accepts full input with all optional fields', () => {
const result = CreateFastingSessionSchema.safeParse({
...validMinimal,
status: 'paused',
stages: [{ stage: 'fed', enteredAt: 1709000000000, autophagyConfidence: 0 }],
moodCheckins: [{ timestamp: 1709000000000, energy: 3, mood: 4, hunger: 2 }],
waterIntake: 5,
notes: 'Feeling good',
lastMealBeforeFast: {
id: 'meal_1',
timestamp: 1708999000000,
description: 'Chicken salad',
mealType: 'last_before_fast',
macros: { carbs: 30, protein: 40, fat: 15 },
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.stages).toHaveLength(1);
expect(result.data.moodCheckins).toHaveLength(1);
expect(result.data.lastMealBeforeFast?.macros?.protein).toBe(40);
}
});
it('rejects missing protocolId', () => {
const result = CreateFastingSessionSchema.safeParse({
startedAt: 1709000000000,
targetDurationMs: 57600000,
});
expect(result.success).toBe(false);
});
it('rejects missing startedAt', () => {
const result = CreateFastingSessionSchema.safeParse({
protocolId: '16:8',
targetDurationMs: 57600000,
});
expect(result.success).toBe(false);
});
it('rejects missing targetDurationMs', () => {
const result = CreateFastingSessionSchema.safeParse({
protocolId: '16:8',
startedAt: 1709000000000,
});
expect(result.success).toBe(false);
});
it('rejects invalid status', () => {
const result = CreateFastingSessionSchema.safeParse({
...validMinimal,
status: 'invalid',
});
expect(result.success).toBe(false);
});
it('rejects negative targetDurationMs', () => {
const result = CreateFastingSessionSchema.safeParse({
...validMinimal,
targetDurationMs: -1,
});
expect(result.success).toBe(false);
});
it('rejects invalid mood checkin ratings', () => {
const result = CreateFastingSessionSchema.safeParse({
...validMinimal,
moodCheckins: [{ timestamp: 1709000000000, energy: 6, mood: 4, hunger: 2 }],
});
expect(result.success).toBe(false);
});
it('rejects invalid body stage in stages array', () => {
const result = CreateFastingSessionSchema.safeParse({
...validMinimal,
stages: [{ stage: 'invalid_stage', enteredAt: 1709000000000, autophagyConfidence: 50 }],
});
expect(result.success).toBe(false);
});
it('rejects autophagy confidence > 100', () => {
const result = CreateFastingSessionSchema.safeParse({
...validMinimal,
stages: [{ stage: 'ketosis', enteredAt: 1709000000000, autophagyConfidence: 150 }],
});
expect(result.success).toBe(false);
});
});
// ── UpdateFastingSessionSchema ──
describe('UpdateFastingSessionSchema', () => {
it('accepts empty object (no updates)', () => {
const result = UpdateFastingSessionSchema.safeParse({});
expect(result.success).toBe(true);
});
it('accepts status update only', () => {
const result = UpdateFastingSessionSchema.safeParse({ status: 'completed' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.status).toBe('completed');
}
});
it('accepts complete session update with metrics', () => {
const result = UpdateFastingSessionSchema.safeParse({
status: 'completed',
endedAt: 1709057600000,
metrics: {
actualDurationMs: 57600000,
completionRatio: 1.0,
peakAutophagyConfidence: 45,
totalPausedMs: 0,
moodCheckinCount: 3,
averageEnergy: 3.5,
averageMood: 4.0,
},
});
expect(result.success).toBe(true);
});
it('accepts break fast meal addition', () => {
const result = UpdateFastingSessionSchema.safeParse({
breakFastMeal: {
id: 'meal_2',
timestamp: 1709057600000,
description: 'Bone broth and eggs',
mealType: 'break_fast',
},
});
expect(result.success).toBe(true);
});
it('rejects invalid status', () => {
const result = UpdateFastingSessionSchema.safeParse({ status: 'deleted' });
expect(result.success).toBe(false);
});
it('rejects metrics with completionRatio > 1', () => {
const result = UpdateFastingSessionSchema.safeParse({
metrics: {
actualDurationMs: 57600000,
completionRatio: 1.5,
peakAutophagyConfidence: 45,
totalPausedMs: 0,
moodCheckinCount: 0,
averageEnergy: null,
averageMood: null,
},
});
expect(result.success).toBe(false);
});
});
// ── FastingSessionQuerySchema ──
describe('FastingSessionQuerySchema', () => {
it('provides defaults for empty query', () => {
const result = FastingSessionQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sortBy).toBe('startedAt');
expect(result.data.sortOrder).toBe('desc');
expect(result.data.limit).toBe(50);
expect(result.data.offset).toBe(0);
}
});
it('coerces string numbers for limit and offset', () => {
const result = FastingSessionQuerySchema.safeParse({ limit: '25', offset: '10' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(25);
expect(result.data.offset).toBe(10);
}
});
it('accepts date range filter', () => {
const result = FastingSessionQuerySchema.safeParse({
startDate: '1709000000000',
endDate: '1709100000000',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.startDate).toBe(1709000000000);
expect(result.data.endDate).toBe(1709100000000);
}
});
it('accepts status filter', () => {
const result = FastingSessionQuerySchema.safeParse({ status: 'completed' });
expect(result.success).toBe(true);
});
it('accepts protocolId filter', () => {
const result = FastingSessionQuerySchema.safeParse({ protocolId: 'omad' });
expect(result.success).toBe(true);
});
it('rejects limit > 100', () => {
const result = FastingSessionQuerySchema.safeParse({ limit: 200 });
expect(result.success).toBe(false);
});
it('rejects negative offset', () => {
const result = FastingSessionQuerySchema.safeParse({ offset: -5 });
expect(result.success).toBe(false);
});
it('rejects invalid sortBy', () => {
const result = FastingSessionQuerySchema.safeParse({ sortBy: 'random' });
expect(result.success).toBe(false);
});
});
// ── Constants ──
describe('type constants', () => {
it('has expected session statuses', () => {
expect(SESSION_STATUSES).toEqual(['active', 'paused', 'completed', 'broken', 'abandoned']);
});
it('has expected body stages', () => {
expect(BODY_STAGES).toEqual([
'fed',
'early_fast',
'fasted',
'ketosis',
'deep_autophagy',
'extended',
]);
});
it('has expected meal types', () => {
expect(MEAL_TYPES).toEqual(['break_fast', 'regular', 'last_before_fast']);
});
it('has 5 session statuses', () => {
expect(SESSION_STATUSES).toHaveLength(5);
});
it('has 6 body stages', () => {
expect(BODY_STAGES).toHaveLength(6);
});
});

View File

@ -0,0 +1,258 @@
/**
* Fasting sessions repository Cosmos DB CRUD + stats aggregation.
*
* Container: fasting_sessions (partition key: /userId)
*/
import { getContainer } from '../../lib/cosmos.js';
import type {
FastingSessionDoc,
FastingSessionQuery,
UserFastingStats,
WeeklyFastingStats,
} from './types.js';
function container() {
return getContainer('fasting_sessions');
}
export async function createSession(doc: FastingSessionDoc): Promise<FastingSessionDoc> {
const { resource } = await container().items.create(doc);
return resource as FastingSessionDoc;
}
export async function getSession(
userId: string,
sessionId: string
): Promise<FastingSessionDoc | null> {
try {
const { resource } = await container().item(sessionId, userId).read<FastingSessionDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function listSessions(
userId: string,
query: FastingSessionQuery
): Promise<{ items: FastingSessionDoc[]; total: number }> {
const conditions: string[] = ['c.userId = @userId'];
const params: { name: string; value: string | number }[] = [{ name: '@userId', value: userId }];
if (query.status) {
conditions.push('c.status = @status');
params.push({ name: '@status', value: query.status });
}
if (query.protocolId) {
conditions.push('c.protocolId = @protocolId');
params.push({ name: '@protocolId', value: query.protocolId });
}
if (query.startDate) {
conditions.push('c.startedAt >= @startDate');
params.push({ name: '@startDate', value: query.startDate });
}
if (query.endDate) {
conditions.push('c.startedAt <= @endDate');
params.push({ name: '@endDate', value: query.endDate });
}
const where = `WHERE ${conditions.join(' AND ')}`;
const sortField = `c.${query.sortBy}`;
const orderDir = query.sortOrder.toUpperCase();
// Count query
const countResult = await container()
.items.query<number>({
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
parameters: params,
})
.fetchAll();
const total = countResult.resources[0] ?? 0;
// Data query with pagination
const { resources } = await container()
.items.query<FastingSessionDoc>({
query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`,
parameters: [
...params,
{ name: '@offset', value: query.offset },
{ name: '@limit', value: query.limit },
],
})
.fetchAll();
return { items: resources, total };
}
export async function updateSession(
userId: string,
sessionId: string,
updates: Partial<FastingSessionDoc>
): Promise<FastingSessionDoc | null> {
try {
const { resource: existing } = await container()
.item(sessionId, userId)
.read<FastingSessionDoc>();
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await container().item(sessionId, userId).replace(merged);
return resource as FastingSessionDoc;
} catch {
return null;
}
}
export async function getUserStats(userId: string): Promise<UserFastingStats> {
const { resources: allSessions } = await container()
.items.query<FastingSessionDoc>({
query: 'SELECT * FROM c WHERE c.userId = @userId ORDER BY c.startedAt DESC',
parameters: [{ name: '@userId', value: userId }],
})
.fetchAll();
const completed = allSessions.filter(s => s.status === 'completed');
const totalHoursMs = completed.reduce((sum, s) => sum + s.metrics.actualDurationMs, 0);
const totalHours = totalHoursMs / (1000 * 60 * 60);
const avgDuration = completed.length > 0 ? totalHours / completed.length : 0;
const completionRate =
allSessions.length > 0
? completed.length /
allSessions.filter(s => s.status !== 'active' && s.status !== 'paused').length || 0
: 0;
// Streak calculation — consecutive completed sessions by day
let currentStreak = 0;
let longestStreak = 0;
if (completed.length > 0) {
const sortedByDate = [...completed].sort((a, b) => b.startedAt - a.startedAt);
const daySet = new Set(
sortedByDate.map(s => {
const d = new Date(s.startedAt);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
})
);
const uniqueDays = [...daySet];
// Current streak: count consecutive days from today backwards
const today = new Date();
const todayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
const yesterday = new Date(today.getTime() - 86400000);
const yesterdayKey = `${yesterday.getFullYear()}-${yesterday.getMonth()}-${yesterday.getDate()}`;
// Start counting if today or yesterday has a fast
if (uniqueDays[0] === todayKey || uniqueDays[0] === yesterdayKey) {
let streak = 1;
for (let i = 1; i < uniqueDays.length; i++) {
// Check if consecutive (simplified — just counting unique fast days in a row)
const prevDate = new Date(
completed.find(s => {
const d = new Date(s.startedAt);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` === uniqueDays[i - 1];
})!.startedAt
);
const currDate = new Date(
completed.find(s => {
const d = new Date(s.startedAt);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` === uniqueDays[i];
})!.startedAt
);
const dayDiff = Math.floor((prevDate.getTime() - currDate.getTime()) / 86400000);
if (dayDiff <= 1) {
streak++;
} else {
break;
}
}
currentStreak = streak;
}
// Longest streak
let tempStreak = 1;
longestStreak = 1;
for (let i = 1; i < uniqueDays.length; i++) {
const prevDate = new Date(
completed.find(s => {
const d = new Date(s.startedAt);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` === uniqueDays[i - 1];
})!.startedAt
);
const currDate = new Date(
completed.find(s => {
const d = new Date(s.startedAt);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` === uniqueDays[i];
})!.startedAt
);
const dayDiff = Math.floor((prevDate.getTime() - currDate.getTime()) / 86400000);
if (dayDiff <= 1) {
tempStreak++;
} else {
tempStreak = 1;
}
longestStreak = Math.max(longestStreak, tempStreak);
}
longestStreak = Math.max(longestStreak, currentStreak);
}
return {
userId,
totalFasts: allSessions.length,
totalHours: Math.round(totalHours * 100) / 100,
averageDurationHours: Math.round(avgDuration * 100) / 100,
completionRate: Math.round(completionRate * 100) / 100,
currentStreak,
longestStreak,
totalCompletedFasts: completed.length,
};
}
export async function getWeeklyStats(userId: string): Promise<WeeklyFastingStats> {
const now = new Date();
const dayOfWeek = now.getDay();
const weekStart = new Date(now);
weekStart.setDate(now.getDate() - dayOfWeek);
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 7);
const weekStartMs = weekStart.getTime();
const weekEndMs = weekEnd.getTime();
const { resources: weekSessions } = await container()
.items.query<FastingSessionDoc>({
query:
'SELECT * FROM c WHERE c.userId = @userId AND c.startedAt >= @weekStart AND c.startedAt < @weekEnd',
parameters: [
{ name: '@userId', value: userId },
{ name: '@weekStart', value: weekStartMs },
{ name: '@weekEnd', value: weekEndMs },
],
})
.fetchAll();
const completed = weekSessions.filter(s => s.status === 'completed');
const finishedSessions = weekSessions.filter(s => s.status !== 'active' && s.status !== 'paused');
const totalHoursMs = completed.reduce((sum, s) => sum + s.metrics.actualDurationMs, 0);
const totalHours = totalHoursMs / (1000 * 60 * 60);
const avgDuration = completed.length > 0 ? totalHours / completed.length : 0;
const longestFastMs =
completed.length > 0 ? Math.max(...completed.map(s => s.metrics.actualDurationMs)) : 0;
return {
userId,
weekStart: weekStart.toISOString(),
weekEnd: weekEnd.toISOString(),
fastsStarted: weekSessions.length,
fastsCompleted: completed.length,
totalHours: Math.round(totalHours * 100) / 100,
averageDurationHours: Math.round(avgDuration * 100) / 100,
longestFastHours: Math.round((longestFastMs / (1000 * 60 * 60)) * 100) / 100,
completionRate:
finishedSessions.length > 0
? Math.round((completed.length / finishedSessions.length) * 100) / 100
: 0,
};
}

View File

@ -0,0 +1,123 @@
/**
* Fasting sessions REST endpoints NomGap.
*
* POST /fasting/sessions create or sync a session
* GET /fasting/sessions list with pagination + date range
* GET /fasting/sessions/:id single session
* PUT /fasting/sessions/:id update (break, complete, add mood checkin)
* GET /fasting/stats aggregated user stats
* GET /fasting/stats/weekly this week's summary
*/
import type { FastifyInstance } from 'fastify';
import { getRequestProductId } from '../../lib/request-context.js';
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js';
import {
CreateFastingSessionSchema,
UpdateFastingSessionSchema,
FastingSessionQuerySchema,
type FastingSessionDoc,
type FastMetrics,
} from './types.js';
export async function fastingSessionRoutes(app: FastifyInstance) {
// Stats — must be registered before :id param route
app.get('/fasting/stats', async req => {
const auth = await extractAuth(req);
const stats = await repo.getUserStats(auth.sub);
return stats;
});
// Weekly stats
app.get('/fasting/stats/weekly', async req => {
const auth = await extractAuth(req);
const stats = await repo.getWeeklyStats(auth.sub);
return stats;
});
// List sessions
app.get('/fasting/sessions', async req => {
const auth = await extractAuth(req);
const parsed = FastingSessionQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { items, total } = await repo.listSessions(auth.sub, parsed.data);
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
});
// Get session
app.get('/fasting/sessions/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const session = await repo.getSession(auth.sub, id);
if (!session) throw new NotFoundError('Fasting session not found');
return session;
});
// Create session
app.post('/fasting/sessions', async (req, reply) => {
const auth = await extractAuth(req);
const pid = getRequestProductId(req);
const parsed = CreateFastingSessionSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const input = parsed.data;
const now = new Date().toISOString();
const defaultMetrics: FastMetrics = {
actualDurationMs: 0,
completionRatio: 0,
peakAutophagyConfidence: 0,
totalPausedMs: 0,
moodCheckinCount: input.moodCheckins.length,
averageEnergy: null,
averageMood: null,
};
const doc: FastingSessionDoc = {
id: `fs_${crypto.randomUUID()}`,
userId: auth.sub,
productId: pid,
protocolId: input.protocolId,
startedAt: input.startedAt,
targetDurationMs: input.targetDurationMs,
status: input.status,
totalPausedMs: 0,
stages: input.stages,
moodCheckins: input.moodCheckins,
waterIntake: input.waterIntake,
notes: input.notes,
lastMealBeforeFast: input.lastMealBeforeFast,
metrics: defaultMetrics,
createdAt: now,
updatedAt: now,
};
req.log.info({ sessionId: doc.id, protocolId: doc.protocolId }, 'Creating fasting session');
const created = await repo.createSession(doc);
reply.code(201);
return created;
});
// Update session
app.put('/fasting/sessions/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const existing = await repo.getSession(auth.sub, id);
if (!existing) throw new NotFoundError('Fasting session not found');
const parsed = UpdateFastingSessionSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
req.log.info({ sessionId: id, updates: Object.keys(parsed.data) }, 'Updating fasting session');
const updated = await repo.updateSession(auth.sub, id, parsed.data);
if (!updated) throw new NotFoundError('Fasting session update failed');
return updated;
});
}

View File

@ -0,0 +1,199 @@
/**
* Fasting session types NomGap fasting tracker.
*
* Cosmos container: `fasting_sessions` (partition key: `/userId`)
* Product-agnostic: every document includes `productId`.
*/
import { z } from 'zod';
// ── Enums / constants ──
export const SESSION_STATUSES = ['active', 'paused', 'completed', 'broken', 'abandoned'] as const;
export type SessionStatus = (typeof SESSION_STATUSES)[number];
export const BODY_STAGES = [
'fed',
'early_fast',
'fasted',
'ketosis',
'deep_autophagy',
'extended',
] as const;
export type BodyStage = (typeof BODY_STAGES)[number];
export const MEAL_TYPES = ['break_fast', 'regular', 'last_before_fast'] as const;
export type MealType = (typeof MEAL_TYPES)[number];
export const RATING_SCALE = [1, 2, 3, 4, 5] as const;
// ── Sub-document interfaces ──
export interface MoodCheckin {
timestamp: number;
energy: 1 | 2 | 3 | 4 | 5;
mood: 1 | 2 | 3 | 4 | 5;
hunger: 1 | 2 | 3 | 4 | 5;
focus?: 1 | 2 | 3 | 4 | 5;
notes?: string;
}
export interface StageTransition {
stage: BodyStage;
enteredAt: number;
autophagyConfidence: number;
}
export interface MealLog {
id: string;
timestamp: number;
photoUrl?: string;
description: string;
estimatedCalories?: number;
macros?: { carbs: number; protein: number; fat: number };
mealType: MealType;
}
export interface FastMetrics {
actualDurationMs: number;
completionRatio: number;
peakAutophagyConfidence: number;
totalPausedMs: number;
moodCheckinCount: number;
averageEnergy: number | null;
averageMood: number | null;
}
// ── Main document ──
export interface FastingSessionDoc {
id: string;
userId: string;
productId: string;
protocolId: string;
startedAt: number;
targetDurationMs: number;
endedAt?: number;
status: SessionStatus;
pausedAt?: number;
totalPausedMs: number;
stages: StageTransition[];
moodCheckins: MoodCheckin[];
waterIntake: number;
notes: string;
breakFastMeal?: MealLog;
lastMealBeforeFast?: MealLog;
metrics: FastMetrics;
createdAt: string;
updatedAt: string;
}
// ── Zod schemas ──
const MoodCheckinSchema = z.object({
timestamp: z.number().int().positive(),
energy: z.number().int().min(1).max(5) as z.ZodType<1 | 2 | 3 | 4 | 5>,
mood: z.number().int().min(1).max(5) as z.ZodType<1 | 2 | 3 | 4 | 5>,
hunger: z.number().int().min(1).max(5) as z.ZodType<1 | 2 | 3 | 4 | 5>,
focus: (z.number().int().min(1).max(5) as z.ZodType<1 | 2 | 3 | 4 | 5>).optional(),
notes: z.string().max(1000).optional(),
});
const StageTransitionSchema = z.object({
stage: z.enum(BODY_STAGES),
enteredAt: z.number().int().positive(),
autophagyConfidence: z.number().min(0).max(100),
});
const MacrosSchema = z.object({
carbs: z.number().min(0),
protein: z.number().min(0),
fat: z.number().min(0),
});
const MealLogSchema = z.object({
id: z.string().min(1),
timestamp: z.number().int().positive(),
photoUrl: z.string().url().optional(),
description: z.string().max(2000),
estimatedCalories: z.number().min(0).optional(),
macros: MacrosSchema.optional(),
mealType: z.enum(MEAL_TYPES),
});
export const CreateFastingSessionSchema = z.object({
protocolId: z.string().min(1).max(128),
startedAt: z.number().int().positive(),
targetDurationMs: z.number().int().positive(),
status: z.enum(SESSION_STATUSES).default('active'),
stages: z.array(StageTransitionSchema).default([]),
moodCheckins: z.array(MoodCheckinSchema).default([]),
waterIntake: z.number().int().min(0).default(0),
notes: z.string().max(5000).default(''),
lastMealBeforeFast: MealLogSchema.optional(),
});
export const UpdateFastingSessionSchema = z.object({
status: z.enum(SESSION_STATUSES).optional(),
endedAt: z.number().int().positive().optional(),
pausedAt: z.number().int().positive().optional(),
totalPausedMs: z.number().int().min(0).optional(),
stages: z.array(StageTransitionSchema).optional(),
moodCheckins: z.array(MoodCheckinSchema).optional(),
waterIntake: z.number().int().min(0).optional(),
notes: z.string().max(5000).optional(),
breakFastMeal: MealLogSchema.optional(),
metrics: z
.object({
actualDurationMs: z.number().int().min(0),
completionRatio: z.number().min(0).max(1),
peakAutophagyConfidence: z.number().min(0).max(100),
totalPausedMs: z.number().int().min(0),
moodCheckinCount: z.number().int().min(0),
averageEnergy: z.number().min(0).max(5).nullable(),
averageMood: z.number().min(0).max(5).nullable(),
})
.optional(),
});
export const FastingSessionQuerySchema = z.object({
startDate: z.coerce.number().int().positive().optional(),
endDate: z.coerce.number().int().positive().optional(),
status: z.enum(SESSION_STATUSES).optional(),
protocolId: z.string().optional(),
sortBy: z.enum(['startedAt', 'endedAt', 'createdAt']).default('startedAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
// ── Inferred types ──
export type CreateFastingSessionInput = z.infer<typeof CreateFastingSessionSchema>;
export type UpdateFastingSessionInput = z.infer<typeof UpdateFastingSessionSchema>;
export type FastingSessionQuery = z.infer<typeof FastingSessionQuerySchema>;
// ── Stats interfaces ──
export interface UserFastingStats {
userId: string;
totalFasts: number;
totalHours: number;
averageDurationHours: number;
completionRate: number;
currentStreak: number;
longestStreak: number;
totalCompletedFasts: number;
}
export interface WeeklyFastingStats {
userId: string;
weekStart: string;
weekEnd: string;
fastsStarted: number;
fastsCompleted: number;
totalHours: number;
averageDurationHours: number;
longestFastHours: number;
completionRate: number;
}

View File

@ -47,6 +47,9 @@ import { tokenRoutes } from './modules/tokens/routes.js';
import { themeRoutes } from './modules/themes/routes.js';
import { waitlistRoutes } from './modules/waitlist/routes.js';
import { telemetryRoutes } from './modules/telemetry/routes.js';
import { fastingSessionRoutes } from './modules/fasting-sessions/routes.js';
import { fastingProtocolRoutes } from './modules/fasting-protocols/routes.js';
import { bodyStageRoutes } from './modules/body-stages/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
@ -121,5 +124,9 @@ await app.register(waitlistRoutes, { prefix: '/api' });
await app.register(telemetryRoutes, { prefix: '/api' });
// Public routes — no auth, registered at top level
await app.register(publicRoutes, { prefix: '/api' });
// NomGap fasting modules
await app.register(fastingSessionRoutes, { prefix: '/api' });
await app.register(fastingProtocolRoutes, { prefix: '/api' });
await app.register(bodyStageRoutes, { prefix: '/api' });
await startService(app, { port: config.PORT, host: config.HOST });