feat(07.1-04): migrate mobile finance + ticket detail to useUserTimezone

- app/mobile/finance/page.tsx: thread tz through fmtDate, setLastSync, monthLabel — 3 formatter callsites now pass timeZone
- app/mobile/tickets/[id]/page.tsx: thread tz through fmtDate (5 callsites) and TimelineCard prop
- All toLocaleDateString / toLocaleString calls in both files now render in user.timezone, not browser local zone
- Resolves TZ-02 on the directly-reported bug surface (mobile finance + ticket detail)
This commit is contained in:
lorentz 2026-05-07 07:54:21 -04:00
parent 2ac2db7a23
commit 14f4da3483
2 changed files with 19 additions and 15 deletions

View file

@ -20,6 +20,7 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
interface AgingBucket { balance: number; count: number; }
interface FinanceData {
@ -39,11 +40,12 @@ interface FinanceData {
function fmt$(n: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
}
function fmtDate(ts: string) {
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
function fmtDate(ts: string, tz: string) {
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: tz });
}
export default function MobileFinance() {
const tz = useUserTimezone();
const [data, setData] = useState<FinanceData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -72,7 +74,7 @@ export default function MobileFinance() {
if (r.ok) {
const d = await r.json();
const ts = d.lastSync?.invoices;
setLastSync(ts ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : null);
setLastSync(ts ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }) : null);
}
} catch {}
};
@ -315,7 +317,7 @@ export default function MobileFinance() {
amountTone={inv.status === 'Overdue' ? 'destructive' : 'default'}
secondary={
<>
#{inv.doc_number} · Due {fmtDate(inv.due_date)}
#{inv.doc_number} · Due {fmtDate(inv.due_date, tz)}
{inv.days_overdue > 0 && (
<span className="text-destructive ml-1">({inv.days_overdue}d overdue)</span>
)}
@ -354,7 +356,7 @@ export default function MobileFinance() {
primary={p.customer_ref_name}
amount={fmt$(p.total_amt)}
amountTone="positive"
secondary={fmtDate(p.txn_date)}
secondary={fmtDate(p.txn_date, tz)}
/>
))
)}
@ -370,7 +372,7 @@ export default function MobileFinance() {
<h2 className="text-sm font-semibold mb-2">Revenue last 12 months</h2>
<div className="rounded-xl border divide-y overflow-hidden">
{monthly_revenue.map((m) => {
const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: tz });
return (
<div key={m.month} className="px-4 py-3 flex items-center justify-between gap-2">
<p className="text-sm font-semibold">{monthLabel}</p>

View file

@ -6,6 +6,7 @@ import {
ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2,
ChevronDown, ChevronRight, User, Briefcase, AlertCircle, EyeOff, Eye, ExternalLink, Mail, AlignLeft, Code2,
} from 'lucide-react';
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
const PRIORITY_LABEL: Record<number, string> = {
1: 'Standard', 2: 'Medium', 3: 'Standard', 4: 'Critical',
@ -45,8 +46,8 @@ type TimelineItem =
| { kind: 'time'; ts: string; data: { id: number; hours_worked: string; notes: string; billable: boolean; resource_name: string } }
| { kind: 'resolved'; ts: string };
function fmtDate(ts: string) {
return new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
function fmtDate(ts: string, tz: string) {
return new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz });
}
function fmtHours(h: string | number) {
const n = parseFloat(String(h));
@ -91,7 +92,7 @@ function renderContent(s: string): React.ReactNode[] {
return nodes;
}
function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defaultOpen?: boolean }) {
function TimelineCard({ item, tz, defaultOpen = false }: { item: TimelineItem; tz: string; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen);
if (item.kind === 'created') {
@ -104,7 +105,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
<div className="w-px flex-1 bg-border mt-1" />
</div>
<div className="pb-4 flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts)}</p>
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts, tz)}</p>
<p className="text-sm font-medium">Ticket created</p>
</div>
</div>
@ -120,7 +121,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
</div>
</div>
<div className="pb-4 flex-1">
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts)}</p>
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts, tz)}</p>
<p className="text-sm font-medium text-green-600">Ticket resolved</p>
</div>
</div>
@ -138,7 +139,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
<div className="w-px flex-1 bg-border mt-1" />
</div>
<div className="pb-4 flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts)}</p>
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts, tz)}</p>
<button onClick={() => setOpen(o => !o)}
className="w-full text-left flex items-center gap-2">
<p className="text-sm font-medium flex-1">
@ -179,7 +180,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
<div className="w-px flex-1 bg-border mt-1" />
</div>
<div className="pb-4 flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts)}</p>
<p className="text-xs text-muted-foreground mb-0.5">{fmtDate(item.ts, tz)}</p>
<button onClick={() => setOpen(o => !o)} className="w-full text-left">
<div className="flex items-start gap-1">
<div className="flex-1 min-w-0">
@ -207,6 +208,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
export default function TicketTimeline({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const tz = useUserTimezone();
const [data, setData] = useState<{ ticket: Ticket; timeline: TimelineItem[]; total_hours: number } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -284,7 +286,7 @@ export default function TicketTimeline({ params }: { params: Promise<{ id: strin
</p>
)}
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />Created {fmtDate(ticket.create_date)}
<Clock className="w-3.5 h-3.5" />Created {fmtDate(ticket.create_date, tz)}
</p>
</div>
@ -370,7 +372,7 @@ export default function TicketTimeline({ params }: { params: Promise<{ id: strin
return true;
})
.map((item, i) => (
<TimelineCard key={i} item={item} defaultOpen={item.kind === 'note' && i === timeline.length - 1} />
<TimelineCard key={i} item={item} tz={tz} defaultOpen={item.kind === 'note' && i === timeline.length - 1} />
))
)}
</div>