diff --git a/app/admin/page.tsx b/app/admin/page.tsx index a1ab8e8..e8631b0 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,55 +1,382 @@ +import { createDefaultForm } from "@/app/admin/form/actions"; import { AdminShellServer } from "@/components/admin-shell-server"; -import Link from "next/link"; +import { RealtimeRefresh } from "@/components/realtime-refresh"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, + CardContent, + CardDescription, CardHeader, CardTitle, - CardDescription, } from "@/components/ui/card"; -import { ClipboardList, Server } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { getAdminDashboardData } from "@/lib/admin-dashboard"; +import { + ClipboardList, + FilePlus2, + Inbox, + ListChecks, + Server, +} from "lucide-react"; +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +function formatNumber(value: number) { + return new Intl.NumberFormat("en-US").format(value); +} + +function formatDate(date: Date | null) { + if (!date) return "No responses"; + return date.toLocaleString("en-GB", { + timeZone: "Asia/Bangkok", + hour12: false, + }); +} + +function respondentName(submission: { + userName: string | null; + userDiscordId: string | null; +}) { + return submission.userName || submission.userDiscordId || "Anonymous"; +} + +function StatCard({ + title, + value, + description, + icon: Icon, +}: { + title: string; + value: number; + description: string; + icon: typeof ClipboardList; +}) { + return ( + + +
+ {title} + {formatNumber(value)} +
+ +
+ +

{description}

+
+
+ ); +} + +export default async function AdminPage() { + const { summary, recentSubmissions, activeForms } = + await getAdminDashboardData(); -export default function AdminPage() { return ( -
-
-

Dashboard

-

Admin control panel

+ + +
+
+
+

Dashboard

+

+ Forms, responses, and activity at a glance. +

+
+
+ +
-
- - - -
- -
- Forms - - Manage custom forms and view submissions - -
-
-
-
- - - - - -
- -
- Backend - - Follower API settings and cache management - -
-
-
-
- +
+ + + +
+ +
+ + +
+
+ Recent responses + + Latest submissions across all forms. + +
+ +
+
+ + {recentSubmissions.length === 0 ? ( +
+ Responses will appear here after people submit a form. +
+ ) : ( + <> +
+ + + + Respondent + Form + Submitted + Status + + + + {recentSubmissions.map((submission) => ( + + + + {respondentName(submission)} + + {submission.userName && + submission.userDiscordId && ( +

+ {submission.userDiscordId} +

+ )} +
+ + + {submission.formTitle} + + + {formatDate(submission.submittedAt)} + + + {submission.edited ? "Edited" : "Original"} + + +
+ ))} +
+
+
+
+ {recentSubmissions.map((submission) => ( + + +
+
+ + + {respondentName(submission)} + + + + {submission.formTitle} + +
+ + {submission.edited ? "Edited" : "Original"} + +
+
+ + {formatDate(submission.submittedAt)} + +
+ ))} +
+ + )} +
+
+ +
+ + + +
+ +
+ Forms + + Manage forms and view submissions + +
+
+
+
+ + + + +
+ +
+ Backend + + Follower API settings and cache + +
+
+
+
+ +
+
+ + + + Form activity + + Forms ranked by responses in the last 7 days. + + + + {activeForms.length === 0 ? ( +
+

+ No forms found. Create one to start collecting responses. +

+
+ +
+
+ ) : ( + <> +
+ + + + Form + Status + Today + Last 7 days + Total + Last response + Actions + + + + {activeForms.map((form) => ( + + + + {form.title} + + + + + {form.isOpen ? "Open" : "Closed"} + + + {formatNumber(form.responsesToday)} + + {formatNumber(form.responsesLast7Days)} + + {formatNumber(form.totalResponses)} + {formatDate(form.lastResponseAt)} + + + + + ))} + +
+
+
+ {activeForms.map((form) => ( + + +
+
+ + + {form.title} + + + + Last response: {formatDate(form.lastResponseAt)} + +
+ + {form.isOpen ? "Open" : "Closed"} + +
+
+ +
+ {formatNumber(form.responsesLast7Days)} this week ยท{" "} + {formatNumber(form.totalResponses)} total +
+ +
+
+ ))} +
+ + )} +
+
); diff --git a/lib/admin-dashboard.test.ts b/lib/admin-dashboard.test.ts new file mode 100644 index 0000000..f7bb049 --- /dev/null +++ b/lib/admin-dashboard.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { getDashboardDateWindows } from "@/lib/admin-dashboard"; + +describe("admin dashboard date windows", () => { + test("uses Bangkok-local midnight for today", () => { + const windows = getDashboardDateWindows( + new Date("2026-07-27T18:30:00.000Z") + ); + + expect(windows.todayStart.toISOString()).toBe("2026-07-27T17:00:00.000Z"); + expect(windows.tomorrowStart.toISOString()).toBe("2026-07-28T17:00:00.000Z"); + }); + + test("includes today and the previous six Bangkok calendar days", () => { + const windows = getDashboardDateWindows( + new Date("2026-07-28T05:00:00.000Z") + ); + + expect(windows.todayStart.toISOString()).toBe("2026-07-27T17:00:00.000Z"); + expect(windows.last7DaysStart.toISOString()).toBe( + "2026-07-21T17:00:00.000Z" + ); + }); +}); diff --git a/lib/admin-dashboard.ts b/lib/admin-dashboard.ts new file mode 100644 index 0000000..4f0a6eb --- /dev/null +++ b/lib/admin-dashboard.ts @@ -0,0 +1,157 @@ +import { db } from "@/db"; +import { forms, submissions } from "@/db/schema"; +import { desc, eq, sql } from "drizzle-orm"; +import { max } from "drizzle-orm/sql/functions/aggregate"; + +export type DashboardDateWindows = { + todayStart: Date; + tomorrowStart: Date; + last7DaysStart: Date; +}; + +export type AdminDashboardData = { + summary: { + totalForms: number; + openForms: number; + closedForms: number; + totalResponses: number; + responsesToday: number; + responsesLast7Days: number; + }; + recentSubmissions: { + id: string; + formId: string; + formTitle: string; + userName: string | null; + userDiscordId: string | null; + submittedAt: Date; + edited: boolean; + }[]; + activeForms: { + id: string; + title: string; + isOpen: boolean; + totalResponses: number; + responsesToday: number; + responsesLast7Days: number; + lastResponseAt: Date | null; + }[]; +}; + +export function getDashboardDateWindows(now = new Date()): DashboardDateWindows { + const bangkokDate = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Bangkok", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now); + const todayStart = new Date(`${bangkokDate}T00:00:00+07:00`); + const tomorrowStart = new Date(todayStart); + tomorrowStart.setUTCDate(tomorrowStart.getUTCDate() + 1); + + const last7DaysStart = new Date(todayStart); + last7DaysStart.setUTCDate(last7DaysStart.getUTCDate() - 6); + + return { todayStart, tomorrowStart, last7DaysStart }; +} + +function toPostgresTimestamp(date: Date) { + return date.toISOString().replace("T", " ").replace("Z", ""); +} + +export async function getAdminDashboardData( + now = new Date() +): Promise { + const { todayStart, tomorrowStart, last7DaysStart } = + getDashboardDateWindows(now); + const todayStartSql = toPostgresTimestamp(todayStart); + const tomorrowStartSql = toPostgresTimestamp(tomorrowStart); + const last7DaysStartSql = toPostgresTimestamp(last7DaysStart); + const totalResponses = sql`count(${submissions.id})::integer`; + const responsesToday = sql`count(${submissions.id}) filter ( + where ${submissions.submittedAt} >= ${todayStartSql}::timestamp + and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp + )::integer`; + const responsesLast7Days = sql`count(${submissions.id}) filter ( + where ${submissions.submittedAt} >= ${last7DaysStartSql}::timestamp + and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp + )::integer`; + + const [formSummaryRows, responseSummaryRows, recentSubmissions, activeForms] = + await Promise.all([ + db + .select({ + totalForms: sql`count(*)::integer`, + openForms: sql`count(*) filter (where ${forms.isOpen})::integer`, + closedForms: sql`count(*) filter (where not ${forms.isOpen})::integer`, + }) + .from(forms), + db + .select({ + totalResponses: sql`count(*)::integer`, + responsesToday: sql`count(*) filter ( + where ${submissions.submittedAt} >= ${todayStartSql}::timestamp + and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp + )::integer`, + responsesLast7Days: sql`count(*) filter ( + where ${submissions.submittedAt} >= ${last7DaysStartSql}::timestamp + and ${submissions.submittedAt} < ${tomorrowStartSql}::timestamp + )::integer`, + }) + .from(submissions), + db + .select({ + id: submissions.id, + formId: submissions.formId, + formTitle: forms.title, + userName: submissions.userName, + userDiscordId: submissions.userDiscordId, + submittedAt: submissions.submittedAt, + edited: sql`jsonb_array_length(coalesce(${submissions.editHistory}, '[]'::jsonb)) > 0`, + }) + .from(submissions) + .innerJoin(forms, eq(forms.id, submissions.formId)) + .orderBy(desc(submissions.submittedAt), desc(submissions.id)) + .limit(6), + db + .select({ + id: forms.id, + title: forms.title, + isOpen: forms.isOpen, + totalResponses, + responsesToday, + responsesLast7Days, + lastResponseAt: max(submissions.submittedAt), + }) + .from(forms) + .leftJoin(submissions, eq(submissions.formId, forms.id)) + .groupBy(forms.id, forms.title, forms.isOpen, forms.createdAt) + .orderBy( + desc(responsesLast7Days), + desc(totalResponses), + desc(forms.createdAt), + desc(forms.id) + ) + .limit(5), + ]); + + const formSummary = formSummaryRows[0] ?? { + totalForms: 0, + openForms: 0, + closedForms: 0, + }; + const responseSummary = responseSummaryRows[0] ?? { + totalResponses: 0, + responsesToday: 0, + responsesLast7Days: 0, + }; + + return { + summary: { + ...formSummary, + ...responseSummary, + }, + recentSubmissions, + activeForms, + }; +} diff --git a/lib/form-submission-stats.ts b/lib/form-submission-stats.ts index 135ee08..8aae146 100644 --- a/lib/form-submission-stats.ts +++ b/lib/form-submission-stats.ts @@ -1,5 +1,6 @@ import { db } from "@/db"; -import { sql } from "drizzle-orm"; +import { submissions } from "@/db/schema"; +import { asc, count, inArray } from "drizzle-orm"; export type FormSubmissionStats = { totalAnswers: number; @@ -14,40 +15,60 @@ export async function getFormSubmissionStats( return new Map(); } - const rows = await db.execute<{ - formId: string; - totalAnswers: number; - queueNumber: number | null; - }>(sql` - with ranked_submissions as ( - select - form_id, - user_discord_id, - count(*) over (partition by form_id)::integer as total_answers, - row_number() over ( - partition by form_id - order by submitted_at asc, id asc - )::integer as queue_number - from form.submission - where form_id in (${sql.join(formIds.map((formId) => sql`${formId}`), sql`, `)}) - ) - select - form_id as "formId", - max(total_answers)::integer as "totalAnswers", - max(queue_number) filter ( - where user_discord_id = ${discordId} - )::integer as "queueNumber" - from ranked_submissions - group by form_id - `); + const totals = await db + .select({ + formId: submissions.formId, + totalAnswers: count(), + }) + .from(submissions) + .where(inArray(submissions.formId, formIds)) + .groupBy(submissions.formId); - return new Map( - rows.map((row) => [ + const stats = new Map( + totals.map((row) => [ row.formId, { totalAnswers: row.totalAnswers, - queueNumber: row.queueNumber, + queueNumber: null, }, ]) ); + + if (!discordId) { + return stats; + } + + const orderedSubmissions = await db + .select({ + formId: submissions.formId, + userDiscordId: submissions.userDiscordId, + }) + .from(submissions) + .where(inArray(submissions.formId, formIds)) + .orderBy( + asc(submissions.formId), + asc(submissions.submittedAt), + asc(submissions.id) + ); + + const queueByForm = new Map(); + const lastUserQueueByForm = new Map(); + + for (const submission of orderedSubmissions) { + const queueNumber = (queueByForm.get(submission.formId) ?? 0) + 1; + queueByForm.set(submission.formId, queueNumber); + if (submission.userDiscordId === discordId) { + lastUserQueueByForm.set(submission.formId, queueNumber); + } + } + + for (const [formId, queueNumber] of lastUserQueueByForm) { + const current = stats.get(formId) ?? { + totalAnswers: queueByForm.get(formId) ?? 0, + queueNumber: null, + }; + stats.set(formId, { ...current, queueNumber }); + } + + return stats; }