+ 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);