diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 7ffa93f..7316bfe 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -6,6 +6,7 @@ import { Sidebar } from "@/components/layout/Sidebar"; import { MobileNav } from "@/components/layout/MobileNav"; import { ActivePetInitializer } from "@/components/layout/ActivePetInitializer"; import { LegalUpdateBanner } from "@/components/legal/LegalUpdateBanner"; +import { recordVisit } from "@/lib/visit-tracking"; /** * Protected app shell layout — applied to all routes under /(app)/. @@ -49,6 +50,10 @@ export default async function AppLayout({ redirect("/banned"); } + // DAU/MAU source of truth (Gitea #10 Phase 3) — idempotent SADD, safe to + // call on every render, never blocks navigation on failure. + await recordVisit(userId); + // No pets → send to onboarding to create their first pet if (pets.length === 0) { redirect("/onboarding/pet"); diff --git a/src/app/p/[secret]/page.tsx b/src/app/p/[secret]/page.tsx index 2672363..23d1394 100644 --- a/src/app/p/[secret]/page.tsx +++ b/src/app/p/[secret]/page.tsx @@ -3,7 +3,20 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; -import { Users, PawPrint, FileText, ShieldOff, Megaphone, Flag } from "lucide-react"; +import { + Users, + PawPrint, + FileText, + ShieldOff, + Megaphone, + Flag, + Activity, + CalendarDays, + Ghost, + Ban, + Clock, + BadgeCheck, +} from "lucide-react"; import { TimeSeriesChart } from "@/components/admin/TimeSeriesChart"; import { GranularityToggle, type Granularity } from "@/components/admin/GranularityToggle"; @@ -13,6 +26,9 @@ export default function AdminDashboard() { const [postsGranularity, setPostsGranularity] = useState("day"); const [ownerGranularity, setOwnerGranularity] = useState("day"); + const [visitsGranularity, setVisitsGranularity] = useState("day"); + const [commentsGranularity, setCommentsGranularity] = useState("day"); + const [reactionsGranularity, setReactionsGranularity] = useState("day"); const { data: postsOverTime } = useQuery( trpc.admin.getPostsOverTime.queryOptions({ granularity: postsGranularity }) @@ -20,6 +36,15 @@ export default function AdminDashboard() { const { data: ownerGrowth } = useQuery( trpc.admin.getOwnerGrowth.queryOptions({ granularity: ownerGranularity }) ); + const { data: visitsOverTime } = useQuery( + trpc.admin.getVisitsOverTime.queryOptions({ granularity: visitsGranularity }) + ); + const { data: commentsOverTime } = useQuery( + trpc.admin.getCommentsOverTime.queryOptions({ granularity: commentsGranularity }) + ); + const { data: reactionsOverTime } = useQuery( + trpc.admin.getReactionsOverTime.queryOptions({ granularity: reactionsGranularity }) + ); return (
@@ -31,13 +56,19 @@ export default function AdminDashboard() { {isLoading ? (
Loading…
) : ( -
+
+ + + + + +
)} @@ -73,6 +104,54 @@ export default function AdminDashboard() { valueLabel="owners" />
+ +
+
+
+

Visits over time

+

Unique active owners per {visitsGranularity}

+
+ +
+ +
+ +
+
+
+

Comments over time

+

New comments per {commentsGranularity}

+
+ +
+ +
+ +
+
+
+

Likes over time

+

New reactions per {reactionsGranularity}

+
+ +
+ +
); diff --git a/src/lib/visit-tracking.ts b/src/lib/visit-tracking.ts new file mode 100644 index 0000000..0fb2d12 --- /dev/null +++ b/src/lib/visit-tracking.ts @@ -0,0 +1,68 @@ +import "server-only"; +import { redis } from "@/lib/redis"; + +// Retention covers a full trailing month plus slack, so MAU rollups (last 30 +// days) always have their daily sets available when computed. +const VISIT_RETENTION_SECONDS = 35 * 24 * 60 * 60; + +function visitKey(date: Date): string { + return `visits:${date.toISOString().slice(0, 10)}`; // "visits:YYYY-MM-DD" +} + +/** + * Marks `userId` as active today. SADD is idempotent, so this is safe to call + * on every request without over-counting — no client-side "once per day" + * gating needed. Fails silently (same fail-open spirit as rate-limit.ts): a + * Redis hiccup must never block a page render. + */ +export async function recordVisit(userId: string): Promise { + try { + const key = visitKey(new Date()); + await redis.sadd(key, userId); + await redis.expire(key, VISIT_RETENTION_SECONDS); + } catch { + // best-effort — visit analytics are not load-bearing + } +} + +/** Unique visitor count for a single calendar day (UTC). */ +export async function getDailyVisitCount(date: Date): Promise { + try { + return await redis.scard(visitKey(date)); + } catch { + return 0; + } +} + +/** Unique visitor count across a set of days — e.g. a week/month bucket, or trailing 30 days for MAU. */ +export async function getUniqueVisitorCount(dates: Date[]): Promise { + if (dates.length === 0) return 0; + try { + const members = await redis.sunion(...dates.map(visitKey)); + return members.length; + } catch { + return 0; + } +} + +/** `n` UTC calendar days ending today (inclusive), newest first — the window for MAU. */ +export function lastNDays(n: number): Date[] { + const days: Date[] = []; + const cursor = new Date(); + for (let i = 0; i < n; i++) { + days.push(new Date(cursor)); + cursor.setUTCDate(cursor.getUTCDate() - 1); + } + return days; +} + +/** All UTC calendar days in [start, endExclusive) — used to union daily visit sets into a bucket. */ +export function daysInRange(start: Date, endExclusive: Date): Date[] { + const days: Date[] = []; + const cursor = new Date(start); + while (cursor < endExclusive) { + days.push(new Date(cursor)); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return days; +} diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index edc5a7f..8f4fc4c 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -16,6 +16,7 @@ import { sendMail } from "@/lib/mail"; import { deleteOwnerAccount } from "@/lib/delete-account"; import { ensureTeamPet, sendTeamMessage } from "@/lib/team-pet"; import { matchesWordBlacklist, matchesLinkBlacklist } from "@/lib/blacklist"; +import { getDailyVisitCount, getUniqueVisitorCount, lastNDays, daysInRange } from "@/lib/visit-tracking"; const DATA_EXPORT_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days @@ -139,15 +140,47 @@ export const adminRouter = router({ // ─── Dashboard ─────────────────────────────────────────────────────────────── getStats: protectedProcedure.query(async ({ ctx }) => { await assertAdmin(ctx.prisma, ctx.userId); - const [owners, pets, posts, bans, ads, reports] = await Promise.all([ + const [ + owners, + pets, + posts, + bans, + ads, + reports, + shadowbans, + blacklistEntries, + verificationPending, + verificationClosed, + dau, + mau, + ] = await Promise.all([ ctx.prisma.owner.count(), ctx.prisma.pet.count(), ctx.prisma.post.count(), ctx.prisma.userBan.count(), ctx.prisma.advertisement.count({ where: { isActive: true } }), ctx.prisma.report.count(), + ctx.prisma.shadowban.count(), + ctx.prisma.blacklistEntry.count(), + ctx.prisma.verificationRequest.count({ where: { status: "PENDING" } }), + ctx.prisma.verificationRequest.count({ where: { status: { in: ["APPROVED", "REJECTED"] } } }), + getDailyVisitCount(new Date()), + getUniqueVisitorCount(lastNDays(30)), ]); - return { owners, pets, posts, bans, activeAds: ads, reports }; + return { + owners, + pets, + posts, + bans, + activeAds: ads, + reports, + shadowbans, + blacklistEntries, + verificationPending, + verificationClosed, + dau, + mau, + }; }), /** New posts per bucket over the trailing window (30 days / 12 weeks / 12 months). */ @@ -183,6 +216,56 @@ export const adminRouter = router({ return buckets.map((date, i) => ({ date: date.toISOString(), value: cumulative[i]! })); }), + /** New comments per bucket over the trailing window. */ + getCommentsOverTime: protectedProcedure + .input(z.object({ granularity: z.enum(["day", "week", "month"]) })) + .query(async ({ ctx, input }) => { + await assertAdmin(ctx.prisma, ctx.userId); + const buckets = buildBuckets(input.granularity); + const comments = await ctx.prisma.comment.findMany({ + where: { createdAt: { gte: buckets[0] } }, + select: { createdAt: true }, + }); + const counts = countByBucket(comments.map((c) => c.createdAt), buckets, input.granularity); + return buckets.map((date, i) => ({ date: date.toISOString(), value: counts[i]! })); + }), + + /** New reactions ("likes") per bucket over the trailing window. */ + getReactionsOverTime: protectedProcedure + .input(z.object({ granularity: z.enum(["day", "week", "month"]) })) + .query(async ({ ctx, input }) => { + await assertAdmin(ctx.prisma, ctx.userId); + const buckets = buildBuckets(input.granularity); + const reactions = await ctx.prisma.reaction.findMany({ + where: { createdAt: { gte: buckets[0] } }, + select: { createdAt: true }, + }); + const counts = countByBucket(reactions.map((r) => r.createdAt), buckets, input.granularity); + return buckets.map((date, i) => ({ date: date.toISOString(), value: counts[i]! })); + }), + + /** + * Unique daily visitors per bucket (Redis-backed, see visit-tracking.ts) — + * the DAU trend line. Week/month buckets union each day's set in range + * rather than summing daily counts, so a repeat visitor within the bucket + * is still counted once. + */ + getVisitsOverTime: protectedProcedure + .input(z.object({ granularity: z.enum(["day", "week", "month"]) })) + .query(async ({ ctx, input }) => { + await assertAdmin(ctx.prisma, ctx.userId); + const buckets = buildBuckets(input.granularity); + const tomorrow = truncateUTC(new Date(), "day"); + tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); + const values = await Promise.all( + buckets.map((bucketStart, i) => { + const bucketEnd = i + 1 < buckets.length ? buckets[i + 1]! : tomorrow; + return getUniqueVisitorCount(daysInRange(bucketStart, bucketEnd)); + }) + ); + return buckets.map((date, i) => ({ date: date.toISOString(), value: values[i]! })); + }), + // ─── Post moderation ───────────────────────────────────────────────────────── listPosts: protectedProcedure .input(