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