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
+12 -4
View File
@@ -2,15 +2,23 @@
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 {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
const [matches, setMatches] = useState<boolean>(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);