Feature: Flexibles Wochenbudget mit Blocker-Tagen
Some checks are pending
CI / Lint, Typecheck, Test, Build (push) Waiting to run

Pro-Tag-Mehrbedarf statt fester Tageswerte:

- DB: Spalte extraCalories in weekly_calorie_plans (Migration 0018).
- API: neuer Endpoint setWeeklyExtras nimmt die ganze Woche entgegen
  und verteilt eingeplante Mehrkalorien serverseitig. Reine, getestete
  Verteilungslogik distributeWeeklyExtras haelt das Wochenziel konstant
  (Tage mit Blocker = Tagesziel + Extra; uebrige Tage anteilig reduziert,
  Floor 1200). statsSummary liefert extraCalories pro Tag mit.
- 3 neue API-Tests (Verteilung konstant, Floor, Endpoint).
- Wochenbudget-UI: je Tag ein "+kcal"- und ein "Anlass"-Feld plus ein
  einziger "kcal neu verteilen"-Button. Blocker-Tage werden hervorgehoben.
  Das fehleranfaellige Einzel-Datumsformular entfaellt.
- Dashboard nimmt den verteilten Plan-Wert des Tages als Basis-Tagesziel
  (z. B. 1.740 nach Ausgleich, 2.800 an einem Blocker-Tag) und zeigt einen
  Hinweis. Behebt zugleich den Sync: Wochenbudget-Aenderungen invalidieren
  jetzt alle statsSummary-Varianten, sodass das Dashboard sofort mitzieht.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sebastian Mayer 2026-05-29 11:30:29 +02:00
parent e264765876
commit 70f59cfd40
12 changed files with 4905 additions and 56 deletions

View file

@ -373,12 +373,16 @@ export default function DashboardPage() {
return { calories, protein, drinkMl, carbs, fat }; return { calories, protein, drinkMl, carbs, fat };
}, [day.drinks, day.foods]); }, [day.drinks, day.foods]);
// Sportbonus des AUSGEWAEHLTEN Tages (nicht der ganzen Woche). Wird nur dann // Tageswerte aus dem Wochenplan (enthaelt die per Wochenbudget verteilten Ziele
// aufs Tagesziel addiert, wenn der Nutzer das in den Zielen aktiviert hat. // und einen evtl. eingeplanten Mehrbedarf fuer diesen Tag).
const dayActivityCalories = const weekDay = weeklyBudget?.days.find((entry) => entry.date === selectedDate);
weeklyBudget?.days.find((entry) => entry.date === selectedDate)?.activityCalories ?? 0; const dayActivityCalories = weekDay?.activityCalories ?? 0;
const dailySportBonus = goals.sportAddsCalories ? dayActivityCalories : 0; const dailySportBonus = goals.sportAddsCalories ? dayActivityCalories : 0;
const dailyCalorieGoal = goals.calories + dailySportBonus; // Basis-Tagesziel ist der verteilte Plan-Wert dieses Tages (z. B. 1.740 nach
// Umverteilung oder 2.800 an einem Blocker-Tag), sonst das allgemeine Tagesziel.
const baseDailyGoal = weekDay?.plannedCalories ?? goals.calories;
const dayExtraCalories = weekDay?.extraCalories ?? 0;
const dailyCalorieGoal = baseDailyGoal + dailySportBonus;
const remainingCalories = Math.max(0, dailyCalorieGoal - totals.calories); const remainingCalories = Math.max(0, dailyCalorieGoal - totals.calories);
const nutritionScore = Math.min(100, Math.round((progress(totals.protein, goals.protein) + progress(totals.drinkMl, goals.drinkMl)) / 2)); const nutritionScore = Math.min(100, Math.round((progress(totals.protein, goals.protein) + progress(totals.drinkMl, goals.drinkMl)) / 2));
@ -583,10 +587,14 @@ export default function DashboardPage() {
<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">
<span> <span>
Ziel: {goals.calories.toLocaleString("de-DE")} kcal Ziel: {baseDailyGoal.toLocaleString("de-DE")} kcal
{dailySportBonus > 0 {dailySportBonus > 0
? ` + ${dailySportBonus.toLocaleString("de-DE")} Sport = ${dailyCalorieGoal.toLocaleString("de-DE")} kcal` ? ` + ${dailySportBonus.toLocaleString("de-DE")} Sport = ${dailyCalorieGoal.toLocaleString("de-DE")} kcal`
: ""} : ""}
{dayExtraCalories > 0 ? " · höherer Tag eingeplant" : ""}
{baseDailyGoal < goals.calories && dayExtraCalories === 0
? " · Ausgleich für höheren Tag"
: ""}
</span> </span>
<EllipsisHorizontalIcon className="h-7 w-7" /> <EllipsisHorizontalIcon className="h-7 w-7" />
</div> </div>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { ArrowPathIcon, CalendarDaysIcon, FireIcon, SparklesIcon } from "@heroicons/react/24/outline"; import { ArrowPathIcon, CalendarDaysIcon, FireIcon, SparklesIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { todayKey } from "@/lib/hellth-data"; import { todayKey } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
@ -15,48 +15,63 @@ function prognosis(balance: number) {
return { label: "leicht drüber - flexibel ausgleichen", color: "text-amber-700 bg-amber-50" }; return { label: "leicht drüber - flexibel ausgleichen", color: "text-amber-700 bg-amber-50" };
} }
type ExtraDraft = { extraCalories: string; note: string };
export default function WochenbudgetPage() { export default function WochenbudgetPage() {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const [selectedDate, setSelectedDate] = useState(todayKey()); const [selectedDate, setSelectedDate] = useState(todayKey());
const [extraDate, setExtraDate] = useState(todayKey());
const [extraCalories, setExtraCalories] = useState("800");
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
// Pro Tag eingeplanter Mehrbedarf ("Blocker"), bevor er verteilt wird.
const [drafts, setDrafts] = useState<Record<string, ExtraDraft>>({});
const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: selectedDate }); const summaryQuery = trpc.health.statsSummary.useQuery({ referenceDate: selectedDate });
const summary = summaryQuery.data?.week ?? null; const summary = summaryQuery.data?.week ?? null;
const prediction = summary ? prognosis(summary.balance) : null; const prediction = summary ? prognosis(summary.balance) : null;
const upsertPlanMutation = trpc.health.upsertWeeklyCaloriePlan.useMutation({
const setExtrasMutation = trpc.health.setWeeklyExtras.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.health.statsSummary.invalidate({ referenceDate: selectedDate }); // Ohne Key-Filter invalidieren, damit Dashboard + Statistiken mitziehen.
await utils.health.statsSummary.invalidate();
await utils.health.overview.invalidate();
setSaved(true); setSaved(true);
window.setTimeout(() => setSaved(false), 1500); window.setTimeout(() => setSaved(false), 1500);
}, },
}); });
const dayByDate = useMemo(() => new Map((summary?.days ?? []).map((day) => [day.date, day])), [summary?.days]); // Entwurf aus den gespeicherten Tageswerten vorbefüllen, sobald die Woche geladen ist.
useEffect(() => {
function updatePlan(date: string, plannedCalories: number, note?: string) {
upsertPlanMutation.mutate({
date,
plannedCalories: Math.max(800, Math.min(6000, Math.round(plannedCalories))),
note: note ?? dayByDate.get(date)?.note ?? null,
});
}
function applyRestaurantDistribution() {
if (!summary) return; if (!summary) return;
const extra = Math.max(0, Number(extraCalories) || 0); setDrafts((current) => {
const days = summary.days; const next: Record<string, ExtraDraft> = {};
const targetDay = days.find((day) => day.date === extraDate); for (const day of summary.days) {
if (!targetDay) return; next[day.date] = current[day.date] ?? {
extraCalories: day.extraCalories ? String(day.extraCalories) : "",
const otherDays = days.filter((day) => day.date !== extraDate); note: day.note ?? "",
const savingPerOtherDay = otherDays.length ? Math.ceil(extra / otherDays.length) : 0; };
updatePlan(extraDate, targetDay.plannedCalories + extra, targetDay.note ?? "Restaurant / höherer Tag");
for (const day of otherDays) {
updatePlan(day.date, Math.max(1200, day.plannedCalories - savingPerOtherDay), day.note);
} }
return next;
});
}, [summary]);
const totalExtra = useMemo(
() => Object.values(drafts).reduce((sum, draft) => sum + (Number(draft.extraCalories) || 0), 0),
[drafts],
);
function updateDraft(date: string, patch: Partial<ExtraDraft>) {
setDrafts((current) => ({ ...current, [date]: { ...current[date], ...patch } as ExtraDraft }));
}
function redistribute() {
if (!summary) return;
setExtrasMutation.mutate({
weekStart: summary.weekStart,
days: summary.days.map((day) => ({
date: day.date,
extraCalories: Math.max(0, Number(drafts[day.date]?.extraCalories) || 0),
note: drafts[day.date]?.note?.trim() || null,
})),
});
} }
if (!summary || !prediction) { if (!summary || !prediction) {
@ -77,7 +92,9 @@ export default function WochenbudgetPage() {
<p className="text-sm font-black uppercase text-[#0F766E]">Flexible Planung</p> <p className="text-sm font-black uppercase text-[#0F766E]">Flexible Planung</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Wochenbudget</h1> <h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Wochenbudget</h1>
<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">
Ein höherer Tag ist kein Problem. Hellth Hub verteilt den Ausgleich freundlich auf die Woche. 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.
</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" />
@ -98,7 +115,7 @@ export default function WochenbudgetPage() {
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100"> <div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<SparklesIcon className="h-8 w-8 text-emerald-500" /> <SparklesIcon className="h-8 w-8 text-emerald-500" />
<p className="mt-3 text-3xl font-black text-gray-950">+{fmt(summary.activityCalories)}</p> <p className="mt-3 text-3xl font-black text-gray-950">+{fmt(summary.activityCalories)}</p>
<p className="text-sm font-bold text-gray-400">Sportbonus</p> <p className="text-sm font-bold text-gray-400">Sportbonus (Woche)</p>
</div> </div>
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100"> <div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className={`inline-flex rounded-full px-3 py-1 text-xs font-black ${prediction.color}`}>{prediction.label}</div> <div className={`inline-flex rounded-full px-3 py-1 text-xs font-black ${prediction.color}`}>{prediction.label}</div>
@ -108,13 +125,34 @@ export default function WochenbudgetPage() {
</section> </section>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100"> <section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-black text-gray-950">Tagesverteilung</h2> <h2 className="text-xl font-black text-gray-950">Tagesverteilung</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">
{totalExtra > 0
? `${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>
</div>
<button
type="button"
onClick={redistribute}
disabled={setExtrasMutation.isPending}
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-gray-950 px-5 py-3 text-sm font-black text-white disabled:opacity-40"
>
<ArrowPathIcon className="h-5 w-5" />
{setExtrasMutation.isPending ? "Verteile…" : "kcal neu verteilen"}
</button>
</div>
<div className="mt-5 grid gap-3"> <div className="mt-5 grid gap-3">
{summary.days.map((day) => { {summary.days.map((day) => {
const date = new Date(`${day.date}T12:00:00`); const date = new Date(`${day.date}T12:00:00`);
const over = day.eatenCalories > day.plannedCalories + day.activityCalories; const over = day.eatenCalories > day.plannedCalories + day.activityCalories;
const draft = drafts[day.date] ?? { extraCalories: "", note: "" };
const isBlocker = (Number(draft.extraCalories) || 0) > 0;
return ( return (
<div key={day.date} className="grid gap-3 rounded-3xl bg-gray-50 p-4 md:grid-cols-[130px_1fr_160px_120px] md:items-center"> <div key={day.date} className={`grid gap-3 rounded-3xl p-4 md:grid-cols-[120px_1fr_150px_150px_110px] md:items-center ${isBlocker ? "bg-amber-50 ring-1 ring-amber-200" : "bg-gray-50"}`}>
<div> <div>
<p className="text-lg font-black text-gray-950">{date.toLocaleDateString("de-DE", { weekday: "short" })}</p> <p className="text-lg font-black text-gray-950">{date.toLocaleDateString("de-DE", { weekday: "short" })}</p>
<p className="text-xs font-bold text-gray-400">{date.toLocaleDateString("de-DE")}</p> <p className="text-xs font-bold text-gray-400">{date.toLocaleDateString("de-DE")}</p>
@ -126,14 +164,25 @@ export default function WochenbudgetPage() {
<p className="mt-2 text-sm font-bold text-gray-400"> <p className="mt-2 text-sm font-bold text-gray-400">
{fmt(day.eatenCalories)} / {fmt(day.plannedCalories)} kcal {fmt(day.eatenCalories)} / {fmt(day.plannedCalories)} kcal
{day.activityCalories ? ` + ${fmt(day.activityCalories)} Sport` : ""} {day.activityCalories ? ` + ${fmt(day.activityCalories)} Sport` : ""}
{day.note ? ` - ${day.note}` : ""}
</p> </p>
</div> </div>
<label className="flex items-center gap-2">
<span className="text-sm font-black text-gray-400">+</span>
<input <input
type="number" type="number"
value={day.plannedCalories} inputMode="numeric"
onChange={(event) => updatePlan(day.date, Number(event.target.value) || summary.dailyGoal, day.note)} min={0}
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold" value={draft.extraCalories}
onChange={(event) => updateDraft(day.date, { extraCalories: event.target.value })}
placeholder="kcal"
className="w-full rounded-2xl border border-gray-200 px-3 py-3 text-sm font-bold"
/>
</label>
<input
value={draft.note}
onChange={(event) => updateDraft(day.date, { note: event.target.value })}
placeholder="Anlass"
className="rounded-2xl border border-gray-200 px-3 py-3 text-sm font-bold"
/> />
<p className={`text-sm font-black ${day.balance >= 0 ? "text-emerald-700" : "text-amber-700"}`}> <p className={`text-sm font-black ${day.balance >= 0 ? "text-emerald-700" : "text-amber-700"}`}>
{day.balance >= 0 ? "+" : ""}{fmt(day.balance)} kcal {day.balance >= 0 ? "+" : ""}{fmt(day.balance)} kcal
@ -144,21 +193,6 @@ export default function WochenbudgetPage() {
</div> </div>
</section> </section>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Restaurantbesuch einplanen</h2>
<p className="mt-1 text-sm font-semibold text-gray-400">
Beispiel: Samstag +800 kcal. Die App verteilt den Ausgleich auf die restlichen Tage.
</p>
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_160px_180px]">
<input type="date" value={extraDate} onChange={(event) => setExtraDate(event.target.value)} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold" />
<input value={extraCalories} onChange={(event) => setExtraCalories(event.target.value)} placeholder="+ kcal" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold" />
<button type="button" onClick={applyRestaurantDistribution} disabled={upsertPlanMutation.isPending} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:opacity-40">
<ArrowPathIcon className="h-5 w-5" />
Verteilen
</button>
</div>
</section>
{saved ? <div className="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-emerald-600 px-5 py-3 text-sm font-black text-white shadow-lg">Gespeichert</div> : null} {saved ? <div className="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-emerald-600 px-5 py-3 text-sm font-black text-white shadow-lg">Gespeichert</div> : null}
</div> </div>
); );

View file

@ -58,6 +58,7 @@ export function buildWeeklyBudgetSummary(data: HellthData, dateKey = todayKey())
return { return {
date, date,
plannedCalories, plannedCalories,
extraCalories: planByDate.get(date)?.extraCalories ?? 0,
eatenCalories, eatenCalories,
activityCalories, activityCalories,
balance: plannedCalories + activityCalories - eatenCalories, balance: plannedCalories + activityCalories - eatenCalories,

View file

@ -172,6 +172,7 @@ export type ActivityLog = {
export type WeeklyCaloriePlan = { export type WeeklyCaloriePlan = {
date: string; date: string;
plannedCalories: number; plannedCalories: number;
extraCalories?: number;
note?: string; note?: string;
}; };
@ -254,6 +255,7 @@ export type HellthData = {
export type WeeklyBudgetDay = { export type WeeklyBudgetDay = {
date: string; date: string;
plannedCalories: number; plannedCalories: number;
extraCalories: number;
eatenCalories: number; eatenCalories: number;
activityCalories: number; activityCalories: number;
balance: number; balance: number;

View file

@ -1,6 +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";
type Call = { op: string; payload?: unknown }; type Call = { op: string; payload?: unknown };
@ -771,3 +772,85 @@ test("health.upsertProfile weist mealsPerDay groesser 8 ab", async () => {
); );
}); });
test("distributeWeeklyExtras haelt das Wochenbudget konstant", async () => {
const daily = 2000;
// So +800 (Familienfeier), Mi +500 (Trinken), Rest ohne Extra.
const planned = distributeWeeklyExtras(
{
"2026-05-25": 0,
"2026-05-26": 0,
"2026-05-27": 500,
"2026-05-28": 0,
"2026-05-29": 0,
"2026-05-30": 0,
"2026-05-31": 800,
},
daily,
);
// Blocker-Tage = Tagesziel + Extra
assert.equal(planned["2026-05-27"], 2500);
assert.equal(planned["2026-05-31"], 2800);
// 1300 Extra auf 5 Tage ohne Extra: ceil(1300/5)=260 Abzug pro Tag
assert.equal(planned["2026-05-25"], 1740);
// Wochenbudget bleibt im Rahmen des Ausgleichs erhalten (kleine Ceil-Rundung erlaubt).
const total = Object.values(planned).reduce((sum, value) => sum + value, 0);
assert.ok(total <= daily * 7, `Summe ${total} darf das Wochenziel ${daily * 7} nicht ueberschreiten`);
assert.ok(total >= daily * 7 - 7, "Summe darf nur minimal (Rundung) unter dem Wochenziel liegen");
});
test("distributeWeeklyExtras unterschreitet den Floor nicht", async () => {
// Sehr grosser Blocker an einem Tag, der die anderen rechnerisch unter 1200 druecken wuerde.
const planned = distributeWeeklyExtras(
{
"2026-05-25": 0,
"2026-05-26": 0,
"2026-05-31": 4000,
},
2000,
1200,
);
assert.equal(planned["2026-05-31"], 6000);
// 4000/2 = 2000 Abzug -> 2000-2000=0, aber Floor greift bei 1200
assert.equal(planned["2026-05-25"], 1200);
assert.equal(planned["2026-05-26"], 1200);
});
test("health.setWeeklyExtras verteilt und speichert plannedCalories pro Tag", async () => {
const { db, calls } = createHealthDbMock();
const caller = appRouter.createCaller(createContext({ db }) as never);
const result = await caller.health.setWeeklyExtras({
weekStart: "2026-05-25",
days: [
{ date: "2026-05-25", extraCalories: 0 },
{ date: "2026-05-26", extraCalories: 0 },
{ date: "2026-05-27", extraCalories: 0 },
{ date: "2026-05-28", extraCalories: 0 },
{ date: "2026-05-29", extraCalories: 0 },
{ date: "2026-05-30", extraCalories: 0 },
{ date: "2026-05-31", extraCalories: 700, note: "Familienfeier" },
],
});
assert.deepEqual(result, { ok: true });
// Mock-Goals liefern calories=2000. Blocker-Tag bekommt 2700, andere 2000-ceil(700/6)=1883.
const insertCalls = calls.filter((call) => call.op === "tx.insert.values");
const sunday = insertCalls.find((call) => (call.payload as { date?: string }).date === "2026-05-31")?.payload as {
plannedCalories?: number;
extraCalories?: number;
note?: string | null;
};
assert.equal(sunday.plannedCalories, 2700);
assert.equal(sunday.extraCalories, 700);
assert.equal(sunday.note, "Familienfeier");
const monday = insertCalls.find((call) => (call.payload as { date?: string }).date === "2026-05-25")?.payload as {
plannedCalories?: number;
extraCalories?: number;
};
assert.equal(monday.plannedCalories, 2000 - Math.ceil(700 / 6));
assert.equal(monday.extraCalories, 0);
});

View file

@ -249,6 +249,36 @@ export function numberString(value: number | string | null | undefined, fallback
return String(value); return String(value);
} }
/**
* 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).
*
* Gibt fuer jeden uebergebenen Tag den errechneten plannedCalories-Wert zurueck.
*/
export function distributeWeeklyExtras(
extrasByDate: Record<string, number>,
dailyGoal: number,
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> = {};
for (const date of dates) {
const extra = Math.max(0, extrasByDate[date] ?? 0);
result[date] = extra > 0
? dailyGoal + extra
: Math.max(floor, dailyGoal - reductionPerDay);
}
return result;
}
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

@ -309,6 +309,7 @@ export const communityRouter = router({
return { return {
date, date,
plannedCalories, plannedCalories,
extraCalories: weeklyPlanByDate.get(date)?.extraCalories ?? 0,
eatenCalories, eatenCalories,
activityCalories, activityCalories,
balance: plannedCalories + activityCalories - eatenCalories, balance: plannedCalories + activityCalories - eatenCalories,

View file

@ -7,6 +7,7 @@ import {
addDays, addDays,
auditHealthMutation, auditHealthMutation,
dateKeySchema, dateKeySchema,
distributeWeeklyExtras,
id, id,
mealTypeSchema, mealTypeSchema,
numberString, numberString,
@ -308,6 +309,60 @@ export const trackingRouter = router({
return { ok: true }; return { ok: true };
}), }),
// Setzt fuer eine ganze Woche die eingeplanten Mehrkalorien ("Blocker") pro Tag und
// verteilt den Ausgleich serverseitig, sodass das Wochenbudget (dailyGoal * 7) erhalten
// bleibt. Ersetzt das fehleranfaellige Verteilen einzelner Tage im Frontend.
setWeeklyExtras: protectedProcedure
.input(z.object({
weekStart: dateKeySchema,
days: z.array(z.object({
date: dateKeySchema,
extraCalories: z.number().int().min(0).max(4000),
note: z.string().max(500).optional().nullable(),
})).min(1).max(7),
}))
.mutation(async ({ ctx, input }) => {
const goals = await ctx.db.query.healthGoals.findFirst({
where: (row, { eq: rowEq }) => rowEq(row.userId, ctx.user.id),
});
const dailyGoal = goals?.calories ?? 2000;
const extrasByDate: Record<string, number> = {};
for (const day of input.days) extrasByDate[day.date] = day.extraCalories;
const noteByDate = new Map(input.days.map((day) => [day.date, day.note || null]));
const plannedByDate = distributeWeeklyExtras(extrasByDate, dailyGoal);
await ctx.db.transaction(async (tx) => {
for (const day of input.days) {
await tx
.insert(schema.weeklyCaloriePlans)
.values({
userId: ctx.user.id,
date: day.date,
plannedCalories: plannedByDate[day.date]!,
extraCalories: day.extraCalories,
note: noteByDate.get(day.date) ?? null,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [schema.weeklyCaloriePlans.userId, schema.weeklyCaloriePlans.date],
set: {
plannedCalories: plannedByDate[day.date]!,
extraCalories: day.extraCalories,
note: noteByDate.get(day.date) ?? null,
updatedAt: new Date(),
},
});
}
});
auditHealthMutation(ctx, "weekly-calorie-plan.set-extras", {
weekStart: input.weekStart,
totalExtra: Object.values(extrasByDate).reduce((sum, value) => sum + value, 0),
});
return { ok: true };
}),
addActivityLog: protectedProcedure addActivityLog: protectedProcedure
.input(z.object({ .input(z.object({
date: dateKeySchema, date: dateKeySchema,

View file

@ -0,0 +1 @@
ALTER TABLE "weekly_calorie_plans" ADD COLUMN "extraCalories" integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -127,6 +127,13 @@
"when": 1779962400000, "when": 1779962400000,
"tag": "0017_store_change_requests", "tag": "0017_store_change_requests",
"breakpoints": true "breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1780046007288,
"tag": "0018_outgoing_betty_ross",
"breakpoints": true
} }
] ]
} }

View file

@ -64,6 +64,9 @@ export const weeklyCaloriePlans = pgTable(
userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }), userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
date: date("date").notNull(), date: date("date").notNull(),
plannedCalories: integer("plannedCalories").notNull(), plannedCalories: integer("plannedCalories").notNull(),
// Vom Nutzer eingeplanter Mehrbedarf an diesem Tag (z. B. Familienfeier).
// plannedCalories wird daraus + Wochenausgleich abgeleitet.
extraCalories: integer("extraCalories").notNull().default(0),
note: text("note"), note: text("note"),
updatedAt: timestamp("updatedAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(),
}, },