Fix: ernährungsfachliche Auflagen aus der Abnahme umgesetzt
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:
Sebastian Mayer 2026-05-29 11:41:41 +02:00
parent 70f59cfd40
commit 7488916a9e
6 changed files with 160 additions and 26 deletions

View file

@ -693,6 +693,16 @@ export default function EinstellungenPage() {
</label> </label>
))} ))}
</div> </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"> <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 Sportkalorien erhöhen das Tagesbudget
<input <input

View file

@ -388,8 +388,16 @@ export default function DashboardPage() {
// Belohnende Hinweise: nur fuer vergangene/heutige Tage und nur wenn schon // Belohnende Hinweise: nur fuer vergangene/heutige Tage und nur wenn schon
// etwas getrackt wurde, damit "Defizit" am leeren Morgen nicht falsch jubelt. // etwas getrackt wurde, damit "Defizit" am leeren Morgen nicht falsch jubelt.
const calorieDeficit = dailyCalorieGoal - totals.calories; // Defizit gegen das Basis-Tagesziel rechnen (ohne Sportbonus), damit der Bonus
const showDeficitPraise = !isFuture && totals.calories > 0 && calorieDeficit > 0; // 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; const showSportPraise = !isFuture && dailySportBonus > 0;
function addFoodEntry() { function addFoodEntry() {
@ -518,7 +526,7 @@ export default function DashboardPage() {
) : null} ) : null}
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-neutral-900 dark:ring-neutral-800"> <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"> <div className="mb-5 space-y-2">
{showSportPraise ? ( {showSportPraise ? (
<div className="flex items-center gap-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800"> <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> </div>
) : null} ) : null}
{showDeficitPraise ? ( {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 className="text-xl" aria-hidden>🎉</span>
<span> <span>
{calorieDeficit.toLocaleString("de-DE")} kcal im Plus — du liegst im {calorieDeficit.toLocaleString("de-DE")} kcal unter dem Ziel — ein gesundes,
Defizit. Gute Arbeit für deine Ziele! 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> </span>
</div> </div>
) : null} ) : null}
@ -572,17 +590,20 @@ export default function DashboardPage() {
<div className="mt-5 grid grid-cols-3 gap-3"> <div className="mt-5 grid grid-cols-3 gap-3">
<div> <div>
<p className="text-sm font-bold text-gray-400">Kohlenhydr.</p> <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>
<div> <div>
<p className="text-sm font-bold text-gray-400">Fett</p> <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>
<div> <div>
<p className="text-sm font-bold text-gray-400">Proteine</p> <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> <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>
</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>
<div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400"> <div className="mt-8 flex items-center justify-between text-sm font-bold text-gray-400">

View file

@ -58,6 +58,14 @@ export default function WochenbudgetPage() {
[drafts], [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>) { function updateDraft(date: string, patch: Partial<ExtraDraft>) {
setDrafts((current) => ({ ...current, [date]: { ...current[date], ...patch } as 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"> <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 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 „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> </p>
</div> </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" /> <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.` ? `${fmt(totalExtra)} kcal Mehrbedarf eingeplant – wird beim Verteilen auf die übrigen Tage ausgeglichen.`
: "Trag bei einzelnen Tagen einen Mehrbedarf ein und verteile anschließend."} : "Trag bei einzelnen Tagen einen Mehrbedarf ein und verteile anschließend."}
</p> </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> </div>
<button <button
type="button" type="button"

View file

@ -1,7 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { appRouter } from "./index"; import { appRouter } from "./index";
import { distributeWeeklyExtras } from "./routers/health/_shared"; import { distributeWeeklyExtras, weeklyExtrasOverflow } from "./routers/health/_shared";
type Call = { op: string; payload?: unknown }; type Call = { op: string; payload?: unknown };
@ -812,11 +812,49 @@ test("distributeWeeklyExtras unterschreitet den Floor nicht", async () => {
1200, 1200,
); );
assert.equal(planned["2026-05-31"], 6000); 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-25"], 1200);
assert.equal(planned["2026-05-26"], 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 () => { test("health.setWeeklyExtras verteilt und speichert plannedCalories pro Tag", async () => {
const { db, calls } = createHealthDbMock(); const { db, calls } = createHealthDbMock();
const caller = appRouter.createCaller(createContext({ db }) as never); const caller = appRouter.createCaller(createContext({ db }) as never);

View file

@ -142,9 +142,16 @@ export const recipeSubmissionReviewInput = z.object({
reviewNote: z.string().max(1000).optional().nullable(), 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({ export const goalsInput = z.object({
drinkMl: z.number().int().min(0).max(10000), 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), carbsG: z.number().int().min(0).max(800),
fatG: z.number().int().min(0).max(400), fatG: z.number().int().min(0).max(400),
proteinG: 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 * Verteilt eingeplante Mehrkalorien ("Blocker", z. B. Familienfeier) auf die uebrigen
* auf die uebrigen Wochentage, sodass das Wochenbudget (dailyGoal * 7) exakt erhalten * Wochentage. Ziel: das Wochenbudget (dailyGoal * 7) bleibt erhalten. Tage mit Extra
* bleibt. Tage mit Extra erhalten dailyGoal + extra; Tage ohne Extra werden anteilig * erhalten dailyGoal + extra; Tage ohne Extra tragen den Ausgleich.
* reduziert, aber nie unter floor (Schutz vor zu niedrigen Tageszielen). *
* 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. * Gibt fuer jeden uebergebenen Tag den errechneten plannedCalories-Wert zurueck.
*/ */
@ -263,22 +275,59 @@ export function distributeWeeklyExtras(
floor = 1200, floor = 1200,
): Record<string, number> { ): Record<string, number> {
const dates = Object.keys(extrasByDate); 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> = {}; const result: Record<string, number> = {};
// Blocker-Tage fix setzen; Ausgleichstage starten beim Tagesziel.
const adjustableDates: string[] = [];
for (const date of dates) { for (const date of dates) {
const extra = Math.max(0, extrasByDate[date] ?? 0); const extra = Math.max(0, extrasByDate[date] ?? 0);
result[date] = extra > 0 if (extra > 0) {
? dailyGoal + extra result[date] = dailyGoal + extra;
: Math.max(floor, dailyGoal - reductionPerDay); } 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; 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) { export function startOfWeek(dateKey: string) {
const date = new Date(`${dateKey}T12:00:00`); const date = new Date(`${dateKey}T12:00:00`);
const day = date.getDay() === 0 ? 7 : date.getDay(); const day = date.getDay() === 0 ? 7 : date.getDay();

View file

@ -10,6 +10,7 @@ import {
distributeWeeklyExtras, distributeWeeklyExtras,
id, id,
mealTypeSchema, mealTypeSchema,
MIN_CALORIE_GOAL,
numberString, numberString,
startOfWeek, startOfWeek,
} from "./_shared"; } from "./_shared";
@ -283,7 +284,7 @@ export const trackingRouter = router({
}), }),
upsertWeeklyCaloriePlan: protectedProcedure 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 }) => { .mutation(async ({ ctx, input }) => {
await ctx.db await ctx.db
.insert(schema.weeklyCaloriePlans) .insert(schema.weeklyCaloriePlans)