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>
82 lines
2.5 KiB
TypeScript
82 lines
2.5 KiB
TypeScript
export type LifecycleStatus = "draft" | "scheduled" | "published" | "archived";
|
|
|
|
export function slugify(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.normalize("NFKD")
|
|
.replace(/[^a-z0-9\s-]/g, "")
|
|
.replace(/\s+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.slice(0, 100)
|
|
.replace(/^-|-$/g, "");
|
|
}
|
|
|
|
function stripHtml(html: string): string {
|
|
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
export function extractH1Title(html: string): string {
|
|
const match = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
|
|
if (!match?.[1]) return "";
|
|
return stripHtml(match[1]).slice(0, 160).trim();
|
|
}
|
|
|
|
export function toDateValue(date?: Date | string | null) {
|
|
if (!date) return "";
|
|
return new Date(date).toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function toTimeValue(date?: Date | string | null) {
|
|
if (!date) return "";
|
|
return new Date(date).toISOString().slice(11, 16);
|
|
}
|
|
|
|
export function combineDateTime(date: string, time: string) {
|
|
if (!date || !time) return undefined;
|
|
return new Date(`${date}T${time}:00`);
|
|
}
|
|
|
|
export function isSchedulingVisible(status: LifecycleStatus) {
|
|
return status === "draft" || status === "scheduled";
|
|
}
|
|
|
|
export function validatePublishInputs(
|
|
status: LifecycleStatus,
|
|
publishDate: string,
|
|
publishTime: string,
|
|
) {
|
|
if (!isSchedulingVisible(status)) return null;
|
|
|
|
if (status === "scheduled" && (!publishDate || !publishTime)) {
|
|
return "Für den Status Geplant sind Datum und Uhrzeit erforderlich.";
|
|
}
|
|
|
|
if ((publishDate && !publishTime) || (!publishDate && publishTime)) {
|
|
return "Bitte Datum und Uhrzeit gemeinsam setzen.";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function resolveSubmitStatus(
|
|
status: LifecycleStatus,
|
|
publishDate: string,
|
|
publishTime: string,
|
|
) {
|
|
if (status === "draft" && publishDate && publishTime) return "scheduled" as const;
|
|
return status;
|
|
}
|
|
|
|
export function statusLabel(status: LifecycleStatus) {
|
|
if (status === "archived") return "Archiviert";
|
|
if (status === "published") return "Veröffentlicht";
|
|
if (status === "scheduled") return "Geplant";
|
|
return "Entwurf";
|
|
}
|
|
|
|
export function statusClass(status: LifecycleStatus) {
|
|
if (status === "archived") return "bg-gray-200 text-gray-700 dark:bg-neutral-700 dark:text-neutral-300";
|
|
if (status === "published") return "bg-green-50 text-green-700 dark:bg-green-950/50 dark:text-green-400";
|
|
if (status === "scheduled") return "bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-400";
|
|
return "bg-gray-100 text-gray-500 dark:bg-neutral-700 dark:text-neutral-400";
|
|
}
|