From 6de112467a2481c8360045779e57cf0b46a1196c Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 26 Jun 2026 21:43:05 -0600 Subject: [PATCH] fix(admin): SQL GROUP BY in reports, hydration in settings, mobile tabs scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reports.ts: wrap window-function arg in SUM() so it operates on the per-group aggregate instead of the raw c.id column (PostgreSQL rejects window funcs over non-grouped, non-aggregated columns). - SettingsClient.tsx: read URL hash in useEffect instead of a lazy useState initializer to avoid server/client first-render mismatch on tab content (was triggering full client re-render in /admin/settings). - layout.tsx: add pt-16 lg:pt-0 to page-content for mobile header clearance. - ReportsDashboard.tsx: memoize DateRange on primitive inputs to break useEffect → setState → re-render loop caused by buildRange returning a new object every call. - AdminFilterTabs.tsx: add overflow-x-auto so wide tab rows scroll on mobile instead of overflowing. - use-media-query.ts: always start as false for hydration safety; callers handle the mobile/unknown branch first. --- src/actions/reports.ts | 5 ++++- src/app/admin/layout.tsx | 2 +- src/components/admin/ReportsDashboard.tsx | 16 ++++++++++++++-- src/components/admin/SettingsClient.tsx | 17 ++++++++++------- .../admin/design-system/AdminFilterTabs.tsx | 4 ++-- src/lib/use-media-query.ts | 16 ++++++++++++---- 6 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/actions/reports.ts b/src/actions/reports.ts index d80eea4..e54f912 100644 --- a/src/actions/reports.ts +++ b/src/actions/reports.ts @@ -234,12 +234,15 @@ await getSession(); const adminUser = await getAdminUser(); // `customers` replaced `communication_contacts`. `source` column is gone, // so `imports` is always 0 and `new_contacts` is the day's net add. + // Wrap COUNT in SUM() so the window function operates on an aggregate, + // not the raw `c.id` column (which is not in GROUP BY). const { rows } = await pool.query<{ date: string; new_contacts: number; imports: number; total: number }>( `SELECT d::date::text AS date, COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts, 0::int AS imports, - COUNT(c.id) OVER (ORDER BY d::date)::int AS total + SUM(COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)) + OVER (ORDER BY d::date)::int AS total FROM generate_series($1::date, $2::date, '1 day'::interval) d LEFT JOIN customers c ON c.created_at::date = d::date diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 240989f..e888c9c 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -105,7 +105,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{children} diff --git a/src/components/admin/ReportsDashboard.tsx b/src/components/admin/ReportsDashboard.tsx index c65cc16..f9f4c50 100644 --- a/src/components/admin/ReportsDashboard.tsx +++ b/src/components/admin/ReportsDashboard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useReducer, useCallback, useEffect } from "react"; +import { useReducer, useCallback, useEffect, useMemo } from "react"; import { getReportsSummary, getOrdersByStopReport, @@ -317,7 +317,19 @@ export default function ReportsDashboard({ }) { const [state, dispatch] = useReducer(reducer, initialBrandId, makeInitialState); - const range: DateRange = buildRange(state.preset, state.customStart, state.customEnd); + // Memoize range — `buildRange` returns a new object each call (it uses + // `new Date()` for the end-date). Without this memo, `range` would have a + // fresh identity every render, which makes `fetchAll` a fresh identity + // every render (it's a useCallback depending on `range`), which makes the + // `useEffect(() => fetchAll(), [fetchAll])` below refire every render and + // dispatch SET_LOADING/SET_REPORTS, which re-renders → infinite loop. + // Memoizing on the primitive inputs pins range to user-driven changes + // only; the day-rollover case is intentionally not handled here (would + // require a separate timer to refresh `today`). + const range: DateRange = useMemo( + () => buildRange(state.preset, state.customStart, state.customEnd), + [state.preset, state.customStart, state.customEnd] + ); // Year is read from the clock; compute in the browser only to avoid // hydration mismatches with the server. The setState is wrapped in an diff --git a/src/components/admin/SettingsClient.tsx b/src/components/admin/SettingsClient.tsx index 38027d8..9ac9f56 100644 --- a/src/components/admin/SettingsClient.tsx +++ b/src/components/admin/SettingsClient.tsx @@ -135,14 +135,17 @@ export default function SettingsClient({ paymentSettings, currentUser, }: Props) { - // Initial tab is determined by URL hash if present, otherwise "general". - // No useEffect needed — we read window.location.hash during the lazy - // initializer on the client. On the server we default to "general". - const [activeTab, setActiveTab] = useState(() => { - if (typeof window === "undefined") return "general"; + // Server renders the default tab; client picks up the URL hash after mount + // so the initial server HTML matches the first client render (avoids + // hydration mismatch). Reading window.location.hash in a lazy initializer + // is unsafe because the server has no hash and would render a different + // tab than the client. + const [activeTab, setActiveTab] = useState("general"); + useEffect(() => { const hash = window.location.hash.slice(1); - return TABS.find((t) => t.hash === hash)?.id ?? "general"; - }); + const tab = TABS.find((t) => t.hash === hash)?.id; + if (tab) setActiveTab(tab); + }, []); return (
diff --git a/src/components/admin/design-system/AdminFilterTabs.tsx b/src/components/admin/design-system/AdminFilterTabs.tsx index cac1aaa..04d8bf6 100644 --- a/src/components/admin/design-system/AdminFilterTabs.tsx +++ b/src/components/admin/design-system/AdminFilterTabs.tsx @@ -50,9 +50,9 @@ export default function AdminFilterTabs({ const sizes = sizeClasses[size]; return ( -
(() => { - if (typeof window === "undefined") return false; - return window.matchMedia(query).matches; - }); + const [matches, setMatches] = useState(false); useEffect(() => { if (typeof window === "undefined") return; const mq = window.matchMedia(query); + setMatches(mq.matches); const handler = (e: MediaQueryListEvent) => setMatches(e.matches); mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler);