Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-, Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb. Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) - Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) - PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin. Module: - Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery - Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen - Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts - Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen - Essensplanung und Wochenbudget mit Sportbonus - Einkaufslisten mit Maerkten und Laufweg-Sortierung - Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung - Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
5.7 KiB
TypeScript
142 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { BeakerIcon, ClockIcon, FireIcon, ScaleIcon } from "@heroicons/react/24/outline";
|
|
import { useEffect, useState } from "react";
|
|
import { trpc } from "@/lib/trpc";
|
|
|
|
type GoalsForm = {
|
|
calories: number;
|
|
proteinG: number;
|
|
carbsG: number;
|
|
fatG: number;
|
|
drinkMl: number;
|
|
weightKg: number;
|
|
fastingHours: number;
|
|
weeklyCalories: number;
|
|
sportAddsCalories: boolean;
|
|
};
|
|
|
|
const defaultGoals: GoalsForm = {
|
|
calories: 2000,
|
|
proteinG: 150,
|
|
carbsG: 200,
|
|
fatG: 67,
|
|
drinkMl: 2500,
|
|
weightKg: 120,
|
|
fastingHours: 12,
|
|
weeklyCalories: 14000,
|
|
sportAddsCalories: true,
|
|
};
|
|
|
|
const fields: Array<{ key: keyof Omit<GoalsForm, "sportAddsCalories">; label: string; unit: string; icon: typeof FireIcon }> = [
|
|
{ key: "calories", label: "Kalorien", unit: "kcal", icon: FireIcon },
|
|
{ key: "weeklyCalories", label: "Wochenbudget", unit: "kcal", icon: FireIcon },
|
|
{ key: "proteinG", label: "Protein", unit: "g", icon: ScaleIcon },
|
|
{ key: "carbsG", label: "Kohlenhydrate", unit: "g", icon: ScaleIcon },
|
|
{ key: "fatG", label: "Fett", unit: "g", icon: ScaleIcon },
|
|
{ key: "drinkMl", label: "Trinkziel", unit: "ml", icon: BeakerIcon },
|
|
{ key: "weightKg", label: "Gewichtsziel", unit: "kg", icon: ScaleIcon },
|
|
{ key: "fastingHours", label: "Fasten", unit: "h", icon: ClockIcon },
|
|
];
|
|
|
|
function todayKey() {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
export default function ZielePage() {
|
|
const utils = trpc.useUtils();
|
|
const overviewQuery = trpc.health.overview.useQuery({ date: todayKey() });
|
|
const saveGoalsMutation = trpc.health.upsertGoals.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.health.overview.invalidate();
|
|
setSaved(true);
|
|
window.setTimeout(() => setSaved(false), 1600);
|
|
},
|
|
});
|
|
const [goals, setGoals] = useState<GoalsForm>(defaultGoals);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const apiGoals = overviewQuery.data?.goals;
|
|
if (!apiGoals) return;
|
|
const calories = apiGoals.calories ?? defaultGoals.calories;
|
|
setGoals({
|
|
calories,
|
|
proteinG: apiGoals.proteinG ?? defaultGoals.proteinG,
|
|
carbsG: apiGoals.carbsG ?? defaultGoals.carbsG,
|
|
fatG: apiGoals.fatG ?? defaultGoals.fatG,
|
|
drinkMl: apiGoals.drinkMl ?? defaultGoals.drinkMl,
|
|
weightKg: Number(apiGoals.weightKg ?? defaultGoals.weightKg),
|
|
fastingHours: apiGoals.fastingHours ?? defaultGoals.fastingHours,
|
|
weeklyCalories: apiGoals.weeklyCalories ?? calories * 7,
|
|
sportAddsCalories: apiGoals.sportAddsCalories ?? true,
|
|
});
|
|
}, [overviewQuery.data?.goals]);
|
|
|
|
function saveGoals() {
|
|
saveGoalsMutation.mutate({
|
|
drinkMl: goals.drinkMl,
|
|
calories: goals.calories,
|
|
carbsG: goals.carbsG,
|
|
fatG: goals.fatG,
|
|
proteinG: goals.proteinG,
|
|
weightKg: goals.weightKg || null,
|
|
fastingHours: goals.fastingHours,
|
|
weeklyCalories: goals.weeklyCalories || goals.calories * 7,
|
|
sportAddsCalories: goals.sportAddsCalories,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-4xl space-y-5 pb-24">
|
|
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
|
<p className="text-sm font-black uppercase text-[#0F766E]">Health-API</p>
|
|
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Ziele</h1>
|
|
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
|
Alltagstaugliche Zielwerte statt perfekter Wissenschaft. Diese Werte werden jetzt serverseitig pro Benutzer gespeichert.
|
|
</p>
|
|
</header>
|
|
|
|
<section className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
|
{overviewQuery.isLoading ? (
|
|
<div className="rounded-3xl bg-gray-50 p-5 text-sm font-bold text-gray-400">Ziele werden geladen...</div>
|
|
) : null}
|
|
<div className="divide-y divide-gray-100">
|
|
{fields.map((field) => (
|
|
<label key={field.key} className="grid gap-3 py-4 sm:grid-cols-[44px_1fr_180px] sm:items-center">
|
|
<field.icon className="h-8 w-8 text-[#0F766E]" />
|
|
<span className="text-xl font-black text-gray-950">{field.label}</span>
|
|
<div className="grid grid-cols-[1fr_52px] items-center gap-2">
|
|
<input
|
|
type="number"
|
|
value={goals[field.key]}
|
|
onChange={(event) => setGoals((current) => ({ ...current, [field.key]: Number(event.target.value) || 0 }))}
|
|
className="rounded-2xl border border-gray-200 px-4 py-3 text-right text-sm font-bold"
|
|
/>
|
|
<span className="text-sm font-black text-gray-400">{field.unit}</span>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<label className="mt-4 flex items-center justify-between gap-3 rounded-3xl bg-gray-50 px-4 py-4 text-sm font-black text-gray-950">
|
|
Sportkalorien erhöhen das Tagesbudget
|
|
<input
|
|
type="checkbox"
|
|
checked={goals.sportAddsCalories}
|
|
onChange={(event) => setGoals((current) => ({ ...current, sportAddsCalories: event.target.checked }))}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={saveGoals}
|
|
disabled={saveGoalsMutation.isPending}
|
|
className="mt-6 w-full rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
|
|
>
|
|
{saveGoalsMutation.isPending ? "Speichere..." : "Ziele speichern"}
|
|
</button>
|
|
{saved ? <p className="mt-3 text-center text-sm font-bold text-green-700">Gespeichert.</p> : null}
|
|
{saveGoalsMutation.error ? <p className="mt-3 text-center text-sm font-bold text-red-600">{saveGoalsMutation.error.message}</p> : null}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|