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:
1 parent
d022d1c02f
commit
d0c9ecca97
5 files changed
+325
-1
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user