From 78373b76cc66bdfa8f9bab6a3ec02b00445ca6d8 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 9 Aug 2026 21:58:19 +0700 Subject: [PATCH] perf(admin): paginate responses on the server --- app/admin/form/[id]/result/client.tsx | 148 ------------------- app/admin/form/[id]/result/page.tsx | 204 ++++++++++++++++++++++---- lib/form/admin-form-results.ts | 6 +- 3 files changed, 183 insertions(+), 175 deletions(-) delete mode 100644 app/admin/form/[id]/result/client.tsx diff --git a/app/admin/form/[id]/result/client.tsx b/app/admin/form/[id]/result/client.tsx deleted file mode 100644 index 72a3b7e..0000000 --- a/app/admin/form/[id]/result/client.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useMemo, useState } from "react"; -import { Kbd } from "@/components/ui/kbd"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination"; -import { DeleteAllSubmissionsButton, DeleteSubmissionButton } from "@/components/delete-buttons"; -import { ResultsFilters } from "@/components/results-filters"; -import type { DiscordMemberProfile } from "@/lib/discord/discord"; - -const RESULTS_PAGE_SIZE = 25; -type ResultFilters = { - q: string; - page: number; - sort: "submittedAt" | "respondent"; - direction: "asc" | "desc"; - edited: "all" | "edited" | "unedited"; - dateFrom: string; - dateTo: string; -}; - -function resultFiltersToSearchParams(filters: ResultFilters) { - const params = new URLSearchParams(); - if (filters.q) params.set("q", filters.q); - if (filters.page > 1) params.set("page", String(filters.page)); - if (filters.sort !== "submittedAt") params.set("sort", filters.sort); - if (filters.direction !== "desc") params.set("direction", filters.direction); - if (filters.edited !== "all") params.set("edited", filters.edited); - if (filters.dateFrom) params.set("dateFrom", filters.dateFrom); - if (filters.dateTo) params.set("dateTo", filters.dateTo); - return params.toString(); -} - -type ResultRow = { - id: string; - userName: string | null; - userDiscordId: string | null; - submittedAt: string; - adminNote: string | null; - editHistory: unknown[]; -}; - -function formatDate(value: string) { - return new Date(value).toLocaleString("en-GB", { - timeZone: "Asia/Bangkok", - hour12: false, - }); -} - -function bangkokDate(value: string) { - const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: "Asia/Bangkok", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date(value)); - const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); - return `${values.year}-${values.month}-${values.day}`; -} - -function RespondentName({ - row, - profile, -}: { - row: ResultRow; - profile: DiscordMemberProfile | null; -}) { - const displayName = profile?.globalName || profile?.username || row.userName || row.userDiscordId || "Anonymous"; - const username = profile?.username || row.userName; - return ( - - {displayName} - {username && username !== displayName && ({username})} - - ); -} - -export default function ResultsClient({ - formId, - initialFilters, - rows, - profiles, -}: { - formId: string; - initialFilters: ResultFilters; - rows: ResultRow[]; - profiles: Record; -}) { - const [filters, setFilters] = useState(initialFilters); - const [page, setPage] = useState(initialFilters.page); - - const filteredRows = useMemo(() => { - const query = filters.q.toLocaleLowerCase(); - return rows - .filter((row) => { - const matchesQuery = !query || `${row.userName ?? ""} ${row.userDiscordId ?? ""}`.toLocaleLowerCase().includes(query); - const edited = row.editHistory.length > 0; - const matchesEdited = filters.edited === "all" || (filters.edited === "edited" ? edited : !edited); - const date = bangkokDate(row.submittedAt); - return matchesQuery && matchesEdited && (!filters.dateFrom || date >= filters.dateFrom) && (!filters.dateTo || date <= filters.dateTo); - }) - .sort((a, b) => { - if (filters.sort === "respondent") { - const result = (a.userName || a.userDiscordId || "").localeCompare(b.userName || b.userDiscordId || ""); - return filters.direction === "asc" ? result : -result; - } - const result = new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime(); - return filters.direction === "asc" ? result : -result; - }); - }, [filters, rows]); - - const pageCount = Math.max(1, Math.ceil(filteredRows.length / RESULTS_PAGE_SIZE)); - const currentPage = Math.min(page, pageCount); - const visibleRows = filteredRows.slice((currentPage - 1) * RESULTS_PAGE_SIZE, currentPage * RESULTS_PAGE_SIZE); - const updateFilters = (next: ResultFilters) => { - const normalized = { ...next, page: 1 }; - setFilters(normalized); - setPage(1); - const query = resultFiltersToSearchParams(normalized); - window.history.replaceState(null, "", `/admin/form/${formId}/result${query ? `?${query}` : ""}`); - }; - - return ( -
-
-
-

Responses

-

{filteredRows.length} matching {filteredRows.length === 1 ? "response" : "responses"}

-
- {rows.length > 0 && } -
- - {visibleRows.length === 0 ? ( - No responses foundTry changing or clearing the current filters. - ) : ( - <> - RespondentSubmittedNoteActions - {visibleRows.map((row) => {row.userName && row.userDiscordId &&

{row.userDiscordId}

}
{formatDate(row.submittedAt)}{row.adminNote ?

{row.adminNote}

: No note}
)} -
-
{visibleRows.map((row) => {formatDate(row.submittedAt)}

Note

{row.adminNote ?

{row.adminNote}

:

No note

}
)}
- - )} - {pageCount > 1 && { event.preventDefault(); setPage(Math.max(1, currentPage - 1)); }} aria-disabled={currentPage === 1} /> event.preventDefault()}>{currentPage} / {pageCount} { event.preventDefault(); setPage(Math.min(pageCount, currentPage + 1)); }} aria-disabled={currentPage === pageCount} />} -
- ); -} diff --git a/app/admin/form/[id]/result/page.tsx b/app/admin/form/[id]/result/page.tsx index c677d2e..f1f2c14 100644 --- a/app/admin/form/[id]/result/page.tsx +++ b/app/admin/form/[id]/result/page.tsx @@ -1,10 +1,54 @@ -import { parseResultFilters, getFilteredSubmissions } from "@/lib/form/admin-form-results"; -import { getDiscordMemberProfile } from "@/lib/discord/discord"; +import Link from "next/link"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Kbd } from "@/components/ui/kbd"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { DeleteAllSubmissionsButton, DeleteSubmissionButton } from "@/components/delete-buttons"; import { RealtimeRefresh } from "@/components/realtime-refresh"; -import ResultsClient from "./client"; +import { ResultsFilters } from "@/components/results-filters"; +import { getDiscordMemberProfile, type DiscordMemberProfile } from "@/lib/discord/discord"; +import { + getFilteredSubmissions, + parseResultFilters, + resultFiltersToSearchParams, + type ResultFilters, +} from "@/lib/form/admin-form-results"; export const dynamic = "force-dynamic"; +type ResultRow = Awaited>["rows"][number]; + +function formatDate(value: Date) { + return value.toLocaleString("en-GB", { + timeZone: "Asia/Bangkok", + hour12: false, + }); +} + +function RespondentName({ row, profile }: { row: ResultRow; profile: DiscordMemberProfile | null }) { + const displayName = profile?.globalName || profile?.username || row.userName || row.userDiscordId || "Anonymous"; + const username = profile?.username || row.userName; + + return ( + + {displayName} + {username && username !== displayName && ({username})} + + ); +} + +function pageHref(formId: string, filters: ResultFilters, page: number) { + const query = resultFiltersToSearchParams(filters, { page }); + return `/admin/form/${formId}/result${query ? `?${query}` : ""}`; +} + export default async function ResultsListPage({ params, searchParams, @@ -13,15 +57,13 @@ export default async function ResultsListPage({ searchParams: Promise>; }) { const [{ id }, rawFilters] = await Promise.all([params, searchParams]); - const initialFilters = parseResultFilters(rawFilters); - const { rows } = await getFilteredSubmissions(id, parseResultFilters({}), { - paginate: false, - }); + const filters = parseResultFilters(rawFilters); + const { rows, total, page, pageCount } = await getFilteredSubmissions(id, filters); const discordIds = [ ...new Set( rows .map((row) => row.userDiscordId) - .filter((value): value is string => Boolean(value)) + .filter((value): value is string => Boolean(value)), ), ]; const profiles = Object.fromEntries( @@ -29,26 +71,138 @@ export default async function ResultsListPage({ discordIds.map(async (discordId) => [ discordId, await getDiscordMemberProfile(discordId), - ]) - ) + ]), + ), ); + const returnQuery = resultFiltersToSearchParams(filters, { page }); + const detailHref = (submissionId: string) => + `/admin/form/${id}/result/${submissionId}${returnQuery ? `?return=${encodeURIComponent(returnQuery)}` : ""}`; return ( - <> +
- ({ - id: row.id, - userName: row.userName, - userDiscordId: row.userDiscordId, - submittedAt: row.submittedAt.toISOString(), - adminNote: row.adminNote, - editHistory: Array.isArray(row.editHistory) ? row.editHistory : [], - }))} - profiles={profiles} - /> - +
+
+

Responses

+

+ {total} matching {total === 1 ? "response" : "responses"} +

+
+ {total > 0 && } +
+ + + + {rows.length === 0 ? ( + + + No responses found + Try changing or clearing the current filters. + + + ) : ( + <> + + + + + + Respondent + Submitted + Note + Actions + + + + {rows.map((row) => ( + + + + + + {row.userName && row.userDiscordId && ( +

{row.userDiscordId}

+ )} +
+ {formatDate(row.submittedAt)} + + {row.adminNote ? ( +

{row.adminNote}

+ ) : ( + No note + )} +
+ + + +
+ ))} +
+
+
+
+ +
+ {rows.map((row) => ( + + + + + + + + {formatDate(row.submittedAt)} + + +
+

Note

+ {row.adminNote ? ( +

{row.adminNote}

+ ) : ( +

No note

+ )} +
+
+ +
+
+
+ ))} +
+ + )} + + {pageCount > 1 && ( + + + + + + + + {page} / {pageCount} + + + + + + + + )} +
); } diff --git a/lib/form/admin-form-results.ts b/lib/form/admin-form-results.ts index 9165dd9..0930f5e 100644 --- a/lib/form/admin-form-results.ts +++ b/lib/form/admin-form-results.ts @@ -96,6 +96,8 @@ export async function getFilteredSubmissions( .from(submissions) .where(where); + const pageCount = Math.max(1, Math.ceil(total / RESULTS_PAGE_SIZE)); + const page = Math.min(filters.page, pageCount); const baseQuery = db .select() .from(submissions) @@ -106,9 +108,9 @@ export async function getFilteredSubmissions( ? await baseQuery : await baseQuery .limit(RESULTS_PAGE_SIZE) - .offset((filters.page - 1) * RESULTS_PAGE_SIZE); + .offset((page - 1) * RESULTS_PAGE_SIZE); - return { rows, total }; + return { rows, total, page, pageCount }; } export function resultFiltersToSearchParams(