Feature: Flexibles Wochenbudget mit Blocker-Tagen
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
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:
parent
e264765876
commit
70f59cfd40
12 changed files with 4905 additions and 56 deletions
|
|
@ -373,12 +373,16 @@ export default function DashboardPage() {
|
|||
return { calories, protein, drinkMl, carbs, fat };
|
||||
}, [day.drinks, day.foods]);
|
||||
|
||||
// Sportbonus des AUSGEWAEHLTEN Tages (nicht der ganzen Woche). Wird nur dann
|
||||
// aufs Tagesziel addiert, wenn der Nutzer das in den Zielen aktiviert hat.
|
||||
const dayActivityCalories =
|
||||
weeklyBudget?.days.find((entry) => entry.date === selectedDate)?.activityCalories ?? 0;
|
||||
// Tageswerte aus dem Wochenplan (enthaelt die per Wochenbudget verteilten Ziele
|
||||
// und einen evtl. eingeplanten Mehrbedarf fuer diesen Tag).
|
||||
const weekDay = weeklyBudget?.days.find((entry) => entry.date === selectedDate);
|
||||
const dayActivityCalories = weekDay?.activityCalories ?? 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 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">
|
||||
<span>
|
||||
Ziel: {goals.calories.toLocaleString("de-DE")} kcal
|
||||
Ziel: {baseDailyGoal.toLocaleString("de-DE")} kcal
|
||||
{dailySportBonus > 0
|
||||
? ` + ${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>
|
||||
<EllipsisHorizontalIcon className="h-7 w-7" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
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 { 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" };
|
||||
}
|
||||
|
||||
type ExtraDraft = { extraCalories: string; note: string };
|
||||
|
||||
export default function WochenbudgetPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const [selectedDate, setSelectedDate] = useState(todayKey());
|
||||
const [extraDate, setExtraDate] = useState(todayKey());
|
||||
const [extraCalories, setExtraCalories] = useState("800");
|
||||
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 summary = summaryQuery.data?.week ?? null;
|
||||
const prediction = summary ? prognosis(summary.balance) : null;
|
||||
const upsertPlanMutation = trpc.health.upsertWeeklyCaloriePlan.useMutation({
|
||||
|
||||
const setExtrasMutation = trpc.health.setWeeklyExtras.useMutation({
|
||||
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);
|
||||
window.setTimeout(() => setSaved(false), 1500);
|
||||
},
|
||||
});
|
||||
|
||||
const dayByDate = useMemo(() => new Map((summary?.days ?? []).map((day) => [day.date, day])), [summary?.days]);
|
||||
|
||||
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,
|
||||
// Entwurf aus den gespeicherten Tageswerten vorbefüllen, sobald die Woche geladen ist.
|
||||
useEffect(() => {
|
||||
if (!summary) return;
|
||||
setDrafts((current) => {
|
||||
const next: Record<string, ExtraDraft> = {};
|
||||
for (const day of summary.days) {
|
||||
next[day.date] = current[day.date] ?? {
|
||||
extraCalories: day.extraCalories ? String(day.extraCalories) : "",
|
||||
note: 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 applyRestaurantDistribution() {
|
||||
function redistribute() {
|
||||
if (!summary) return;
|
||||
const extra = Math.max(0, Number(extraCalories) || 0);
|
||||
const days = summary.days;
|
||||
const targetDay = days.find((day) => day.date === extraDate);
|
||||
if (!targetDay) return;
|
||||
|
||||
const otherDays = days.filter((day) => day.date !== extraDate);
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
|
|
@ -77,7 +92,9 @@ export default function WochenbudgetPage() {
|
|||
<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>
|
||||
<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>
|
||||
</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" />
|
||||
|
|
@ -98,7 +115,7 @@ export default function WochenbudgetPage() {
|
|||
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||||
<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="text-sm font-bold text-gray-400">Sportbonus</p>
|
||||
<p className="text-sm font-bold text-gray-400">Sportbonus (Woche)</p>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -108,13 +125,34 @@ export default function WochenbudgetPage() {
|
|||
</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">Tagesverteilung</h2>
|
||||
<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>
|
||||
<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">
|
||||
{summary.days.map((day) => {
|
||||
const date = new Date(`${day.date}T12:00:00`);
|
||||
const over = day.eatenCalories > day.plannedCalories + day.activityCalories;
|
||||
const draft = drafts[day.date] ?? { extraCalories: "", note: "" };
|
||||
const isBlocker = (Number(draft.extraCalories) || 0) > 0;
|
||||
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>
|
||||
<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>
|
||||
|
|
@ -126,14 +164,25 @@ export default function WochenbudgetPage() {
|
|||
<p className="mt-2 text-sm font-bold text-gray-400">
|
||||
{fmt(day.eatenCalories)} / {fmt(day.plannedCalories)} kcal
|
||||
{day.activityCalories ? ` + ${fmt(day.activityCalories)} Sport` : ""}
|
||||
{day.note ? ` - ${day.note}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-gray-400">+</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
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
|
||||
type="number"
|
||||
value={day.plannedCalories}
|
||||
onChange={(event) => updatePlan(day.date, Number(event.target.value) || summary.dailyGoal, day.note)}
|
||||
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-bold"
|
||||
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"}`}>
|
||||
{day.balance >= 0 ? "+" : ""}{fmt(day.balance)} kcal
|
||||
|
|
@ -144,21 +193,6 @@ export default function WochenbudgetPage() {
|
|||
</div>
|
||||
</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}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export function buildWeeklyBudgetSummary(data: HellthData, dateKey = todayKey())
|
|||
return {
|
||||
date,
|
||||
plannedCalories,
|
||||
extraCalories: planByDate.get(date)?.extraCalories ?? 0,
|
||||
eatenCalories,
|
||||
activityCalories,
|
||||
balance: plannedCalories + activityCalories - eatenCalories,
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ export type ActivityLog = {
|
|||
export type WeeklyCaloriePlan = {
|
||||
date: string;
|
||||
plannedCalories: number;
|
||||
extraCalories?: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
|
|
@ -254,6 +255,7 @@ export type HellthData = {
|
|||
export type WeeklyBudgetDay = {
|
||||
date: string;
|
||||
plannedCalories: number;
|
||||
extraCalories: number;
|
||||
eatenCalories: number;
|
||||
activityCalories: number;
|
||||
balance: number;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { appRouter } from "./index";
|
||||
import { distributeWeeklyExtras } from "./routers/health/_shared";
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -249,6 +249,36 @@ export function numberString(value: number | string | null | undefined, fallback
|
|||
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) {
|
||||
const date = new Date(`${dateKey}T12:00:00`);
|
||||
const day = date.getDay() === 0 ? 7 : date.getDay();
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ export const communityRouter = router({
|
|||
return {
|
||||
date,
|
||||
plannedCalories,
|
||||
extraCalories: weeklyPlanByDate.get(date)?.extraCalories ?? 0,
|
||||
eatenCalories,
|
||||
activityCalories,
|
||||
balance: plannedCalories + activityCalories - eatenCalories,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
addDays,
|
||||
auditHealthMutation,
|
||||
dateKeySchema,
|
||||
distributeWeeklyExtras,
|
||||
id,
|
||||
mealTypeSchema,
|
||||
numberString,
|
||||
|
|
@ -308,6 +309,60 @@ export const trackingRouter = router({
|
|||
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
|
||||
.input(z.object({
|
||||
date: dateKeySchema,
|
||||
|
|
|
|||
1
packages/db/drizzle/0018_outgoing_betty_ross.sql
Normal file
1
packages/db/drizzle/0018_outgoing_betty_ross.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "weekly_calorie_plans" ADD COLUMN "extraCalories" integer DEFAULT 0 NOT NULL;
|
||||
4624
packages/db/drizzle/meta/0018_snapshot.json
Normal file
4624
packages/db/drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -127,6 +127,13 @@
|
|||
"when": 1779962400000,
|
||||
"tag": "0017_store_change_requests",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1780046007288,
|
||||
"tag": "0018_outgoing_betty_ross",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -64,6 +64,9 @@ export const weeklyCaloriePlans = pgTable(
|
|||
userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||
date: date("date").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"),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue