From 1ec2fc900fbf850fb769a77ba60610a1e9b826c0 Mon Sep 17 00:00:00 2001 From: Exc1D Date: Fri, 1 May 2026 21:25:28 +0800 Subject: [PATCH 1/9] fix(citizen-pwa): add try/catch error boundaries to cloud callable wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestDataExport and registerCitizen called await callable() with no error handling — Firebase errors would propagate as opaque internal errors. Wrap both in try/catch and rethrow descriptive messages with the original error as cause. Co-Authored-By: Claude Sonnet 4.6 --- apps/citizen-pwa/src/services/callables.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/citizen-pwa/src/services/callables.ts b/apps/citizen-pwa/src/services/callables.ts index cf7d29bb..858a89d9 100644 --- a/apps/citizen-pwa/src/services/callables.ts +++ b/apps/citizen-pwa/src/services/callables.ts @@ -3,7 +3,14 @@ import { fns } from './firebase.js' export async function requestDataExport(): Promise { const callable = httpsCallable(fns(), 'requestDataExport') - await callable() + try { + await callable() + } catch (err) { + throw new Error( + `Data export request failed: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ) + } } export async function registerCitizen(): Promise<{ @@ -12,6 +19,13 @@ export async function registerCitizen(): Promise<{ accountStatus: string }> { const callable = httpsCallable(fns(), 'registerCitizen') - const result = await callable() - return result.data as { uid: string; role: string; accountStatus: string } + try { + const result = await callable() + return result.data as { uid: string; role: string; accountStatus: string } + } catch (err) { + throw new Error( + `Citizen registration failed: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ) + } } From 13a7ebf2279e9705dc4511dfd286f36afbdcd6b0 Mon Sep 17 00:00:00 2001 From: Exc1D Date: Fri, 1 May 2026 21:25:42 +0800 Subject: [PATCH 2/9] fix(citizen-pwa): make timeline actor optional and map safely from Firestore ReportTimelineEvent.actor was typed as required string but Firestore data may omit it, violating the TS contract at runtime. Make actor optional in the interface and map timeline events individually in mapReportFromFirestore instead of bulk-casting the array. Co-Authored-By: Claude Sonnet 4.6 --- apps/citizen-pwa/src/hooks/useReport.ts | 2 +- apps/citizen-pwa/src/lib/mappers.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/citizen-pwa/src/hooks/useReport.ts b/apps/citizen-pwa/src/hooks/useReport.ts index 657e8ff3..4896935f 100644 --- a/apps/citizen-pwa/src/hooks/useReport.ts +++ b/apps/citizen-pwa/src/hooks/useReport.ts @@ -9,7 +9,7 @@ import type { ReportStatus } from '@bantayog/shared-types' export interface ReportTimelineEvent { event: string timestamp: number - actor: string + actor?: string note?: string } diff --git a/apps/citizen-pwa/src/lib/mappers.ts b/apps/citizen-pwa/src/lib/mappers.ts index 4e9bc6a3..600fcf68 100644 --- a/apps/citizen-pwa/src/lib/mappers.ts +++ b/apps/citizen-pwa/src/lib/mappers.ts @@ -9,7 +9,12 @@ export function mapReportFromFirestore(data: Record): ReportDat const result: ReportData = { id: data.id as string, status: data.status as ReportStatus, - timeline: data.timeline as ReportData['timeline'], + timeline: (data.timeline as Array>).map((evt) => ({ + event: evt.event as string, + timestamp: evt.timestamp as number, + ...(evt.actor !== undefined && { actor: evt.actor as string }), + ...(evt.note !== undefined && { note: evt.note as string }), + })), }; if (data.type !== undefined) { From 7a1b011bcb430d1d58c35100ea149cf0a0312964 Mon Sep 17 00:00:00 2001 From: Exc1D Date: Fri, 1 May 2026 21:25:58 +0800 Subject: [PATCH 3/9] fix(citizen-pwa): RevealSheet clipboard feedback, timer cleanup, extract variants - handleCopySecret: show 'Copy failed' message to user instead of only console.error; use ref-based timer with cleanup on unmount instead of void t anti-pattern - Extract REVEAL_VARIANTS to module-level constant (was recreated every render as 42-line inline object) - Extract GUARDIAN_BENEFITS checklist items to shared constant (was duplicated between RevealSheet and ProfileTab inline) - Derive guardianIcon via useMemo keyed on state Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/RevealSheet.tsx | 127 +++++++++++------- 1 file changed, 75 insertions(+), 52 deletions(-) diff --git a/apps/citizen-pwa/src/components/RevealSheet.tsx b/apps/citizen-pwa/src/components/RevealSheet.tsx index bbc1558d..3d829c0d 100644 --- a/apps/citizen-pwa/src/components/RevealSheet.tsx +++ b/apps/citizen-pwa/src/components/RevealSheet.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { Check, ShieldCheck, Copy, CheckCircle, LogIn, Save } from 'lucide-react' import { onAuthStateChanged } from 'firebase/auth' @@ -13,6 +13,50 @@ import { Timeline } from './ui/Timeline' const HOTLINE_NUMBER = '(054) 721-1216' +const REVEAL_VARIANTS = { + success: { + headline: 'We heard you. We are here.', + subline: 'Your report is with Daet MDRRMO. Keep your line open.', + sublineTl: 'Narinig namin kayo. Hawak na ng MDRRMO ang inyong ulat.', + bannerVariant: 'success' as const, + receiverText: 'Received by Daet MDRRMO', + primaryButton: 'Track this report', + primaryVariant: 'primary' as const, + secondaryButton: undefined as string | undefined, + permissionText: "You can close this app. We'll text you.", + }, + queued: { + headline: "Saved. We'll send it for you.", + subline: + "Your report is safe on this phone. The moment signal returns, we'll automatically forward it to Daet MDRRMO — no action needed from you. Walang mawawala.", + sublineTl: undefined as string | undefined, + bannerVariant: 'queued' as const, + receiverText: 'Saved to device · auto-send when online', + primaryButton: 'Try sending now', + primaryVariant: 'amber' as const, + secondaryButton: 'Keep draft & close', + permissionText: "We'll keep trying quietly in the background.", + }, + failed_retryable: { + headline: 'Your report is safe. Still trying.', + subline: + "We saved it securely on your phone and are retrying automatically. The network is having trouble — this is not your fault and nothing is lost. If it's a life-threatening emergency, call now.", + sublineTl: 'Ligtas ang inyong ulat. Nagre-retry kami. Kung emergency, tawagan kami ngayon.', + bannerVariant: 'queued' as const, + receiverText: undefined as string | undefined, + primaryButton: 'Retry now', + primaryVariant: 'amber' as const, + secondaryButton: 'Keep draft & close', + permissionText: "We'll hold this draft for 24 hours and keep retrying.", + }, +} + +const GUARDIAN_BENEFITS = [ + 'Track reports across devices', + 'Earn Guardian badges', + 'Get status updates via app', +] + function RadarRings({ rgb = '5,150,105' }: { rgb?: string }) { const ringsRef = useRef(null) useEffect(() => { @@ -59,51 +103,18 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea const displayedCode = reducedMotion ? referenceCode : slotDisplay const typewriterComplete = reducedMotion ? true : slotDone const [copied, setCopied] = useState(false) + const [copyError, setCopyError] = useState(false) const [secretVisible, setSecretVisible] = useState(false) + const copyTimerRef = useRef | null>(null) // null = loading (firebase present), true = guest (no account), false = registered const [isGuest, setIsGuest] = useState(() => (hasFirebaseConfig() ? null : true)) - const variants = { - success: { - icon: , - headline: 'We heard you. We are here.', - subline: 'Your report is with Daet MDRRMO. Keep your line open.', - sublineTl: 'Narinig namin kayo. Hawak na ng MDRRMO ang inyong ulat.', - bannerVariant: 'success' as const, - receiverText: 'Received by Daet MDRRMO', - primaryButton: 'Track this report', - primaryVariant: 'primary' as const, - secondaryButton: undefined, - permissionText: "You can close this app. We'll text you.", - }, - queued: { - icon: , - headline: "Saved. We'll send it for you.", - subline: - "Your report is safe on this phone. The moment signal returns, we'll automatically forward it to Daet MDRRMO — no action needed from you. Walang mawawala.", - sublineTl: undefined, - bannerVariant: 'queued' as const, - receiverText: 'Saved to device · auto-send when online', - primaryButton: 'Try sending now', - primaryVariant: 'amber' as const, - secondaryButton: 'Keep draft & close', - permissionText: "We'll keep trying quietly in the background.", - }, - failed_retryable: { - icon: , - headline: 'Your report is safe. Still trying.', - subline: - "We saved it securely on your phone and are retrying automatically. The network is having trouble — this is not your fault and nothing is lost. If it's a life-threatening emergency, call now.", - sublineTl: 'Ligtas ang inyong ulat. Nagre-retry kami. Kung emergency, tawagan kami ngayon.', - bannerVariant: 'queued' as const, - receiverText: undefined, - primaryButton: 'Retry now', - primaryVariant: 'amber' as const, - secondaryButton: 'Keep draft & close', - permissionText: "We'll hold this draft for 24 hours and keep retrying.", - }, - } + const variant = REVEAL_VARIANTS[state] - const variant = variants[state] + const guardianIcon = useMemo(() => { + if (state === 'success') return + if (state === 'queued') return + return + }, [state]) useEffect(() => { if (!('vibrate' in navigator)) return @@ -141,15 +152,26 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea try { await navigator.clipboard.writeText(secretCode) setCopied(true) - const t = setTimeout(() => { + setCopyError(false) + if (copyTimerRef.current) clearTimeout(copyTimerRef.current) + copyTimerRef.current = setTimeout(() => { setCopied(false) }, 2000) - void t - } catch (e) { - console.error('Failed to copy secret code:', e) + } catch { + setCopyError(true) + if (copyTimerRef.current) clearTimeout(copyTimerRef.current) + copyTimerRef.current = setTimeout(() => { + setCopyError(false) + }, 3000) } }, [secretCode]) + useEffect(() => { + return () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current) + } + }, []) + const afterglowTime = new Date().toLocaleTimeString('en-PH', { hour: '2-digit', minute: '2-digit', @@ -305,7 +327,7 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea )} - + {variant.headline} @@ -405,6 +427,11 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea {copied && (

Copied!

)} + {copyError && ( +

+ Copy failed — please write it down +

+ )}

Save this to check your report without an account. @@ -447,11 +474,7 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea community.

    - {[ - 'Track reports across devices', - 'Earn Guardian badges', - 'Get status updates via app', - ].map((benefit) => ( + {GUARDIAN_BENEFITS.map((benefit) => (
  • {benefit} From ddc3bc3337337cd1d0dcf30e44478599eb751a53 Mon Sep 17 00:00:00 2001 From: Exc1D Date: Fri, 1 May 2026 21:26:14 +0800 Subject: [PATCH 4/9] fix(citizen-pwa): deduplicate status/severity logic, fix useBadges and signOut feedback - Extract statusMeta() and severityDotColor() to incident-meta.tsx as single source of truth (was duplicated in ProfileTab) - ProfileTab: remove local statusMeta/severityDot, import from utils - useBadges: wrap in useMemo to avoid recalculating every render - handleSignOut: show 'Sign out failed. Please try again.' on error instead of silently swallowing Co-Authored-By: Claude Sonnet 4.6 --- .../citizen-pwa/src/components/ProfileTab.tsx | 85 +++++++------------ apps/citizen-pwa/src/utils/incident-meta.tsx | 45 ++++++++++ 2 files changed, 74 insertions(+), 56 deletions(-) diff --git a/apps/citizen-pwa/src/components/ProfileTab.tsx b/apps/citizen-pwa/src/components/ProfileTab.tsx index a4ee5697..87e8d896 100644 --- a/apps/citizen-pwa/src/components/ProfileTab.tsx +++ b/apps/citizen-pwa/src/components/ProfileTab.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { ClipboardList, @@ -18,51 +18,15 @@ import { import { onAuthStateChanged, signOut } from 'firebase/auth' import type { User } from 'firebase/auth' import { useMyActiveReports } from '../hooks/useMyActiveReports.js' -import { incidentIcon, incidentLabel } from '../utils/incident-meta.js' +import { + incidentIcon, + incidentLabel, + statusMeta, + severityDotColor, +} from '../utils/incident-meta.js' import { auth, hasFirebaseConfig } from '../services/firebase.js' import type { MyReport } from './MapTab/types.js' -function statusMeta(status: string): { label: string; bg: string; color: string } { - switch (status) { - case 'queued': - case 'draft_inbox': - return { label: 'Sending…', bg: 'bg-surface-200', color: 'text-surface-600' } - case 'new': - return { label: 'Received', bg: 'bg-brand-100', color: 'text-brand-600' } - case 'awaiting_verify': - return { label: 'Under review', bg: 'bg-warning-400/20', color: 'text-warning-500' } - case 'verified': - return { label: 'Verified', bg: 'bg-brand-100', color: 'text-brand-600' } - case 'assigned': - case 'acknowledged': - return { label: 'Help on the way', bg: 'bg-success-400/20', color: 'text-success-500' } - case 'en_route': - return { label: 'Responder en route', bg: 'bg-success-400/20', color: 'text-success-500' } - case 'on_scene': - return { label: 'On scene', bg: 'bg-success-400/20', color: 'text-success-500' } - case 'resolved': - case 'closed': - return { label: 'Resolved', bg: 'bg-success-400/20', color: 'text-success-500' } - case 'reopened': - return { label: 'Re-opened', bg: 'bg-brand-100', color: 'text-brand-600' } - case 'rejected': - return { label: 'Not accepted', bg: 'bg-danger-400/20', color: 'text-danger-500' } - case 'cancelled': - case 'cancelled_false_report': - return { label: 'Cancelled', bg: 'bg-surface-200', color: 'text-surface-600' } - case 'merged_as_duplicate': - return { label: 'Merged', bg: 'bg-surface-200', color: 'text-surface-600' } - default: - return { label: status.replace(/_/g, ' '), bg: 'bg-surface-200', color: 'text-surface-600' } - } -} - -function severityDot(severity: string): string { - if (severity === 'high') return '#dc2626' - if (severity === 'medium') return '#d97706' - return '#334155' -} - function timeAgo(ts: number): string { const minutes = Math.floor((Date.now() - ts) / 60000) if (minutes < 1) return 'just now' @@ -106,17 +70,21 @@ const BADGE_DEFS: Omit[] = [ function useBadges(reports: MyReport[]): BadgeDef[] { const count = reports.length const verifiedCount = reports.filter((r) => r.status === 'verified').length - return BADGE_DEFS.map((def) => ({ - ...def, - earned: - def.id === 'first-report' - ? count >= 1 - : def.id === 'verified-reporter' - ? verifiedCount >= 1 - : def.id === 'community-helper' - ? count >= 3 - : count >= 5, - })) + return useMemo( + () => + BADGE_DEFS.map((def) => ({ + ...def, + earned: + def.id === 'first-report' + ? count >= 1 + : def.id === 'verified-reporter' + ? verifiedCount >= 1 + : def.id === 'community-helper' + ? count >= 3 + : count >= 5, + })), + [count, verifiedCount], + ) } /* ── Guardian pitch card ── */ @@ -216,7 +184,7 @@ function ReportCard({ report, onTap }: { report: MyReport; onTap: () => void }) const icon = incidentIcon(report.reportType) const label = incidentLabel(report.reportType) const { label: statusLabel, bg, color } = statusMeta(report.status) - const dot = severityDot(report.severity) + const dot = severityDotColor(report.severity) return (