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 };
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue