feat(admin): DAU/MAU tracking and expanded dashboard stats (Gitea #10 Phase 3, part 4/4)
Redis-backed visit tracking (src/lib/visit-tracking.ts): daily SADD sets keyed "visits:YYYY-MM-DD", same fail-open idiom as the existing rate-limit.ts. Recorded once per authenticated (app) layout render — SADD is idempotent so no client-side dedup is needed. DAU = today's SCARD, MAU = SUNION across the trailing 30 daily sets. Dashboard additions: - 6 new stat cards: DAU, MAU, shadowban count, blacklist entry count, verifications open/closed. - 3 new time-series charts (reusing the existing TimeSeriesChart/ GranularityToggle components): visits, comments, and reactions over time. Comments/reactions are plain Postgres bucket queries cloned from the existing getPostsOverTime pattern; visits reroutes through the new Redis helpers per bucket. This closes out Gitea #10 Phase 3 (hide-without-delete, shadowban, blacklist, and now analytics — all four parts shipped).
This commit is contained in:
1 parent
a35cb6640f
commit
4000a1eed9
4 files changed
+239
-4
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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<Granularity>("day");
|
||||
const [ownerGranularity, setOwnerGranularity] = useState<Granularity>("day");
|
||||
const [visitsGranularity, setVisitsGranularity] = useState<Granularity>("day");
|
||||
const [commentsGranularity, setCommentsGranularity] = useState<Granularity>("day");
|
||||
const [reactionsGranularity, setReactionsGranularity] = useState<Granularity>("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 (
|
||||
<div className="space-y-6">
|
||||
@@ -31,13 +56,19 @@ export default function AdminDashboard() {
|
||||
{isLoading ? (
|
||||
<div className="text-zinc-500 text-sm">Loading…</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon={Users} label="Owners" value={stats?.owners ?? 0} />
|
||||
<StatCard icon={PawPrint} label="Pets" value={stats?.pets ?? 0} />
|
||||
<StatCard icon={FileText} label="Posts" value={stats?.posts ?? 0} />
|
||||
<StatCard icon={ShieldOff} label="Banned users" value={stats?.bans ?? 0} color="text-red-400" />
|
||||
<StatCard icon={Megaphone} label="Active ads" value={stats?.activeAds ?? 0} color="text-orange-400" />
|
||||
<StatCard icon={Flag} label="Open reports" value={stats?.reports ?? 0} color="text-yellow-400" />
|
||||
<StatCard icon={Activity} label="DAU (today)" value={stats?.dau ?? 0} color="text-emerald-400" />
|
||||
<StatCard icon={CalendarDays} label="MAU (30d)" value={stats?.mau ?? 0} color="text-emerald-400" />
|
||||
<StatCard icon={Ghost} label="Shadowbanned" value={stats?.shadowbans ?? 0} color="text-violet-400" />
|
||||
<StatCard icon={Ban} label="Blacklist entries" value={stats?.blacklistEntries ?? 0} color="text-zinc-300" />
|
||||
<StatCard icon={Clock} label="Verifications open" value={stats?.verificationPending ?? 0} color="text-amber-400" />
|
||||
<StatCard icon={BadgeCheck} label="Verifications closed" value={stats?.verificationClosed ?? 0} color="text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -73,6 +104,54 @@ export default function AdminDashboard() {
|
||||
valueLabel="owners"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Visits over time</h2>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Unique active owners per {visitsGranularity}</p>
|
||||
</div>
|
||||
<GranularityToggle value={visitsGranularity} onChange={setVisitsGranularity} />
|
||||
</div>
|
||||
<TimeSeriesChart
|
||||
data={visitsOverTime ?? []}
|
||||
granularity={visitsGranularity}
|
||||
variant="bar"
|
||||
valueLabel="visits"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Comments over time</h2>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">New comments per {commentsGranularity}</p>
|
||||
</div>
|
||||
<GranularityToggle value={commentsGranularity} onChange={setCommentsGranularity} />
|
||||
</div>
|
||||
<TimeSeriesChart
|
||||
data={commentsOverTime ?? []}
|
||||
granularity={commentsGranularity}
|
||||
variant="bar"
|
||||
valueLabel="comments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Likes over time</h2>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">New reactions per {reactionsGranularity}</p>
|
||||
</div>
|
||||
<GranularityToggle value={reactionsGranularity} onChange={setReactionsGranularity} />
|
||||
</div>
|
||||
<TimeSeriesChart
|
||||
data={reactionsOverTime ?? []}
|
||||
granularity={reactionsGranularity}
|
||||
variant="bar"
|
||||
valueLabel="likes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<void> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user