fix(admin): SQL GROUP BY in reports, hydration in settings, mobile tabs scroll

- 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.
This commit is contained in:
Nora
2026-06-26 21:43:05 -06:00
parent 137b5e7ffe
commit 6de112467a
6 changed files with 43 additions and 17 deletions
+4 -1
View File
@@ -234,12 +234,15 @@ await getSession(); const adminUser = await getAdminUser();
// `customers` replaced `communication_contacts`. `source` column is gone, // `customers` replaced `communication_contacts`. `source` column is gone,
// so `imports` is always 0 and `new_contacts` is the day's net add. // 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 }>( const { rows } = await pool.query<{ date: string; new_contacts: number; imports: number; total: number }>(
`SELECT `SELECT
d::date::text AS date, d::date::text AS date,
COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts, COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts,
0::int AS imports, 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 FROM generate_series($1::date, $2::date, '1 day'::interval) d
LEFT JOIN customers c LEFT JOIN customers c
ON c.created_at::date = d::date ON c.created_at::date = d::date
+1 -1
View File
@@ -105,7 +105,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<CommandPalette /> <CommandPalette />
<div <div
id="page-content" id="page-content"
className="min-h-screen lg:pl-60 admin-section outline-none" className="min-h-screen lg:pl-60 pt-16 lg:pt-0 admin-section outline-none"
style={{ backgroundColor: "var(--admin-bg)" }} style={{ backgroundColor: "var(--admin-bg)" }}
> >
<SmoothViewTransition>{children}</SmoothViewTransition> <SmoothViewTransition>{children}</SmoothViewTransition>
+14 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useReducer, useCallback, useEffect } from "react"; import { useReducer, useCallback, useEffect, useMemo } from "react";
import { import {
getReportsSummary, getReportsSummary,
getOrdersByStopReport, getOrdersByStopReport,
@@ -317,7 +317,19 @@ export default function ReportsDashboard({
}) { }) {
const [state, dispatch] = useReducer(reducer, initialBrandId, makeInitialState); 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 // Year is read from the clock; compute in the browser only to avoid
// hydration mismatches with the server. The setState is wrapped in an // hydration mismatches with the server. The setState is wrapped in an
+10 -7
View File
@@ -135,14 +135,17 @@ export default function SettingsClient({
paymentSettings, paymentSettings,
currentUser, currentUser,
}: Props) { }: Props) {
// Initial tab is determined by URL hash if present, otherwise "general". // Server renders the default tab; client picks up the URL hash after mount
// No useEffect needed — we read window.location.hash during the lazy // so the initial server HTML matches the first client render (avoids
// initializer on the client. On the server we default to "general". // hydration mismatch). Reading window.location.hash in a lazy initializer
const [activeTab, setActiveTab] = useState<Tab>(() => { // is unsafe because the server has no hash and would render a different
if (typeof window === "undefined") return "general"; // tab than the client.
const [activeTab, setActiveTab] = useState<Tab>("general");
useEffect(() => {
const hash = window.location.hash.slice(1); 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 ( return (
<main className="min-h-screen bg-[var(--admin-bg)]"> <main className="min-h-screen bg-[var(--admin-bg)]">
@@ -50,9 +50,9 @@ export default function AdminFilterTabs({
const sizes = sizeClasses[size]; const sizes = sizeClasses[size];
return ( return (
<div <div
className={` className={`
flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)] flex overflow-x-auto rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]
${sizes.container} ${className} ${sizes.container} ${className}
`} `}
role="tablist" role="tablist"
+12 -4
View File
@@ -2,15 +2,23 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
/**
* Returns whether the given media query currently matches.
*
* Always starts as `false` so that server-rendered HTML and the initial
* client render produce the same markup (no hydration mismatch). The
* real value is fetched after mount via `matchMedia`. Callers that need
* a different initial branch (e.g. AdminShell choosing mobile vs desktop
* layout) should render the "mobile / not-yet-known" branch first and
* rely on a re-render after mount.
*/
export function useMediaQuery(query: string): boolean { export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => { const [matches, setMatches] = useState<boolean>(false);
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const mq = window.matchMedia(query); const mq = window.matchMedia(query);
setMatches(mq.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches); const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler); mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler); return () => mq.removeEventListener("change", handler);