Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
742a06e
docs(phase9): Phase 9 pilot-deployable milestone design spec
Exc1D Apr 29, 2026
b726189
docs(phase9): clarify PAGASA webhook deferral in Section 8
Exc1D Apr 29, 2026
afe1b72
docs(design): add PRODUCT.md, DESIGN.md, DESIGN.json for citizen PWA\…
Exc1D Apr 29, 2026
d457df5
feat(citizen-pwa): report form polish — fix border rule, add Tagalo…
Exc1D Apr 29, 2026
58a73bb
feat(citizen-pwa): map tab redesign — chip filters, richer peek/det…
Exc1D Apr 29, 2026
9e8de8e
feat(citizen-pwa): feed tab, incident detail page, useIncident hook
Exc1D Apr 29, 2026
e845887
feat(citizen-pwa): profile tab — my reports list + privacy section
Exc1D Apr 29, 2026
46fc96d
feat(citizen-pwa): alerts tab + useAlerts hook
Exc1D Apr 29, 2026
07dc24e
feat(citizen-pwa): RevealSheet polish — slide-up animation, scroll gu…
Exc1D Apr 29, 2026
43da045
docs: update progress + learnings for citizen PWA redesign
Exc1D Apr 29, 2026
21db95c
fix(shared-firebase): add optional onError callback to subscribeAlerts
Exc1D Apr 29, 2026
fb44e49
fix(citizen-pwa): add explicit error state to useAlerts hook
Exc1D Apr 29, 2026
ea7f714
fix(citizen-pwa): tighten isPublicIncidentData guard and add cancella…
Exc1D Apr 29, 2026
7d4eab3
fix(citizen-pwa): accurate CTA copy in Step2WhoWhere
Exc1D Apr 29, 2026
0c6e97c
test(citizen-pwa): add direct route test for /incidents/:id
Exc1D Apr 29, 2026
ff89323
fix(design): correct refersTo mappings in DESIGN.json
Exc1D Apr 29, 2026
aa863b6
fix(design): reconcile elevation rules and remove legacy stripe refer…
Exc1D Apr 29, 2026
bd2dcbf
docs(product): remove accidental Register stub
Exc1D Apr 29, 2026
348a215
docs(spec): add language identifiers to fenced code blocks
Exc1D Apr 29, 2026
5b16624
Merge branch 'main' into feat/citizen-pwa-redesign
Exc1D Apr 29, 2026
5e5372f
docs(citizen-pwa): add redesign design spec — polish + spec gaps + ce…
Exc1D Apr 30, 2026
c352115
docs(responder-app): add redesign design spec — shell + ceremony la…
Exc1D Apr 30, 2026
d47a30f
feat(citizen-pwa): implement all 18 redesign tasks — design complia…
Exc1D Apr 30, 2026
51f7539
fix(coderabbit): address PR #85 review comments
Exc1D Apr 30, 2026
6804d7e
docs: fix DESIGN.json badge size + markdown lint in specs/plans
Exc1D Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
506 changes: 506 additions & 0 deletions DESIGN.json

Large diffs are not rendered by default.

300 changes: 300 additions & 0 deletions DESIGN.md

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Product

## Register

product

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
## Users

Citizens of Camarines Norte, Philippines. Reporting during active emergencies — flood water rising, a landslide just happened, they smell smoke. Time-pressured, scared, often in motion. Tech literacy ranges from first-time smartphone users to daily app users; the interface must work for both without condescending to either. Mobile-first: iOS Safari and Android Chrome on mid-range to low-end Android devices. Connectivity is unreliable during the exact moments the app matters most. Many will report without creating an account — pseudonymous use is supported and respected, not treated as a lesser experience.

## Product Purpose

Bantayog Alert gives citizens the fastest path from "I see an emergency" to "I've reported it and I'm being heard." Secondary jobs: stay spatially aware of what's happening nearby, browse the public incident feed, receive official government alerts, and track the status of their own reports. Success looks like a report submitted under 60 seconds, a citizen who feels their report mattered, and a community better coordinated during a crisis.

## Brand Personality

Caring, Assuring, Urgent.

The app speaks the way a calm, trusted neighbor would during a crisis — it does not panic, it does not waste words, it does not decorate. It moves with purpose. Every interaction should leave the user feeling: "I was heard, I am safe, I know what happens next."

## References

- **Apple.com** — for cleanliness: generous whitespace, precise typography, nothing that doesn't earn its place on the screen.
- **Grab mobile** — for efficiency: task-focused flows, obvious primary actions, no detours between intent and completion.
- **Facebook feed** — for the Feed tab only: familiar infinite scroll, card rhythm, recognizable interaction patterns citizens already know.
- **Google Maps** — for the Map tab: spatial clarity, intuitive pin interactions, minimal chrome around the map surface itself.

## Anti-references

Shopping apps (Shopee, Lazada aesthetic): cluttered grids, promotional banners, badge inflation, competing CTAs, anything that optimizes for attention over task completion. This app is used during emergencies. Visual noise costs lives.

## Design Principles

1. **Clarity under pressure.** Every screen must work at a glance for someone who is scared, moving, and has 4% battery. If a screen requires reading to understand, it has failed.
2. **Speed is care.** The fastest path to a submitted report is the most caring UX. Fewer taps, fewer decisions, fewer chances to abandon. Urgency and care are the same thing here.
3. **Trust through calm.** The UI must never feel chaotic, even when reporting chaos. Calm visual weight, unambiguous status, no jank. A panicked user needs an unshakeable interface.
4. **Familiar over clever.** When a pattern is already trusted (Maps-style pins, Feed-style cards, Apple-style form fields), use it. Novel UX patterns are a liability in a crisis.
5. **Inclusive by default.** WCAG 2.1 AAA on report submission and alerts. Colorblind-safe status indicators. Legible at arm's length in rain. Subtle Tagalog translations on any sentence that could confuse a first-time user.

## Accessibility & Inclusion

- **WCAG 2.1 AAA** on critical paths: report submission flow, alerts tab, and any error or status message.
- **WCAG 2.1 AA** minimum on all other surfaces.
- Colorblind-safe status palette (never rely on red/green alone — pair with icons and labels).
- Touch targets minimum 44×44px; prefer 48px on interactive elements in the report flow.
- Reduced-motion: all animations must respect `prefers-reduced-motion`.
- Tagalog subtitles or inline translations on complex English sentences (legal disclosures, privacy notices, error explanations). Inline, subtle — not a separate language toggle.
- Tested on low-end Android (Chrome) at 110% system font scale.
10 changes: 9 additions & 1 deletion apps/citizen-pwa/src/App.routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ vi.mock('./components/LookupScreen.js', () => ({
LookupScreen: () => <div>Lookup</div>,
}))

vi.mock('./components/FeedTab.js', () => ({
FeedTab: () => <div>Feed tab</div>,
}))

vi.mock('./components/IncidentDetailPage.js', () => ({
IncidentDetailPage: () => <div>Incident detail</div>,
}))

async function renderAppAt(pathname: string) {
window.history.pushState({}, '', pathname)
vi.resetModules()
Expand Down Expand Up @@ -52,7 +60,7 @@ describe('App routes', () => {
await renderAppAt('/')
fireEvent.click(screen.getByRole('button', { name: /feed/i }))
await waitFor(() => {
expect(screen.getByText(/Feed — coming soon/)).toBeInTheDocument()
expect(screen.getByText(/Feed tab/)).toBeInTheDocument()
})
})
})
252 changes: 252 additions & 0 deletions apps/citizen-pwa/src/components/AlertsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { useAlerts } from '../hooks/useAlerts.js'
import type { AlertDoc } from '@bantayog/shared-types'

const SEVERITY_ORDER: Record<string, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
info: 4,
}

function severityMeta(severity: string): { label: string; bg: string; color: string } {
switch (severity) {
case 'critical':
return { label: 'CRITICAL', bg: '#fecaca', color: '#7f1d1d' }
case 'high':
return { label: 'HIGH', bg: '#fee2e2', color: '#991b1b' }
case 'medium':
return { label: 'MEDIUM', bg: '#fff5ef', color: '#a73400' }
case 'low':
return { label: 'LOW', bg: '#e0e7f0', color: '#001e40' }
default:
return { label: 'INFO', bg: '#dbeafe', color: '#1e40af' }
}
}

function severityIcon(severity: string): string {
switch (severity) {
case 'critical':
case 'high':
return '🚨'
case 'medium':
return '⚠️'
default:
return 'ℹ️'
}
}

function timeAgo(ts: number): string {
const minutes = Math.floor((Date.now() - ts) / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${String(minutes)}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${String(hours)}h ago`
return `${String(Math.floor(hours / 24))}d ago`
}

function AlertCard({ alert }: { alert: AlertDoc }) {
const { label, bg, color } = severityMeta(alert.severity)
const icon = severityIcon(alert.severity)
const isCritical = alert.severity === 'critical' || alert.severity === 'high'

return (
<div
className="card"
style={{
marginBottom: '0.5rem',
padding: '0.875rem',
...(isCritical ? { background: '#fffbfb', border: '1px solid #fecaca' } : {}),
}}
>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span aria-hidden="true" style={{ fontSize: '1.25rem', flexShrink: 0, lineHeight: 1.3 }}>
{icon}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 8,
}}
>
<p
style={{
margin: 0,
fontWeight: 700,
fontSize: '0.9375rem',
color: '#001e40',
lineHeight: 1.3,
}}
>
{alert.title}
</p>
<span style={{ flexShrink: 0, fontSize: '0.6875rem', color: '#7b8794' }}>
{timeAgo(alert.publishedAt)}
</span>
</div>
<p
style={{
margin: '4px 0 8px',
fontSize: '0.8125rem',
color: '#374151',
lineHeight: 1.5,
}}
>
{alert.body}
</p>
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 999,
fontSize: '0.625rem',
fontWeight: 700,
letterSpacing: '0.05em',
background: bg,
color,
}}
>
{label}
</span>
</div>
</div>
</div>
)
}

function SkeletonCard() {
return (
<div
className="card"
style={{ animation: 'pulse 1.6s ease-in-out infinite', marginBottom: '0.5rem' }}
>
<div style={{ display: 'flex', gap: 10 }}>
<div
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: '#e0e3e5',
flexShrink: 0,
}}
/>
<div style={{ flex: 1 }}>
<div
style={{
height: 14,
width: '65%',
background: '#e0e3e5',
borderRadius: 4,
marginBottom: 8,
}}
/>
<div
style={{
height: 12,
width: '90%',
background: '#e0e3e5',
borderRadius: 4,
marginBottom: 6,
}}
/>
<div
style={{
height: 12,
width: '70%',
background: '#e0e3e5',
borderRadius: 4,
marginBottom: 10,
}}
/>
<div style={{ height: 18, width: 60, background: '#e0e3e5', borderRadius: 999 }} />
</div>
</div>
</div>
)
}

export function AlertsTab() {
const { alerts, loading } = useAlerts()

const sorted = [...alerts].sort(
(a, b) =>
(SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99) ||
b.publishedAt - a.publishedAt,
)

return (
<div style={{ height: '100%', overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
{/* Sticky header */}
<div
style={{
position: 'sticky',
top: 0,
zIndex: 10,
background: 'rgba(247,249,251,0.96)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
padding: '12px 16px 10px',
borderBottom: '1px solid #e5e7eb',
}}
>
<h1 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 800, color: '#001e40' }}>
Alerts
<span
style={{
display: 'block',
fontSize: '0.6875rem',
fontWeight: 400,
color: '#7b8794',
marginTop: 2,
}}
>
Mga babala at abiso para sa inyong lugar
</span>
</h1>
</div>

{/* Content */}
<div style={{ padding: '12px 16px 24px' }}>
{loading ? (
<>
<SkeletonCard />
<SkeletonCard />
</>
) : sorted.length === 0 ? (
<div role="status" style={{ padding: '48px 16px', textAlign: 'center' }}>
<p style={{ fontSize: '2.5rem', margin: '0 0 8px' }}>✅</p>
<p
style={{
margin: '0 0 6px',
fontWeight: 700,
color: '#001e40',
fontSize: '0.9375rem',
}}
>
No active alerts
</p>
<p style={{ margin: 0, fontSize: '0.8125rem', color: '#52606d' }}>
You will be notified when new alerts are issued.
<span
style={{
display: 'block',
fontSize: '0.6875rem',
color: '#7b8794',
marginTop: 4,
fontStyle: 'italic',
}}
>
Ipaaalam sa inyo kung may bagong babala.
</span>
</p>
</div>
) : (
sorted.map((alert) => <AlertCard key={alert.id} alert={alert} />)
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</div>
</div>
)
}
Loading
Loading