Fix: ernährungsfachliche Auflagen aus der Abnahme umgesetzt
Some checks are pending
CI / Lint, Typecheck, Test, Build (push) Waiting to run
Some checks are pending
CI / Lint, Typecheck, Test, Build (push) Waiting to run
Nachbesserungen aus der fachlichen Begutachtung (Skill ernaehrungswissenschaftler): - Kalorien-Untergrenze von 800 auf 1.200 kcal angehoben (goalsInput und upsertWeeklyCaloriePlan, neue Konstante MIN_CALORIE_GOAL). Im Ziele-Tab Sicherheitswarnung bei einem Ziel unter 1.500 kcal. - Defizit-Banner wird nur noch bei moderatem Defizit (10-25 % unter dem Basis-Tagesziel) gefeiert und gegen baseDailyGoal statt inkl. Sportbonus gerechnet. Bei einem Defizit über 25 % erscheint stattdessen ein sanfter "iss genug"-Hinweis statt Lob. - Wochenbudget-Verteilung: der am 1.200-Floor gekappte Rest wird iterativ auf die übrigen Tage weiterverteilt, sodass das Wochenbudget erhalten bleibt, solange es geht. Neue Funktion weeklyExtrasOverflow meldet den unvermeidbaren Überschuss; die Wochenbudget-Seite weist ihn ehrlich aus statt pauschal "bleibt gleich" zu versprechen. - Dashboard-Makros: Kohlenhydrate und Fett als geschätzt (~) gekennzeichnet inkl. erklärendem Hinweis; Protein bleibt real getrackt. - 2 neue API-Tests (iterative Floor-Verteilung, weeklyExtrasOverflow). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
70f59cfd40
commit
7488916a9e
6 changed files with 160 additions and 26 deletions
|
|
@ -693,6 +693,16 @@ export default function EinstellungenPage() {
|
|||
</label>
|
||||
))}
|
||||
</div>
|
||||
{goalsForm.calories < 1500 ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-800">
|
||||
<span className="text-base" aria-hidden>⚠️</span>
|
||||
<span>
|
||||
Ein Tagesziel unter 1.500 kcal liegt am unteren Rand des Grundbedarfs.
|
||||
Sehr niedrige Ziele bitte nur in ärztlicher Absprache wählen. Das Minimum
|
||||
beträgt 1.200 kcal.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="flex items-center justify-between gap-3 rounded-lg bg-gray-50 px-4 py-3 text-sm font-medium text-gray-700">
|
||||
Sportkalorien erhöhen das Tagesbudget
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -388,8 +388,16 @@ export default function DashboardPage() {
|
|||
|
||||
// Belohnende Hinweise: nur fuer vergangene/heutige Tage und nur wenn schon
|
||||
// etwas getrackt wurde, damit "Defizit" am leeren Morgen nicht falsch jubelt.
|
||||
const calorieDeficit = dailyCalorieGoal - totals.calories;
|
||||
const showDeficitPraise = !isFuture && totals.calories > 0 && calorieDeficit > 0;
|
||||
// Defizit gegen das Basis-Tagesziel rechnen (ohne Sportbonus), damit der Bonus
|
||||
// den "Erfolg" nicht kuenstlich aufblaeht.
|
||||
const calorieDeficit = baseDailyGoal - totals.calories;
|
||||
const deficitRatio = baseDailyGoal > 0 ? calorieDeficit / baseDailyGoal : 0;
|
||||
const hasTrackedFood = !isFuture && totals.calories > 0;
|
||||
// Nur ein moderates Defizit (10-25 % unter Ziel) wird gefeiert. Daraus folgt
|
||||
// gesundes, nicht restriktives Verhalten.
|
||||
const showDeficitPraise = hasTrackedFood && deficitRatio >= 0.1 && deficitRatio <= 0.25;
|
||||
// Zu starkes Defizit (> 25 % unter Ziel) wird NICHT bejubelt, sondern sanft gewarnt.
|
||||
const showUnderEatingWarning = hasTrackedFood && deficitRatio > 0.25;
|
||||
const showSportPraise = !isFuture && dailySportBonus > 0;
|
||||
|
||||
function addFoodEntry() {
|
||||
|
|
@ -518,7 +526,7 @@ export default function DashboardPage() {
|
|||
) : null}
|
||||
|
||||
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800">
|
||||
{showSportPraise || showDeficitPraise ? (
|
||||
{showSportPraise || showDeficitPraise || showUnderEatingWarning ? (
|
||||
<div className="mb-5 space-y-2">
|
||||
{showSportPraise ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">
|
||||
|
|
@ -530,11 +538,21 @@ export default function DashboardPage() {
|
|||
</div>
|
||||
) : null}
|
||||
{showDeficitPraise ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-amber-50 px-4 py-3 text-sm font-bold text-amber-800">
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">
|
||||
<span className="text-xl" aria-hidden>🎉</span>
|
||||
<span>
|
||||
{calorieDeficit.toLocaleString("de-DE")} kcal im Plus — du liegst im
|
||||
Defizit. Gute Arbeit für deine Ziele!
|
||||
{calorieDeficit.toLocaleString("de-DE")} kcal unter dem Ziel — ein gesundes,
|
||||
moderates Defizit. Gute Arbeit für deine Ziele!
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{showUnderEatingWarning ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-amber-50 px-4 py-3 text-sm font-bold text-amber-800">
|
||||
<span className="text-xl" aria-hidden>💛</span>
|
||||
<span>
|
||||
Du liegst {calorieDeficit.toLocaleString("de-DE")} kcal unter deinem Ziel –
|
||||
das ist ein recht großes Defizit. Achte darauf, genug zu essen; dauerhaft zu
|
||||
wenig schadet mehr, als es hilft.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -572,17 +590,20 @@ export default function DashboardPage() {
|
|||
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-400">Kohlenhydr.</p>
|
||||
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">{totals.carbs}<span className="text-gray-300">/{goals.carbs} g</span></p>
|
||||
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">~{totals.carbs}<span className="text-gray-300">/{goals.carbs} g</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-400">Fett</p>
|
||||
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">{totals.fat}<span className="text-gray-300">/{goals.fat} g</span></p>
|
||||
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">~{totals.fat}<span className="text-gray-300">/{goals.fat} g</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-400">Proteine</p>
|
||||
<p className="mt-1 text-2xl font-black text-gray-950 dark:text-neutral-100">{Math.round(totals.protein)}<span className="text-gray-300">/{goals.protein} g</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-semibold text-gray-400">
|
||||
Kohlenhydrate und Fett sind aus den Kalorien geschätzt (~). Protein wird aus deinen Einträgen berechnet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400">
|
||||
|
|
|
|||
|
|
@ -58,6 +58,14 @@ export default function WochenbudgetPage() {
|
|||
[drafts],
|
||||
);
|
||||
|
||||
// Wenn der 1.200-kcal-Floor griff, liegt die Summe der gespeicherten Tagesziele ueber
|
||||
// dem Wochenziel. Diesen unvermeidbaren Ueberschuss ehrlich ausweisen.
|
||||
const weeklyOverflow = useMemo(() => {
|
||||
if (!summary) return 0;
|
||||
const planned = summary.days.reduce((sum, day) => sum + day.plannedCalories, 0);
|
||||
return Math.max(0, planned - summary.baseBudget);
|
||||
}, [summary]);
|
||||
|
||||
function updateDraft(date: string, patch: Partial<ExtraDraft>) {
|
||||
setDrafts((current) => ({ ...current, [date]: { ...current[date], ...patch } as ExtraDraft }));
|
||||
}
|
||||
|
|
@ -94,7 +102,7 @@ export default function WochenbudgetPage() {
|
|||
<p className="mt-3 max-w-2xl text-sm font-semibold text-gray-400">
|
||||
Trag pro Tag ein, wenn du mehr brauchst (z. B. Familienfeier +800). Mit
|
||||
„kcal neu verteilen" hebt Hellth Hub diese Tage an und gleicht den Rest der Woche
|
||||
gleichmäßig aus. Dein Wochenziel bleibt gleich.
|
||||
gleichmäßig aus – solange kein Tag unter 1.200 kcal fällt, bleibt dein Wochenziel gleich.
|
||||
</p>
|
||||
</div>
|
||||
<input type="date" value={selectedDate} onChange={(event) => setSelectedDate(event.target.value || todayKey())} className="rounded-full border border-gray-200 px-4 py-3 text-sm font-bold" />
|
||||
|
|
@ -133,6 +141,13 @@ export default function WochenbudgetPage() {
|
|||
? `${fmt(totalExtra)} kcal Mehrbedarf eingeplant – wird beim Verteilen auf die übrigen Tage ausgeglichen.`
|
||||
: "Trag bei einzelnen Tagen einen Mehrbedarf ein und verteile anschließend."}
|
||||
</p>
|
||||
{weeklyOverflow > 0 ? (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-sm font-semibold text-amber-700">
|
||||
<span aria-hidden>ⓘ</span>
|
||||
Der Ausgleich stößt an die 1.200-kcal-Untergrenze: Dein Wochenbudget liegt diese
|
||||
Woche um {fmt(weeklyOverflow)} kcal höher, damit kein Tag zu niedrig wird.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { appRouter } from "./index";
|
||||
import { distributeWeeklyExtras } from "./routers/health/_shared";
|
||||
import { distributeWeeklyExtras, weeklyExtrasOverflow } from "./routers/health/_shared";
|
||||
|
||||
type Call = { op: string; payload?: unknown };
|
||||
|
||||
|
|
@ -812,11 +812,49 @@ test("distributeWeeklyExtras unterschreitet den Floor nicht", async () => {
|
|||
1200,
|
||||
);
|
||||
assert.equal(planned["2026-05-31"], 6000);
|
||||
// 4000/2 = 2000 Abzug -> 2000-2000=0, aber Floor greift bei 1200
|
||||
// Beide Ausgleichstage landen am Floor (mehr Ausgleich ist nicht moeglich).
|
||||
assert.equal(planned["2026-05-25"], 1200);
|
||||
assert.equal(planned["2026-05-26"], 1200);
|
||||
});
|
||||
|
||||
test("distributeWeeklyExtras verteilt den am Floor gekappten Rest auf die anderen Tage", async () => {
|
||||
// 1500 Extra auf 3 Ausgleichstage: gleichmaessig waeren das 500/Tag -> 1500 je Tag,
|
||||
// einer faellt aber knapp unter Floor. Der Rest muss auf die uebrigen wandern, sodass
|
||||
// das Wochenbudget (4 Tage * 2000 = 8000) exakt erhalten bleibt.
|
||||
const planned = distributeWeeklyExtras(
|
||||
{
|
||||
"2026-05-25": 0,
|
||||
"2026-05-26": 0,
|
||||
"2026-05-27": 0,
|
||||
"2026-05-31": 1500,
|
||||
},
|
||||
2000,
|
||||
1200,
|
||||
);
|
||||
assert.equal(planned["2026-05-31"], 3500);
|
||||
const total = Object.values(planned).reduce((sum, value) => sum + value, 0);
|
||||
// Wochenbudget bleibt erhalten (kein Tag musste unter den Floor).
|
||||
assert.ok(total <= 8000, `Summe ${total} darf 8000 nicht ueberschreiten`);
|
||||
assert.ok(total >= 8000 - 4, "nur minimale Rundung erlaubt");
|
||||
// Kein Ausgleichstag unter dem Floor.
|
||||
for (const date of ["2026-05-25", "2026-05-26", "2026-05-27"]) {
|
||||
assert.ok(planned[date]! >= 1200, `${date} darf nicht unter den Floor fallen`);
|
||||
}
|
||||
});
|
||||
|
||||
test("weeklyExtrasOverflow meldet nur den unvermeidbaren Ueberschuss", async () => {
|
||||
// Ausgleichbar -> kein Overflow.
|
||||
assert.equal(
|
||||
weeklyExtrasOverflow({ "a": 0, "b": 0, "c": 0, "d": 1500 }, 2000, 1200),
|
||||
0,
|
||||
);
|
||||
// Nicht mehr ausgleichbar (beide Ausgleichstage am Floor): 6000+1200+1200=8400 vs 6000 -> 2400.
|
||||
assert.equal(
|
||||
weeklyExtrasOverflow({ "a": 0, "b": 0, "c": 4000 }, 2000, 1200),
|
||||
2400,
|
||||
);
|
||||
});
|
||||
|
||||
test("health.setWeeklyExtras verteilt und speichert plannedCalories pro Tag", async () => {
|
||||
const { db, calls } = createHealthDbMock();
|
||||
const caller = appRouter.createCaller(createContext({ db }) as never);
|
||||
|
|
|
|||
|
|
@ -142,9 +142,16 @@ export const recipeSubmissionReviewInput = z.object({
|
|||
reviewNote: z.string().max(1000).optional().nullable(),
|
||||
});
|
||||
|
||||
// Fachliche Untergrenze: 1200 kcal liegt am unteren Rand des Grundumsatzes
|
||||
// Erwachsener. Darunter nur unter aerztlicher Begleitung vertretbar, daher hier
|
||||
// als harte Grenze. Werte zwischen 1200 und WARN_CALORIE_FLOOR loesen im UI eine
|
||||
// Sicherheitswarnung aus.
|
||||
export const MIN_CALORIE_GOAL = 1200;
|
||||
export const WARN_CALORIE_FLOOR = 1500;
|
||||
|
||||
export const goalsInput = z.object({
|
||||
drinkMl: z.number().int().min(0).max(10000),
|
||||
calories: z.number().int().min(800).max(6000),
|
||||
calories: z.number().int().min(MIN_CALORIE_GOAL).max(6000),
|
||||
carbsG: z.number().int().min(0).max(800),
|
||||
fatG: z.number().int().min(0).max(400),
|
||||
proteinG: z.number().int().min(0).max(400),
|
||||
|
|
@ -250,10 +257,15 @@ export function numberString(value: number | string | null | undefined, fallback
|
|||
}
|
||||
|
||||
/**
|
||||
* Verteilt eingeplante Mehrkalorien ("Blocker", z. B. Familienfeier) gleichmaessig
|
||||
* auf die uebrigen Wochentage, sodass das Wochenbudget (dailyGoal * 7) exakt erhalten
|
||||
* bleibt. Tage mit Extra erhalten dailyGoal + extra; Tage ohne Extra werden anteilig
|
||||
* reduziert, aber nie unter floor (Schutz vor zu niedrigen Tageszielen).
|
||||
* Verteilt eingeplante Mehrkalorien ("Blocker", z. B. Familienfeier) auf die uebrigen
|
||||
* Wochentage. Ziel: das Wochenbudget (dailyGoal * 7) bleibt erhalten. Tage mit Extra
|
||||
* erhalten dailyGoal + extra; Tage ohne Extra tragen den Ausgleich.
|
||||
*
|
||||
* Kein Ausgleichstag faellt unter floor (Schutz vor Hungertagen). Wuerde die
|
||||
* gleichmaessige Reduktion einen Tag unter den floor druecken, wird der dort nicht
|
||||
* abziehbare Rest iterativ auf die noch nicht am floor liegenden Tage weiterverteilt.
|
||||
* Erst wenn ALLE Ausgleichstage am floor liegen, laesst sich der Rest nicht mehr
|
||||
* kompensieren – dann steigt das Wochenbudget unvermeidbar (siehe wochenbudgetOverflow).
|
||||
*
|
||||
* Gibt fuer jeden uebergebenen Tag den errechneten plannedCalories-Wert zurueck.
|
||||
*/
|
||||
|
|
@ -263,22 +275,59 @@ export function distributeWeeklyExtras(
|
|||
floor = 1200,
|
||||
): Record<string, number> {
|
||||
const dates = Object.keys(extrasByDate);
|
||||
const totalExtra = dates.reduce((sum, date) => sum + Math.max(0, extrasByDate[date] ?? 0), 0);
|
||||
const daysWithoutExtra = dates.filter((date) => (extrasByDate[date] ?? 0) <= 0);
|
||||
const reductionPerDay = daysWithoutExtra.length
|
||||
? Math.ceil(totalExtra / daysWithoutExtra.length)
|
||||
: 0;
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
// Blocker-Tage fix setzen; Ausgleichstage starten beim Tagesziel.
|
||||
const adjustableDates: string[] = [];
|
||||
for (const date of dates) {
|
||||
const extra = Math.max(0, extrasByDate[date] ?? 0);
|
||||
result[date] = extra > 0
|
||||
? dailyGoal + extra
|
||||
: Math.max(floor, dailyGoal - reductionPerDay);
|
||||
if (extra > 0) {
|
||||
result[date] = dailyGoal + extra;
|
||||
} else {
|
||||
result[date] = dailyGoal;
|
||||
adjustableDates.push(date);
|
||||
}
|
||||
}
|
||||
|
||||
let remainingToReduce = dates.reduce((sum, date) => sum + Math.max(0, extrasByDate[date] ?? 0), 0);
|
||||
|
||||
// Iterativ verteilen: in jeder Runde gleichmaessig auf die Tage, die noch ueber dem
|
||||
// floor liegen. Am floor angekommene Tage scheiden aus, ihr Rest geht an die anderen.
|
||||
while (remainingToReduce > 0) {
|
||||
const open = adjustableDates.filter((date) => result[date]! > floor);
|
||||
if (open.length === 0) break; // alle am floor -> Rest nicht mehr ausgleichbar
|
||||
const perDay = Math.ceil(remainingToReduce / open.length);
|
||||
let reducedThisRound = 0;
|
||||
for (const date of open) {
|
||||
const before = result[date]!;
|
||||
const after = Math.max(floor, before - perDay);
|
||||
reducedThisRound += before - after;
|
||||
result[date] = after;
|
||||
}
|
||||
if (reducedThisRound === 0) break;
|
||||
remainingToReduce -= reducedThisRound;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restbetrag, der sich beim Verteilen NICHT mehr ausgleichen liess, weil alle
|
||||
* Ausgleichstage am floor angekommen sind. > 0 bedeutet: das Wochenbudget steigt um
|
||||
* diesen Wert. Dient der ehrlichen Kommunikation im UI.
|
||||
*/
|
||||
export function weeklyExtrasOverflow(
|
||||
extrasByDate: Record<string, number>,
|
||||
dailyGoal: number,
|
||||
floor = 1200,
|
||||
): number {
|
||||
const planned = distributeWeeklyExtras(extrasByDate, dailyGoal, floor);
|
||||
const dates = Object.keys(extrasByDate);
|
||||
const target = dailyGoal * dates.length;
|
||||
const total = dates.reduce((sum, date) => sum + (planned[date] ?? 0), 0);
|
||||
return Math.max(0, total - target);
|
||||
}
|
||||
|
||||
export function startOfWeek(dateKey: string) {
|
||||
const date = new Date(`${dateKey}T12:00:00`);
|
||||
const day = date.getDay() === 0 ? 7 : date.getDay();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
distributeWeeklyExtras,
|
||||
id,
|
||||
mealTypeSchema,
|
||||
MIN_CALORIE_GOAL,
|
||||
numberString,
|
||||
startOfWeek,
|
||||
} from "./_shared";
|
||||
|
|
@ -283,7 +284,7 @@ export const trackingRouter = router({
|
|||
}),
|
||||
|
||||
upsertWeeklyCaloriePlan: protectedProcedure
|
||||
.input(z.object({ date: dateKeySchema, plannedCalories: z.number().int().min(800).max(6000), note: z.string().max(500).optional().nullable() }))
|
||||
.input(z.object({ date: dateKeySchema, plannedCalories: z.number().int().min(MIN_CALORIE_GOAL).max(6000), note: z.string().max(500).optional().nullable() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db
|
||||
.insert(schema.weeklyCaloriePlans)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue