Repository navigation
Expand file tree
/
Copy pathtdeeAlgorithm.ts
More file actions
341 lines (303 loc) · 12.7 KB
/
Copy pathtdeeAlgorithm.ts
File metadata and controls
341 lines (303 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
/**
* CODE SAMPLE — the adaptive calorie-target algorithm described in the
* README's "A technical challenge" section: the regression fit plus the
* guardrails around it.
*
* Smart TDEE, Stoke's core differentiator.
*
* Most trackers nudge calories weekly off noisy scale data. Stoke fits a
* LINEAR REGRESSION trend line through the last 4 weeks of weigh-ins (robust
* to noise and missing days) and only suggests a small, safe adjustment when
* the trend has been *consistently* off-target. It never auto-applies, the
* user approves.
*
* Guardrails:
* - need ≥ 10 weigh-ins in the 28-day window (enough signal)
* - the deviation must hold over BOTH the 4-week and the most-recent-2-week
* trend (not one week of noise)
* - at most ±100 kcal per adjustment, and at most one adjustment per 2 weeks
* - never below the safety floor (1200 kcal female / 1400 kcal otherwise)
*
* All reads/writes are scoped with `.eq('user_id', …)` and protected by
* owner-only RLS (see migration 0004).
*/
import { defaultGoalRate } from '@/constants/profile';
import { supabase } from '@/lib/supabase';
import { Profile, TdeeAdjustment } from '@/types/database';
import { addDays, todayISO } from '@/utils/date';
import { KCAL_PER_KG, macrosForCalories } from '@/utils/nutrition';
const WINDOW_DAYS = 28;
const RECENT_DAYS = 14;
const MIN_ENTRIES = 10;
/** A weekly trend within ±this of the goal counts as on-target (kg/week). */
const SLOPE_TOLERANCE = 0.15;
const MAX_ADJUST_KCAL = 100;
const MIN_DAYS_BETWEEN_ADJUSTMENTS = 14;
const FEMALE_FLOOR = 1200;
const DEFAULT_FLOOR = 1400; // male / other
export interface WeightSample {
iso: string; // YYYY-MM-DD
kg: number;
}
export type TdeeStatus = 'insufficient_data' | 'on_track' | 'too_slow' | 'too_fast';
export interface TdeeRecommendation {
oldTarget: number;
newTarget: number;
/** Signed kcal delta (negative = reduce). */
deltaKcal: number;
reason: string;
slopeKgPerWeek: number;
}
export interface TdeeInsight {
status: TdeeStatus;
/** Weigh-ins inside the 28-day window. */
entryCount: number;
/** Fitted 4-week trend (kg/week), or null when < 2 points. */
slopeKgPerWeek: number | null;
/** Target weekly rate from the user's goal (kg/week). */
expectedSlopeKgPerWeek: number;
lastAdjustment: TdeeAdjustment | null;
/** Present only when a safe, consistent, due adjustment is warranted. */
recommendation: TdeeRecommendation | null;
}
// ── Pure math ────────────────────────────────────────────────────────────────
/** Least-squares slope of y over x. Null if < 2 points or no x-variance. */
export function linearRegressionSlope(points: { x: number; y: number }[]): number | null {
const n = points.length;
if (n < 2) return null;
const meanX = points.reduce((s, p) => s + p.x, 0) / n;
const meanY = points.reduce((s, p) => s + p.y, 0) / n;
let num = 0;
let den = 0;
for (const p of points) {
const dx = p.x - meanX;
num += dx * (p.y - meanY);
den += dx * dx;
}
if (den === 0) return null;
return num / den;
}
/** Weight-trend slope in kg/WEEK from dated samples (x measured in days). */
export function weeklySlope(samples: WeightSample[]): number | null {
if (samples.length < 2) return null;
const t0 = Date.parse(samples[0].iso);
const points = samples.map((s) => ({ x: (Date.parse(s.iso) - t0) / 86_400_000, y: s.kg }));
const perDay = linearRegressionSlope(points);
return perDay == null ? null : perDay * 7;
}
const sign = (x: number): number => (x > 0 ? 1 : x < 0 ? -1 : 0);
function classify(goal: Profile['goal'], deviation: number): Exclude<TdeeStatus, 'insufficient_data'> {
if (Math.abs(deviation) <= SLOPE_TOLERANCE) return 'on_track';
// deviation > 0 ⇒ actual slope above expected (gaining more / losing less).
if (goal === 'lose') return deviation > 0 ? 'too_slow' : 'too_fast';
return deviation > 0 ? 'too_fast' : 'too_slow'; // gain / maintain
}
function buildReason(goal: Profile['goal'], deviation: number, deltaKcal: number): string {
const amt = Math.abs(deltaKcal);
const dir = deltaKcal < 0 ? `reducing by ${amt} kcal` : `adding ${amt} kcal`;
if (goal === 'lose') {
return deviation > 0
? `Your trend suggests you're losing slower than your goal. Consider ${dir}.`
: `You're losing faster than your goal, ${dir} keeps it sustainable.`;
}
if (goal === 'gain') {
return deviation > 0
? `You're gaining faster than your goal. Consider ${dir}.`
: `You're gaining slower than your goal. Consider ${dir}.`;
}
return deviation > 0
? `Your weight is trending up. Consider ${dir} to hold steady.`
: `Your weight is trending down. Consider ${dir} to hold steady.`;
}
function floorFor(sex: Profile['sex']): number {
return sex === 'female' ? FEMALE_FLOOR : DEFAULT_FLOOR;
}
function daysSince(iso: string, now: Date): number {
return (now.getTime() - Date.parse(iso)) / 86_400_000;
}
/**
* Pure analysis: given the window's weigh-ins, the profile and the last
* adjustment, decide the trend status and whether to recommend a change.
*/
export function computeInsight(input: {
weights: WeightSample[];
profile: Profile;
lastAdjustment: TdeeAdjustment | null;
now: Date;
}): TdeeInsight {
const { profile, lastAdjustment, now } = input;
const windowStart = addDays(todayISO(), -(WINDOW_DAYS - 1));
const recentStart = addDays(todayISO(), -(RECENT_DAYS - 1));
const samples = input.weights
.filter((s) => s.iso >= windowStart)
.sort((a, b) => (a.iso < b.iso ? -1 : 1));
const recent = samples.filter((s) => s.iso >= recentStart);
const expectedSlopeKgPerWeek = profile.goal_rate_kg_per_week ?? defaultGoalRate(profile.goal ?? 'maintain');
const slopeKgPerWeek = weeklySlope(samples);
const base: TdeeInsight = {
status: 'insufficient_data',
entryCount: samples.length,
slopeKgPerWeek,
expectedSlopeKgPerWeek,
lastAdjustment,
recommendation: null,
};
// Gate 1: enough data + a fitted slope.
if (samples.length < MIN_ENTRIES || slopeKgPerWeek == null) return base;
const deviation = slopeKgPerWeek - expectedSlopeKgPerWeek;
const status = classify(profile.goal, deviation);
const insight: TdeeInsight = { ...base, status };
if (status === 'on_track') return insight;
// Gate 2: the 2-week trend must agree in direction (not one week of noise).
const recentSlope = recent.length >= 4 ? weeklySlope(recent) : null;
if (recentSlope == null) return insight;
const recentDeviation = recentSlope - expectedSlopeKgPerWeek;
if (sign(recentDeviation) !== sign(deviation)) return insight;
// Gate 3: at most one adjustment per 2 weeks.
if (lastAdjustment && daysSince(lastAdjustment.adjusted_at, now) < MIN_DAYS_BETWEEN_ADJUSTMENTS) {
return insight;
}
// Gate 4: a target to adjust + a safe, non-zero change.
const oldTarget = profile.calorie_target ?? 0;
if (oldTarget <= 0) return insight;
// Daily kcal change needed to move the weekly trend by `deviation`, capped.
const rawKcal = (deviation * KCAL_PER_KG) / 7;
let deltaKcal = Math.round(Math.max(-MAX_ADJUST_KCAL, Math.min(MAX_ADJUST_KCAL, -rawKcal)) / 10) * 10;
const floor = floorFor(profile.sex);
let newTarget = oldTarget + deltaKcal;
if (newTarget < floor) {
newTarget = floor;
deltaKcal = newTarget - oldTarget; // smaller (or zero) reduction at the floor
}
if (deltaKcal === 0) return insight; // already at the safety floor, nothing safe to do
return {
...insight,
recommendation: {
oldTarget,
newTarget,
deltaKcal,
slopeKgPerWeek,
reason: buildReason(profile.goal, deviation, deltaKcal),
},
};
}
// ── Adaptive expenditure estimate (Dashboard hero row + Trends card) ─────────
export interface DailyIntake {
iso: string; // YYYY-MM-DD
calories: number;
}
/**
* Estimated daily expenditure (adaptive TDEE) for the 28-day window ending at
* `endIso`: average logged intake minus the daily energy imbalance implied by
* the fitted weight trend. Null until there is enough signal on BOTH sides
* (>= MIN_ENTRIES weigh-ins and >= MIN_ENTRIES logged days in the window).
*/
export function estimateExpenditure(
weights: WeightSample[],
intake: DailyIntake[],
endIso: string,
): number | null {
const startIso = addDays(endIso, -(WINDOW_DAYS - 1));
const w = weights.filter((s) => s.iso >= startIso && s.iso <= endIso).sort((a, b) => (a.iso < b.iso ? -1 : 1));
const days = intake.filter((d) => d.iso >= startIso && d.iso <= endIso && d.calories > 0);
if (w.length < MIN_ENTRIES || days.length < MIN_ENTRIES) return null;
const slope = weeklySlope(w);
if (slope == null) return null;
const avgIntake = days.reduce((s, d) => s + d.calories, 0) / days.length;
return Math.round(avgIntake - (slope * KCAL_PER_KG) / 7);
}
export interface ExpenditureEstimate {
kcalPerDay: number;
/** Change vs the estimate one week earlier; null when last week lacks data. */
deltaVsLastWeek: number | null;
/** Weekly estimates, oldest to newest (current included), for sparklines. */
series: number[];
}
const EXPENDITURE_POINTS = 6;
/** Fetch weights + daily intake once and derive the rolling weekly estimates. */
export async function getExpenditureEstimate(userId: string): Promise<ExpenditureEstimate | null> {
const spanDays = WINDOW_DAYS + (EXPENDITURE_POINTS - 1) * 7;
const sinceIso = addDays(todayISO(), -(spanDays - 1));
const [weights, intake] = await Promise.all([
getWeightSamplesSince(userId, sinceIso),
getDailyIntakeSince(userId, sinceIso),
]);
const current = estimateExpenditure(weights, intake, todayISO());
if (current == null) return null;
const prev = estimateExpenditure(weights, intake, addDays(todayISO(), -7));
const series: number[] = [];
for (let k = EXPENDITURE_POINTS - 1; k >= 0; k--) {
const est = estimateExpenditure(weights, intake, addDays(todayISO(), -7 * k));
if (est != null) series.push(est);
}
return { kcalPerDay: current, deltaVsLastWeek: prev == null ? null : current - prev, series };
}
// ── Data access ──────────────────────────────────────────────────────────────
async function getDailyIntakeSince(userId: string, sinceIso: string): Promise<DailyIntake[]> {
const { data } = await supabase
.from('food_logs')
.select('date, calories')
.eq('user_id', userId)
.gte('date', sinceIso);
const byDay = new Map<string, number>();
for (const r of data ?? []) byDay.set(r.date, (byDay.get(r.date) ?? 0) + Number(r.calories));
return [...byDay.entries()].map(([iso, calories]) => ({ iso, calories }));
}
async function getWeightSamplesSince(userId: string, sinceIso: string): Promise<WeightSample[]> {
const { data } = await supabase
.from('weight_logs')
.select('date, weight_kg')
.eq('user_id', userId)
.gte('date', sinceIso)
.order('date', { ascending: true });
return (data ?? []).map((r) => ({ iso: r.date, kg: Number(r.weight_kg) }));
}
/** The user's most recent approved adjustment (for the cooldown + UI). */
export async function getLastAdjustment(userId: string): Promise<TdeeAdjustment | null> {
const { data } = await supabase
.from('tdee_adjustments')
.select('*')
.eq('user_id', userId)
.order('adjusted_at', { ascending: false })
.limit(1)
.maybeSingle();
return data ?? null;
}
/** Fetch the window + last adjustment and run the analysis. */
export async function getTdeeInsight(userId: string, profile: Profile): Promise<TdeeInsight> {
const sinceIso = addDays(todayISO(), -(WINDOW_DAYS - 1));
const [weights, lastAdjustment] = await Promise.all([
getWeightSamplesSince(userId, sinceIso),
getLastAdjustment(userId),
]);
return computeInsight({ weights, profile, lastAdjustment, now: new Date() });
}
/**
* Apply an approved recommendation: append the audit row, then update the
* profile's calorie target + macro split (kept consistent via macrosForCalories).
*/
export async function applyTdeeAdjustment(
userId: string,
profile: Profile,
rec: TdeeRecommendation,
): Promise<void> {
const { error: insErr } = await supabase.from('tdee_adjustments').insert({
user_id: userId,
old_target: rec.oldTarget,
new_target: rec.newTarget,
trend_slope: Math.round(rec.slopeKgPerWeek * 1000) / 1000,
reason: rec.reason,
});
if (insErr) throw insErr;
const macros = macrosForCalories(rec.newTarget, profile.weight_kg ?? 0);
const { error: upErr } = await supabase
.from('profiles')
.update({
calorie_target: rec.newTarget,
protein_target_g: macros.proteinTargetG,
carb_target_g: macros.carbTargetG,
fat_target_g: macros.fatTargetG,
})
.eq('id', userId);
if (upErr) throw upErr;
}