feat(admin): Seite Wachstum – neue Halter, Aktivierung und Bindung pro Woche (Phase 0 D11)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 09:13:40 +02:00
1 parent d022d1c02f
commit d0c9ecca97
5 files changed
+325 -1

No files matched your search

+104
View File
@@ -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 (
<td className="px-3 py-2 text-right font-mono tabular-nums">
{percent(rate)}
{rate.of > 0 && <span className="ml-1.5 text-xs text-muted-foreground">{rate.count}/{rate.of}</span>}
</td>
);
}
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 <Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />;
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 (
<div className="mx-auto max-w-6xl space-y-6">
<AdminPageHeader
title="Wachstum"
description="Neue Halter, Aktivierung und Bindung pro Registrierungswoche."
/>
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{tiles.map((tile) => (
<div key={tile.label} className="rounded-xl border bg-card p-4">
<dt className="text-xs text-muted-foreground">{tile.label}</dt>
<dd className="mt-1 font-mono text-2xl font-semibold tabular-nums">{tile.value}</dd>
<dd className="mt-0.5 text-xs text-muted-foreground">{tile.hint}</dd>
</div>
))}
</dl>
<AdminSection
title="Pro Woche"
description="Kohorte = Woche der Registrierung. Bindung zählt erst, wenn Tag 7 bzw. 30 erreicht ist."
>
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] text-sm">
<thead>
<tr className="border-b text-xs uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2 text-left font-medium">Woche ab</th>
<th className="px-3 py-2 text-right font-medium">Neue Halter</th>
<th className="px-3 py-2 text-right font-medium">Aktivierung</th>
<th className="px-3 py-2 text-right font-medium">Tag 7</th>
<th className="px-3 py-2 text-right font-medium">Tag 30</th>
</tr>
</thead>
<tbody className="divide-y">
{[...data.weeks].reverse().map((week) => (
<tr key={week.weekStart}>
<td className="px-3 py-2">{weekLabel(week.weekStart)}</td>
<td className="px-3 py-2 text-right font-mono tabular-nums">{week.newOwners}</td>
<RateCell rate={week.activated} />
<RateCell rate={week.retainedD7} />
<RateCell rate={week.retainedD30} />
</tr>
))}
</tbody>
</table>
</div>
</AdminSection>
<p className="text-sm text-muted-foreground">
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{" "}
<Link href={`/p/${secret}/referrals`} className="font-medium text-primary hover:underline">
Empfehlungen
</Link>
.
</p>
</div>
);
}
+8 -1
View File
@@ -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: [
+68
View File
@@ -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<OwnerActivity> & { 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");
});
});
+136
View File
@@ -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<PrismaClient, "owner" | "pet" | "post" | "story" | "reaction" | "comment" | "message" | "pushToken">;
const latest = (...dates: (Date | null | undefined)[]): Date | null =>
dates.reduce<Date | null>((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<OwnerActivity[]> {
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<string, Date | null>();
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<Date | null>((min, p) => (!min || p.createdAt < min ? p.createdAt : min), null);
const firstPostAt = own.reduce<Date | null>((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 };
});
}
@@ -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);