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>
))}
</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

View file

@ -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">

View file

@ -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"

View file

@ -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);

View file

@ -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();

View file

@ -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)