hellth-hub/apps/admin/app/_components/reminder-runner.tsx
Sebastian Mayer 2436dd9cac Initial commit: Hellth Hub monorepo
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>
2026-05-27 22:15:16 +02:00

86 lines
2.6 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { useSession } from "@/lib/auth-client";
import { ReminderKind, loadHellthData } from "@/lib/hellth-data";
const labels: Record<ReminderKind, string> = {
water: "Wasser",
snack: "Snack",
movement: "Bewegung",
};
function minutesOfDay(value: string) {
const [hours = "0", minutes = "0"] = value.split(":");
return Number(hours) * 60 + Number(minutes);
}
function canNotifyNow(startTime: string, endTime: string) {
const now = new Date();
const current = now.getHours() * 60 + now.getMinutes();
return current >= minutesOfDay(startTime) && current <= minutesOfDay(endTime);
}
async function showReminder(kind: ReminderKind, message: string) {
if (typeof window === "undefined" || !("Notification" in window)) return;
if (Notification.permission !== "granted") return;
const title = `Hellth Hub: ${labels[kind]}`;
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, {
body: message,
tag: `hellth-${kind}`,
badge: "/icon-192.png",
});
return;
} catch {
// Fall through to regular notification.
}
}
new Notification(title, { body: message, tag: `hellth-${kind}` });
}
export function ReminderRunner() {
const { data: session } = useSession();
const userKey = session?.user?.id ?? session?.user?.email ?? null;
const lastSentRef = useRef<Record<string, number>>({});
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
void navigator.serviceWorker.register("/hellth-sw.js").catch(() => undefined);
}, []);
useEffect(() => {
if (!userKey) return;
function tick() {
const data = loadHellthData(userKey);
const reminders = data.reminders;
if (!reminders.enabled) return;
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
const now = Date.now();
(Object.keys(reminders.rules) as ReminderKind[]).forEach((kind) => {
const rule = reminders.rules[kind];
if (!rule.enabled) return;
if (!canNotifyNow(rule.startTime, rule.endTime)) return;
const lastSentKey = `${userKey}:${kind}`;
const lastSent = lastSentRef.current[lastSentKey] ?? 0;
if (now - lastSent < rule.intervalMinutes * 60_000) return;
lastSentRef.current[lastSentKey] = now;
void showReminder(kind, rule.message);
});
}
tick();
const interval = window.setInterval(tick, 60_000);
return () => window.clearInterval(interval);
}, [userKey]);
return null;
}