From f32403ee358c4b4e5ae2161df209a55128643812 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 14 May 2026 21:58:35 -0400 Subject: [PATCH 1/2] fix(theme): guard ThemeSessionBridge sync with hasSynced ref The dep-only fix wasn't enough: better-auth's useSession (nanostores) can re-emit on focus / store refresh, transitioning session.user.id through undefined and back. Each transition re-fires the effect, which then calls setTheme(session.user.theme) with the stale cached value and reverts the user's selection. Track "have we synced this tab session" with a ref. After the first successful sync, no subsequent effect fire can revert. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/mobile/profile/ThemeSessionBridge.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/components/mobile/profile/ThemeSessionBridge.tsx b/components/mobile/profile/ThemeSessionBridge.tsx index 5e5b3c4..7d00531 100644 --- a/components/mobile/profile/ThemeSessionBridge.tsx +++ b/components/mobile/profile/ThemeSessionBridge.tsx @@ -10,7 +10,7 @@ * Renders nothing. */ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useTheme } from 'next-themes'; import { useSession } from '@/lib/auth-client'; @@ -19,8 +19,15 @@ type SessionUserWithTheme = { theme?: 'light' | 'dark' | 'system' | string }; export function ThemeSessionBridge() { const { data: session } = useSession(); const { theme, setTheme } = useTheme(); + // Sync server → client EXACTLY ONCE per tab session. better-auth's + // useSession (nanostores) can re-emit on focus / store refresh, which would + // otherwise call setTheme with the stale cached session.user.theme and + // revert the user's just-made selection. The PUT to /api/me/theme does NOT + // refresh the cached session, so any post-selection re-fire would clobber. + const hasSynced = useRef(false); useEffect(() => { + if (hasSynced.current) return; if (!session?.user) return; const serverTheme = (session.user as SessionUserWithTheme).theme; // Only enforce explicit choices. 'system' is the column default and may @@ -32,12 +39,7 @@ export function ThemeSessionBridge() { setTheme(serverTheme); } } - // Deps intentionally exclude `theme`. This bridge syncs server → client - // on session establishment only. Including `theme` made the effect re-fire - // on every user selection and revert to the stale cached - // `session.user.theme` (the PUT to /api/me/theme does not refresh the - // better-auth session, so user.theme remains the pre-change value and - // "wins" against the new client value). + hasSynced.current = true; // eslint-disable-next-line react-hooks/exhaustive-deps }, [session?.user?.id, setTheme]); From 5f4ccb9c56f94e3ed7c678afe83a284d750d1230 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 14 May 2026 21:58:35 -0400 Subject: [PATCH 2/2] fix(dashboard): correct NOW() timezone conversion for KPI/trend queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOW() returns TIMESTAMPTZ. The pattern (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $userTz)::date double-converts: first strips the tz designation (keeping UTC wall-clock as naive TIMESTAMP), then re-interprets that wall-clock as user-local (pushing UTC into the user-tz's UTC equivalent). For non-UTC users this gives the WRONG date — e.g. NY user at 9pm sees "today = tomorrow's UTC date", so opened-today returns 0. The column-side pattern ((col AT TIME ZONE 'UTC') AT TIME ZONE $userTz) is correct because the columns are TIMESTAMP without TZ (stored as UTC) — only the NOW() side was buggy. Replace with (NOW() AT TIME ZONE $userTz) everywhere. Affects: dashboard overview/trends, mobile dashboard/engagement/finance. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/dashboard/overview/route.ts | 10 +++++----- app/api/dashboard/trends/route.ts | 10 +++++----- app/api/mobile/dashboard/route.ts | 4 ++-- app/api/mobile/engagement/summary/route.ts | 2 +- app/api/mobile/engagement/trend/route.ts | 8 ++++---- app/api/mobile/finance/route.ts | 22 +++++++++++----------- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/app/api/dashboard/overview/route.ts b/app/api/dashboard/overview/route.ts index e7c1fb9..1f0f466 100644 --- a/app/api/dashboard/overview/route.ts +++ b/app/api/dashboard/overview/route.ts @@ -49,8 +49,8 @@ export async function GET() { }>( ` SELECT - COUNT(*) FILTER (WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text AS opened_today, - COUNT(*) FILTER (WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text AS resolved_today, + COUNT(*) FILTER (WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE $1)::date)::text AS opened_today, + COUNT(*) FILTER (WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE $1)::date)::text AS resolved_today, COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total, COUNT(*) FILTER ( WHERE completed_date IS NULL @@ -68,7 +68,7 @@ export async function GET() { ` SELECT COUNT(*)::text AS count FROM tickets - WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '1 day' + WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE $1)::date - INTERVAL '1 day' AND (is_deleted = false OR is_deleted IS NULL) AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false) `, @@ -81,8 +81,8 @@ export async function GET() { FROM ( SELECT ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date AS d, COUNT(*) AS daily_count FROM tickets - WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '7 days' - AND ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date + WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date >= (NOW() AT TIME ZONE $1)::date - INTERVAL '7 days' + AND ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date < (NOW() AT TIME ZONE $1)::date AND (is_deleted = false OR is_deleted IS NULL) AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false) GROUP BY ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date diff --git a/app/api/dashboard/trends/route.ts b/app/api/dashboard/trends/route.ts index 26ea945..9a53b21 100644 --- a/app/api/dashboard/trends/route.ts +++ b/app/api/dashboard/trends/route.ts @@ -28,8 +28,8 @@ export async function GET() { postgresClient.query<{ d: string; count: string }>( `WITH days AS ( SELECT generate_series( - (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days', - (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date, + (NOW() AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days', + (NOW() AT TIME ZONE $1)::date, INTERVAL '1 day' )::date AS d ) @@ -46,8 +46,8 @@ export async function GET() { postgresClient.query<{ d: string; avg_hours: string | null }>( `WITH days AS ( SELECT generate_series( - (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days', - (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date, + (NOW() AT TIME ZONE $1)::date - INTERVAL '${TREND_DAYS - 1} days', + (NOW() AT TIME ZONE $1)::date, INTERVAL '1 day' )::date AS d ) @@ -94,7 +94,7 @@ export async function GET() { COUNT(DISTINCT te.ticket_id)::text AS tickets_touched FROM time_entries te LEFT JOIN resources r ON r.id = te.resource_id - WHERE ((te.entry_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date + WHERE ((te.entry_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE $1)::date AND te.hours_worked > 0 GROUP BY te.resource_id, r.first_name, r.last_name, r.email ORDER BY SUM(te.hours_worked) DESC diff --git a/app/api/mobile/dashboard/route.ts b/app/api/mobile/dashboard/route.ts index bee9a61..4feda6a 100644 --- a/app/api/mobile/dashboard/route.ts +++ b/app/api/mobile/dashboard/route.ts @@ -72,8 +72,8 @@ export async function GET() { ` SELECT COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total, - COUNT(*) FILTER (WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text AS opened_today, - COUNT(*) FILTER (WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date)::text AS resolved_today, + COUNT(*) FILTER (WHERE ((create_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE $1)::date)::text AS opened_today, + COUNT(*) FILTER (WHERE ((completed_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE $1)::date)::text AS resolved_today, COUNT(*) FILTER ( WHERE completed_date IS NULL AND due_date_time IS NOT NULL diff --git a/app/api/mobile/engagement/summary/route.ts b/app/api/mobile/engagement/summary/route.ts index 04500d2..6809751 100644 --- a/app/api/mobile/engagement/summary/route.ts +++ b/app/api/mobile/engagement/summary/route.ts @@ -131,7 +131,7 @@ export async function GET(request: NextRequest): Promise { FROM time_entries te JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL) JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email) - WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${interval}' + WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() AT TIME ZONE $1) - INTERVAL '${interval}' AND (te.is_deleted = false OR te.is_deleted IS NULL) AND gu.account_enabled = true AND LOWER(gu.email) LIKE '%@wulfconsulting.%' diff --git a/app/api/mobile/engagement/trend/route.ts b/app/api/mobile/engagement/trend/route.ts index 3191e5a..afcf32c 100644 --- a/app/api/mobile/engagement/trend/route.ts +++ b/app/api/mobile/engagement/trend/route.ts @@ -50,8 +50,8 @@ export async function GET(request: NextRequest): Promise { const sql = ` WITH day_series AS ( SELECT generate_series( - ((NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date, - (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date, + ((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date, + (NOW() AT TIME ZONE $1)::date, INTERVAL '1 day' )::date AS day ), @@ -61,8 +61,8 @@ export async function GET(request: NextRequest): Promise { JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL) JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email) - WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date >= ((NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date - AND (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date <= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date + WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date >= ((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date + AND (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date <= (NOW() AT TIME ZONE $1)::date AND (te.is_deleted = false OR te.is_deleted IS NULL) AND gu.account_enabled = true AND LOWER(gu.email) LIKE '%@wulfconsulting.%' diff --git a/app/api/mobile/finance/route.ts b/app/api/mobile/finance/route.ts index b1a3805..17bf669 100644 --- a/app/api/mobile/finance/route.ts +++ b/app/api/mobile/finance/route.ts @@ -23,8 +23,8 @@ export async function GET() { COUNT(*) FILTER (WHERE status = 'Open') as current_count, SUM(balance) FILTER (WHERE status = 'Overdue') as overdue_balance, COUNT(*) FILTER (WHERE status = 'Overdue') as overdue_count, - SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)) as paid_mtd, - SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('year', NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)) as paid_ytd + SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE $1)) as paid_mtd, + SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('year', NOW() AT TIME ZONE $1)) as paid_ytd FROM qbo_invoices `, [tz], @@ -32,14 +32,14 @@ export async function GET() { postgresClient.query( ` SELECT - SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30) as days_1_30, - COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30) as cnt_1_30, - SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30 - AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as days_31_60, - COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 30 - AND due_date >= (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as cnt_31_60, - SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as days_60_plus, - COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - 60) as cnt_60_plus + SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE $1)::date - 30) as days_1_30, + COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE $1)::date - 30) as cnt_1_30, + SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 30 + AND due_date >= (NOW() AT TIME ZONE $1)::date - 60) as days_31_60, + COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 30 + AND due_date >= (NOW() AT TIME ZONE $1)::date - 60) as cnt_31_60, + SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 60) as days_60_plus, + COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 60) as cnt_60_plus FROM qbo_invoices `, [tz], @@ -54,7 +54,7 @@ export async function GET() { postgresClient.query( ` SELECT id, doc_number, txn_date, due_date, customer_ref_name, total_amt, balance, status, - (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $1)::date - due_date::date as days_overdue + (NOW() AT TIME ZONE $1)::date - due_date::date as days_overdue FROM qbo_invoices WHERE status IN ('Open','Overdue') ORDER BY status DESC, balance DESC LIMIT 30