diff --git a/src/app/p/[secret]/growth/page.tsx b/src/app/p/[secret]/growth/page.tsx new file mode 100644 index 0000000..2d60f52 --- /dev/null +++ b/src/app/p/[secret]/growth/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useTRPC } from "@/trpc/client"; +import { AdminPageHeader, AdminSection } from "@/components/admin/AdminSection"; + +type Rate = { count: number; of: number }; + +function percent(rate: Rate): string { + return rate.of === 0 ? "–" : `${Math.round((rate.count / rate.of) * 100)} %`; +} + +function RateCell({ rate }: { rate: Rate }) { + return ( + + {percent(rate)} + {rate.of > 0 && {rate.count}/{rate.of}} + + ); +} + +const weekLabel = (iso: string) => + new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", timeZone: "UTC" }); + +/** + * Wachstums-Kennzahlen (Phase 0, D11): neue Halter mit Tier, Aktivierung und + * Bindung pro Registrierungswoche. Einladungsfaktor und Herkunft stehen unter + * „Empfehlungen“ und werden hier nur verlinkt. + */ +export default function AdminGrowthPage() { + const trpc = useTRPC(); + const { secret } = useParams<{ secret: string }>(); + const { data, isLoading } = useQuery(trpc.admin.getGrowth.queryOptions()); + + if (isLoading || !data) return ; + + const tiles = [ + { label: "Neue Halter mit Tier", value: String(data.total.newOwners), hint: "letzte 12 Wochen" }, + { label: "Aktivierung", value: percent(data.total.activated), hint: "erster Beitrag in 48 h" }, + { label: "Bindung Tag 7", value: percent(data.total.retainedD7), hint: `${data.total.retainedD7.of} auswertbar` }, + { label: "Bindung Tag 30", value: percent(data.total.retainedD30), hint: `${data.total.retainedD30.of} auswertbar` }, + ]; + + return ( +
+ + +
+ {tiles.map((tile) => ( +
+
{tile.label}
+
{tile.value}
+
{tile.hint}
+
+ ))} +
+ + +
+ + + + + + + + + + + + {[...data.weeks].reverse().map((week) => ( + + + + + + + + ))} + +
Woche abNeue HalterAktivierungTag 7Tag 30
{weekLabel(week.weekStart)}{week.newOwners}
+
+
+ +

+ Aktiv heißt: App geöffnet oder etwas gepostet, kommentiert, eine Pfote oder Nachricht geschickt. Reines Mitlesen + im Web ist nicht messbar, die Bindung ist daher eher zu niedrig. Einladungsfaktor und Herkunft stehen unter{" "} + + Empfehlungen + + . +

+
+ ); +} diff --git a/src/components/admin/admin-nav.tsx b/src/components/admin/admin-nav.tsx index b127644..2a0f0b0 100644 --- a/src/components/admin/admin-nav.tsx +++ b/src/components/admin/admin-nav.tsx @@ -16,6 +16,7 @@ import { Sprout, PawPrint, Siren, + TrendingUp, } from "lucide-react"; /** Welche Warteschlange (adminOverview.getQueues) als Zähler neben dem Menüpunkt steht. */ @@ -54,7 +55,13 @@ export function buildAdminNav(base: string, role: AdminRoleName): AdminNavGroup[ const only = (allowed: boolean, item: AdminNavItem) => (allowed ? [item] : []); const groups: AdminNavGroup[] = [ - { label: "Start", items: [{ href: base, label: "Übersicht", icon: icon(LayoutDashboard) }] }, + { + label: "Start", + items: [ + { href: base, label: "Übersicht", icon: icon(LayoutDashboard) }, + { href: `${base}/growth`, label: "Wachstum", icon: icon(TrendingUp) }, + ], + }, { label: "Moderation", items: [ diff --git a/src/lib/growth-metrics.test.ts b/src/lib/growth-metrics.test.ts new file mode 100644 index 0000000..f3408cc --- /dev/null +++ b/src/lib/growth-metrics.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { computeGrowth, weekStartUTC, type OwnerActivity } from "./growth-metrics"; + +const NOW = new Date("2026-10-11T12:00:00Z"); // Sonntag → Woche ab Mo 05.10. +const at = (iso: string) => new Date(iso); +const owner = (o: Partial & { createdAt: Date }): OwnerActivity => ({ + firstPetAt: o.createdAt, + firstPostAt: null, + lastActiveAt: null, + ...o, +}); + +describe("computeGrowth", () => { + it("ordnet nach Registrierungswoche und zählt nur Owner mit Tier", () => { + const { weeks, total } = computeGrowth( + [ + owner({ createdAt: at("2026-10-05T08:00:00Z") }), + owner({ createdAt: at("2026-10-11T08:00:00Z") }), + owner({ createdAt: at("2026-09-30T08:00:00Z") }), + owner({ createdAt: at("2026-10-06T08:00:00Z"), firstPetAt: null }), + ], + NOW + ); + expect(weeks).toHaveLength(12); + expect(weeks.at(-1)).toMatchObject({ weekStart: "2026-10-05T00:00:00.000Z", newOwners: 2 }); + expect(weeks.at(-2)?.newOwners).toBe(1); + expect(total.newOwners).toBe(3); + }); + + it("zählt Aktivierung nur bei erstem Beitrag innerhalb von 48 h nach dem ersten Tier", () => { + const pet = at("2026-10-05T08:00:00Z"); + const { total } = computeGrowth( + [ + owner({ createdAt: pet, firstPostAt: at("2026-10-07T08:00:00Z") }), // genau 48 h + owner({ createdAt: pet, firstPostAt: at("2026-10-07T08:00:01Z") }), + owner({ createdAt: pet }), + ], + NOW + ); + expect(total.activated).toEqual({ count: 1, of: 3 }); + }); + + it("wertet Bindung erst aus, wenn Tag 7 bzw. 30 erreicht ist", () => { + const { total } = computeGrowth( + [ + owner({ createdAt: at("2026-09-01T08:00:00Z"), lastActiveAt: at("2026-10-10T08:00:00Z") }), // D7 + D30 + owner({ createdAt: at("2026-09-28T08:00:00Z"), lastActiveAt: at("2026-10-05T08:00:00Z") }), // genau Tag 7 + owner({ createdAt: at("2026-09-28T08:00:00Z"), lastActiveAt: at("2026-09-29T08:00:00Z") }), // nicht zurückgekommen + owner({ createdAt: at("2026-10-09T08:00:00Z"), lastActiveAt: at("2026-10-10T08:00:00Z") }), // zu neu + ], + NOW + ); + expect(total.retainedD7).toEqual({ count: 2, of: 3 }); + expect(total.retainedD30).toEqual({ count: 1, of: 1 }); + }); + + it("lässt Owner außerhalb des 12-Wochen-Fensters weg", () => { + const { total } = computeGrowth([owner({ createdAt: at("2026-06-01T08:00:00Z") })], NOW); + expect(total.newOwners).toBe(0); + }); +}); + +describe("weekStartUTC", () => { + it("liefert den Montag der ISO-Woche", () => { + expect(weekStartUTC(at("2026-10-11T23:59:00Z")).toISOString()).toBe("2026-10-05T00:00:00.000Z"); + expect(weekStartUTC(at("2026-10-05T00:00:00Z")).toISOString()).toBe("2026-10-05T00:00:00.000Z"); + }); +}); diff --git a/src/lib/growth-metrics.ts b/src/lib/growth-metrics.ts new file mode 100644 index 0000000..09bacbd --- /dev/null +++ b/src/lib/growth-metrics.ts @@ -0,0 +1,136 @@ +import type { PrismaClient } from "@prisma/client"; + +/** + * Wachstums-Kennzahlen fürs Admin-Panel (Phase 0, D11). Kohorte = Woche der + * Registrierung (Owner.createdAt, ISO-Woche ab Montag, UTC). Gezählt werden nur + * Owner mit mindestens einem Tier. + * + * - Aktivierung: erster Beitrag innerhalb von 48 h nach dem ersten Tier. + * - Bindung Tag N: letzte Aktivität liegt N oder mehr Tage nach der Registrierung. + * Aktivität = App-Start (PushToken.lastSeenAt) oder ein Beitrag, eine Story, + * Pfote, ein Kommentar oder eine Nachricht eines der eigenen Tiere. + * Reines Mitlesen im Web ist nicht messbar — die Werte sind eher zu niedrig. + */ + +const DAY_MS = 86_400_000; +const ACTIVATION_MS = 48 * 3_600_000; +export const GROWTH_WEEKS = 12; + +export interface OwnerActivity { + createdAt: Date; + firstPetAt: Date | null; + firstPostAt: Date | null; + lastActiveAt: Date | null; +} + +export interface Rate { + count: number; + of: number; +} + +export interface GrowthWeek { + weekStart: string; + newOwners: number; + activated: Rate; + retainedD7: Rate; + retainedD30: Rate; +} + +export function weekStartUTC(date: Date): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() || 7) - 1)); + return d; +} + +/** Ältester Wochenanfang des Auswertungsfensters. */ +export function growthWindowStart(now: Date, weeks = GROWTH_WEEKS): Date { + const start = weekStartUTC(now); + start.setUTCDate(start.getUTCDate() - 7 * (weeks - 1)); + return start; +} + +function retained(o: OwnerActivity, days: number, now: Date): Rate { + const due = o.createdAt.getTime() + days * DAY_MS; + if (due > now.getTime()) return { count: 0, of: 0 }; // noch nicht auswertbar + return { count: o.lastActiveAt && o.lastActiveAt.getTime() >= due ? 1 : 0, of: 1 }; +} + +const add = (a: Rate, b: Rate): Rate => ({ count: a.count + b.count, of: a.of + b.of }); +const ZERO: Rate = { count: 0, of: 0 }; + +export function computeGrowth(owners: OwnerActivity[], now: Date, weeks = GROWTH_WEEKS) { + const start = growthWindowStart(now, weeks); + const rows: GrowthWeek[] = Array.from({ length: weeks }, (_, i) => ({ + weekStart: new Date(start.getTime() + i * 7 * DAY_MS).toISOString(), + newOwners: 0, + activated: ZERO, + retainedD7: ZERO, + retainedD30: ZERO, + })); + + for (const o of owners) { + if (!o.firstPetAt) continue; + const index = Math.floor((weekStartUTC(o.createdAt).getTime() - start.getTime()) / (7 * DAY_MS)); + const row = rows[index]; + if (!row) continue; + const activated = !!o.firstPostAt && o.firstPostAt.getTime() - o.firstPetAt.getTime() <= ACTIVATION_MS; + row.newOwners += 1; + row.activated = add(row.activated, { count: activated ? 1 : 0, of: 1 }); + row.retainedD7 = add(row.retainedD7, retained(o, 7, now)); + row.retainedD30 = add(row.retainedD30, retained(o, 30, now)); + } + + const total = rows.reduce( + (t, r) => ({ + newOwners: t.newOwners + r.newOwners, + activated: add(t.activated, r.activated), + retainedD7: add(t.retainedD7, r.retainedD7), + retainedD30: add(t.retainedD30, r.retainedD30), + }), + { newOwners: 0, activated: ZERO, retainedD7: ZERO, retainedD30: ZERO } + ); + return { weeks: rows, total }; +} + +type Db = Pick; + +const latest = (...dates: (Date | null | undefined)[]): Date | null => + dates.reduce((max, d) => (d && (!max || d > max) ? d : max), null); + +/** Lädt die Aktivität aller Owner, die sich im Auswertungsfenster registriert haben (nur Aggregat-Abfragen). */ +export async function loadOwnerActivity(db: Db, since: Date): Promise { + const owners = await db.owner.findMany({ where: { createdAt: { gte: since } }, select: { id: true, createdAt: true } }); + const ownerIds = owners.map((o) => o.id); + if (ownerIds.length === 0) return []; + + const pets = await db.pet.findMany({ where: { ownerId: { in: ownerIds } }, select: { id: true, ownerId: true, createdAt: true } }); + const petIds = pets.map((p) => p.id); + const byPet = { petId: { in: petIds } }; + + const [posts, stories, reactions, comments, messages, tokens] = await Promise.all([ + db.post.groupBy({ by: ["petId"], where: byPet, _min: { createdAt: true }, _max: { createdAt: true } }), + db.story.groupBy({ by: ["petId"], where: byPet, _max: { createdAt: true } }), + db.reaction.groupBy({ by: ["petId"], where: byPet, _max: { createdAt: true } }), + db.comment.groupBy({ by: ["petId"], where: byPet, _max: { createdAt: true } }), + db.message.groupBy({ by: ["senderPetId"], where: { senderPetId: { in: petIds } }, _max: { createdAt: true } }), + db.pushToken.groupBy({ by: ["ownerId"], where: { ownerId: { in: ownerIds } }, _max: { lastSeenAt: true } }), + ]); + + const lastByPet = new Map(); + const note = (petId: string, d: Date | null) => lastByPet.set(petId, latest(lastByPet.get(petId), d)); + for (const r of [...posts, ...stories, ...reactions, ...comments]) note(r.petId, r._max.createdAt); + for (const r of messages) note(r.senderPetId, r._max.createdAt); + const firstPostByPet = new Map(posts.map((r) => [r.petId, r._min.createdAt])); + const tokenByOwner = new Map(tokens.map((t) => [t.ownerId, t._max.lastSeenAt])); + + return owners.map((o) => { + const own = pets.filter((p) => p.ownerId === o.id); + const firstPetAt = own.reduce((min, p) => (!min || p.createdAt < min ? p.createdAt : min), null); + const firstPostAt = own.reduce((min, p) => { + const d = firstPostByPet.get(p.id); + return d && (!min || d < min) ? d : min; + }, null); + const lastActiveAt = latest(tokenByOwner.get(o.id), ...own.map((p) => lastByPet.get(p.id))); + return { createdAt: o.createdAt, firstPetAt, firstPostAt, lastActiveAt }; + }); +} diff --git a/src/trpc/routers/admin-procedures/dashboard.ts b/src/trpc/routers/admin-procedures/dashboard.ts index 37391ed..1793cd7 100644 --- a/src/trpc/routers/admin-procedures/dashboard.ts +++ b/src/trpc/routers/admin-procedures/dashboard.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { protectedProcedure } from "@/trpc/init"; import { getDailyVisitCount, getUniqueVisitorCount, lastNDays, daysInRange } from "@/lib/visit-tracking"; import { assertAdmin } from "@/trpc/routers/admin-procedures/guards"; +import { computeGrowth, growthWindowStart, loadOwnerActivity } from "@/lib/growth-metrics"; /** Dashboard-Zahlen und Zeitreihen. Teil von `trpc.admin.*` (admin.ts). */ @@ -61,6 +62,14 @@ async function countCreatedOverTime( } export const adminDashboardProcedures = { + /** Wachstums-Kennzahlen pro Registrierungswoche (Seite „Wachstum“, src/lib/growth-metrics.ts). */ + getGrowth: protectedProcedure.query(async ({ ctx }) => { + await assertAdmin(ctx.prisma, ctx.userId); + const now = new Date(); + const owners = await loadOwnerActivity(ctx.prisma, growthWindowStart(now)); + return computeGrowth(owners, now); + }), + // ─── Dashboard ─────────────────────────────────────────────────────────────── getStats: protectedProcedure.query(async ({ ctx }) => { await assertAdmin(ctx.prisma, ctx.userId);