Files
ims/docs/superpowers/plans/2026-07-11-phase-4-scale-polish.md
T
adminandClaude Fable 5 646c94be0c chore: commit supabase config, phase 2/4 plan docs, extend gitignore
- supabase/config.toml + supabase/.gitignore from supabase init (needed
  for supabase db push / local dev)
- phase 2 and phase 4 implementation plans referenced by the SDD
  progress ledger but never committed
- ignore local tooling dirs (node_modules.nosync, graphify-out, .claude)
  and iCloud "name 2.ext" sync-conflict copies

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
2026-07-12 10:30:06 +08:00

1842 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 4 — Scale & Polish Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add WhatsApp Business API notifications, CAPA effectiveness re-check automation (30/60/90-day), multi-language UI (EN/MS/ZH), and PWA offline incident capture.
**Architecture:** WhatsApp is a thin fetch wrapper over Meta Graph API used fire-and-forget alongside existing Resend email. i18n uses a lightweight cookie-based custom context (no extra library) to avoid restructuring App Router routes. Offline capture uses `idb` (IndexedDB wrapper) to queue reports when offline and auto-sync on reconnect via a client-side listener. All cron routes share the existing `Bearer $CRON_SECRET` auth pattern.
**Tech Stack:** Next.js 15 App Router · Supabase · Meta WhatsApp Business Cloud API v19.0 · idb · Vitest 4
## Global Constraints
- `export const dynamic = 'force-dynamic'` on every route.ts and protected page.tsx.
- Auth pattern for hse/admin routes: `auth.getUser()``users.role` check → 401/403.
- Every DB mutation calls `supabase.rpc('write_audit_log', { p_table_name, p_record_id, p_action, p_new_value })`.
- Supabase join type casts: `(row.relation as unknown as { field: string } | null)?.field`.
- `getApiKey(supabase, 'KEY_NAME')` from `lib/settings.ts` for all API credentials — DB first, env fallback.
- `basePath: '/ims'` — hard-code `/ims/` prefix in manifest `start_url`, SW registration path, and WhatsApp notification URLs. Client-side `fetch()` calls use `/api/...` paths (consistent with existing codebase).
- WhatsApp sends are always fire-and-forget (non-blocking, `catch(err => console.error(...))`).
- No service-role key in app code. No `.env` secrets committed.
- Cron routes: `GET` protected by `Authorization: Bearer $CRON_SECRET` header.
- `next.config.js` uses CommonJS (`module.exports`). No new build plugins introduced.
- Meta WhatsApp template names: `ims_incident_alert`, `ims_capa_overdue`, `ims_effectiveness_recheck`. These must be created and approved in Meta Business Manager before production use. The implementation is complete regardless; see Task 1 Step 1 for exact variable specs.
---
## File Map
**New files:**
- `lib/notifications/whatsapp.ts`
- `lib/notifications/effectiveness-recheck.ts`
- `lib/i18n/locales.ts`
- `lib/i18n/context.tsx`
- `lib/i18n/server.ts`
- `messages/en.json`
- `messages/ms.json`
- `messages/zh.json`
- `components/language-switcher.tsx`
- `components/incidents/offline-sync.tsx`
- `lib/offline/db.ts`
- `public/manifest.json`
- `public/icons/icon.svg`
- `public/sw.js`
- `app/api/cron/effectiveness-recheck/route.ts`
- `supabase/migrations/20260711000015_phase4.sql`
- `tests/lib/notifications/whatsapp.test.ts`
- `tests/lib/notifications/effectiveness-recheck.test.ts`
- `tests/lib/i18n/translations.test.ts`
- `tests/lib/offline/db.test.ts`
**Modified files:**
- `app/api/settings/route.ts` — expand ALLOWED_KEYS with WhatsApp credentials
- `app/api/incidents/route.ts` — fire WhatsApp alongside email
- `lib/notifications/capa-escalation.ts` — add WhatsApp for urgent thresholds
- `app/api/capa/[id]/verify/route.ts` — set effectiveness_recheck_date + round on verify
- `app/layout.tsx` — I18nProvider wrap + manifest link + SW registration script
- `app/report/page.tsx` — add LanguageSwitcher
- `components/incidents/report-form.tsx` — offline mode + i18n translations
---
## Task 1: WhatsApp notification helper + migration
**Files:**
- Create: `lib/notifications/whatsapp.ts`
- Create: `supabase/migrations/20260711000015_phase4.sql`
- Modify: `app/api/settings/route.ts`
- Test: `tests/lib/notifications/whatsapp.test.ts`
**Interfaces:**
- Produces: `sendWhatsAppMessage(phoneNumber, templateName, parameters, phoneNumberId, accessToken): Promise<void>` — pure, testable, used by Tasks 2 and 3.
**WhatsApp template variable specs (create these in Meta Business Manager):**
```
Template: ims_incident_alert
Body: "IMS Alert: New incident {{1}} ({{2}}) reported at {{3}}. Please review immediately."
Variables: {{1}}=reference_no, {{2}}=incident_type, {{3}}=site_name
Template: ims_capa_overdue
Body: "IMS CAPA Overdue: Action for incident {{1}} is overdue. Description: {{2}}. Due: {{3}}."
Variables: {{1}}=incident_ref, {{2}}=capa_description, {{3}}=due_date
Template: ims_effectiveness_recheck
Body: "IMS Effectiveness Check ({{1}}): Please verify that the corrective action for incident {{2}} is still holding. Action: {{3}}"
Variables: {{1}}=round_label (e.g. "30-day"), {{2}}=incident_ref, {{3}}=capa_description
```
- [ ] **Step 1: Write the failing test**
```typescript
// tests/lib/notifications/whatsapp.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
describe('sendWhatsAppMessage', () => {
beforeEach(() => fetchMock.mockClear())
it('posts to Meta Graph API with correct template structure', async () => {
fetchMock.mockResolvedValueOnce(
new Response('{"messages":[{"id":"wamid.abc"}]}', { status: 200 })
)
await sendWhatsAppMessage(
'60123456789',
'ims_incident_alert',
['SETIA-202407-0001', 'injury', 'Warehouse A'],
'test-phone-id',
'test-token'
)
expect(fetchMock).toHaveBeenCalledOnce()
const [url, opts] = fetchMock.mock.calls[0]
expect(url).toBe('https://graph.facebook.com/v19.0/test-phone-id/messages')
expect(opts.method).toBe('POST')
expect(opts.headers['Authorization']).toBe('Bearer test-token')
const body = JSON.parse(opts.body)
expect(body.messaging_product).toBe('whatsapp')
expect(body.to).toBe('60123456789')
expect(body.type).toBe('template')
expect(body.template.name).toBe('ims_incident_alert')
expect(body.template.language.code).toBe('en_US')
expect(body.template.components[0].parameters).toHaveLength(3)
expect(body.template.components[0].parameters[0]).toEqual({ type: 'text', text: 'SETIA-202407-0001' })
})
it('throws on non-2xx response', async () => {
fetchMock.mockResolvedValueOnce(
new Response('{"error":{"message":"Invalid token"}}', { status: 400 })
)
await expect(
sendWhatsAppMessage('60123456789', 'ims_incident_alert', ['a'], 'pid', 'tok')
).rejects.toThrow('WhatsApp API error: 400')
})
it('returns immediately without calling fetch when phoneNumber is empty', async () => {
await sendWhatsAppMessage('', 'ims_incident_alert', ['a'], 'pid', 'tok')
expect(fetchMock).not.toHaveBeenCalled()
})
it('sanitizes phone number — strips spaces, dashes, and plus sign', async () => {
fetchMock.mockResolvedValueOnce(
new Response('{"messages":[{"id":"x"}]}', { status: 200 })
)
await sendWhatsAppMessage('+60 12-345 6789', 'ims_incident_alert', ['a'], 'pid', 'tok')
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(body.to).toBe('60123456789')
})
})
```
- [ ] **Step 2: Run test to confirm it fails**
```bash
npx vitest run tests/lib/notifications/whatsapp.test.ts
```
Expected: FAIL — "Cannot find module '@/lib/notifications/whatsapp'"
- [ ] **Step 3: Create `lib/notifications/whatsapp.ts`**
```typescript
export async function sendWhatsAppMessage(
phoneNumber: string,
templateName: string,
parameters: string[],
phoneNumberId: string,
accessToken: string,
): Promise<void> {
const sanitized = phoneNumber.replace(/[^0-9]/g, '')
if (!sanitized) return
const body = {
messaging_product: 'whatsapp',
to: sanitized,
type: 'template',
template: {
name: templateName,
language: { code: 'en_US' },
components: [
{
type: 'body',
parameters: parameters.map(text => ({ type: 'text', text })),
},
],
},
}
const res = await fetch(
`https://graph.facebook.com/v19.0/${phoneNumberId}/messages`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(body),
}
)
if (!res.ok) {
throw new Error(`WhatsApp API error: ${res.status}`)
}
}
```
- [ ] **Step 4: Run test to confirm it passes**
```bash
npx vitest run tests/lib/notifications/whatsapp.test.ts
```
Expected: 4/4 PASS
- [ ] **Step 5: Create migration `supabase/migrations/20260711000015_phase4.sql`**
```sql
-- Add effectiveness recheck round tracking to capa_actions
ALTER TABLE capa_actions
ADD COLUMN IF NOT EXISTS effectiveness_recheck_round INT NOT NULL DEFAULT 0;
-- WhatsApp and effectiveness recheck credentials for Settings UI
INSERT INTO app_settings (key, value, updated_at, updated_by)
VALUES
('META_WHATSAPP_PHONE_NUMBER_ID', '', now(), NULL),
('META_WHATSAPP_ACCESS_TOKEN', '', now(), NULL)
ON CONFLICT (key) DO NOTHING;
```
- [ ] **Step 6: Expand ALLOWED_KEYS in `app/api/settings/route.ts`**
Change line 6 from:
```typescript
const ALLOWED_KEYS = ['ANTHROPIC_API_KEY', 'VOYAGE_API_KEY'] as const
type SettingKey = typeof ALLOWED_KEYS[number]
```
To:
```typescript
const ALLOWED_KEYS = [
'ANTHROPIC_API_KEY',
'VOYAGE_API_KEY',
'META_WHATSAPP_PHONE_NUMBER_ID',
'META_WHATSAPP_ACCESS_TOKEN',
] as const
type SettingKey = typeof ALLOWED_KEYS[number]
```
- [ ] **Step 7: Build check**
```bash
npm run build 2>&1 | tail -20
```
Expected: zero TypeScript/ESLint errors
- [ ] **Step 8: Commit**
```bash
git add lib/notifications/whatsapp.ts \
supabase/migrations/20260711000015_phase4.sql \
app/api/settings/route.ts \
tests/lib/notifications/whatsapp.test.ts
git commit -m "feat: WhatsApp notification helper + phase 4 migration (recheck_round, WhatsApp settings)"
```
---
## Task 2: WhatsApp integration — new incident alert + CAPA escalation
**Files:**
- Modify: `app/api/incidents/route.ts`
- Modify: `lib/notifications/capa-escalation.ts`
- Test: extend `tests/lib/notifications/whatsapp.test.ts` (no, this is in capa-escalation.test.ts)
**Interfaces:**
- Consumes: `sendWhatsAppMessage` from `lib/notifications/whatsapp.ts`
- Consumes: `getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')` from `lib/settings.ts`
**Note:** The `users` table has a `phone TEXT` column. WhatsApp sends only go to users with a non-empty phone. Entire WhatsApp block is fire-and-forget — never blocks the main request.
- [ ] **Step 1: Write the failing test for WhatsApp in capa escalation**
Read `tests/lib/notifications/capa-escalation.test.ts` first to understand the existing mock structure. Then:
1. Add `vi.mock('@/lib/notifications/whatsapp', () => ({ sendWhatsAppMessage: vi.fn().mockResolvedValue(undefined) }))` at the top of the file (alongside the existing vi.mock calls).
2. Import `sendWhatsAppMessage` from the mock: `import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'`
3. Add the following two test cases inside the existing `describe` block, after existing tests:
```typescript
// In tests/lib/notifications/capa-escalation.test.ts
// (add vi.mock and import above, then these test cases inside the describe block)
it('sends WhatsApp when threshold is overdue_7d and owner has phone', async () => {
// Use the same mock pattern as existing tests for supabase + resend.
// Key difference: mock the owner to have a phone number and set today 7 days after due_date.
// Then call escalateOverdueCapa(supabase) and assert:
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
// Set up getApiKey to return dummy credentials (mock lib/settings.ts if not already mocked):
// vi.mock('@/lib/settings', () => ({ getApiKey: vi.fn().mockResolvedValue('test-cred') }))
// The supabase mock must return a capa with due_date = 7 days ago and owner.phone set.
// After calling escalateOverdueCapa(supabase), expect:
expect(mockWA).toHaveBeenCalledWith(
expect.stringMatching(/^\d+$/), // sanitized phone digits
'ims_capa_overdue',
expect.arrayContaining([expect.any(String)]),
'test-cred',
'test-cred',
)
})
it('does NOT send WhatsApp when threshold is warning_3d', async () => {
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
vi.clearAllMocks()
// Set up supabase mock to return a capa with due_date = 3 days from today (warning_3d threshold).
// After calling escalateOverdueCapa(supabase):
expect(mockWA).not.toHaveBeenCalled()
})
```
**Implementation note for the test setup:** The existing tests in this file already mock `supabase` as a chainable query builder and `Resend`. Mirror that exact pattern — do not create new mock infrastructure. Only add the `whatsapp` mock and `settings` mock on top of what exists. If `lib/settings` is already mocked in the file, reuse that mock; if not, add `vi.mock('@/lib/settings', () => ({ getApiKey: vi.fn().mockResolvedValue('test-cred') }))` at the top.
- [ ] **Step 2: Modify `lib/notifications/capa-escalation.ts`**
Add imports at the top (after existing imports):
```typescript
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
import { getApiKey } from '@/lib/settings'
```
Inside `escalateOverdueCapa`, after the `resend.emails.send()` call and before the `notifications_log.insert()` call, add the WhatsApp block. Locate this section (around line 88):
```typescript
const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text })
if (error) {
console.error('Escalation email error:', error)
continue
}
```
Change to:
```typescript
const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text })
if (error) {
console.error('Escalation email error:', error)
continue
}
// WhatsApp for urgent thresholds only (owner must have a phone number)
if (['overdue_3d', 'overdue_7d'].includes(threshold)) {
const ownerPhone = (capa.owner as unknown as { phone: string | null } | null)?.phone ?? ''
if (ownerPhone) {
try {
const phoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
const accessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
await sendWhatsAppMessage(
ownerPhone,
'ims_capa_overdue',
[incidentRef, (capa as { description: string }).description, (capa as { due_date: string }).due_date],
phoneNumberId,
accessToken,
)
} catch (waErr) {
console.error('WhatsApp escalation error:', waErr)
}
}
}
```
Also update the `.select()` query near the top of `escalateOverdueCapa` to include `phone` in the owner join. Change:
```typescript
owner:users!owner_user_id (email, name)
```
To:
```typescript
owner:users!owner_user_id (email, name, phone)
```
- [ ] **Step 3: Add WhatsApp fire-and-forget to `app/api/incidents/route.ts`**
Add import at the top (after the existing `sendNewIncidentEmail` import):
```typescript
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
import { getApiKey } from '@/lib/settings'
```
Find the fire-and-forget email call (around line 109):
```typescript
sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type)
.catch(err => console.error('email notification failed:', err))
```
Add immediately after it:
```typescript
// WhatsApp alert — fire-and-forget alongside email
;(async () => {
try {
const phoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
const accessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
const { data: siteData } = await supabase
.from('sites').select('name').eq('id', zone.site_id).single()
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
const { data: recipients } = await supabase
.from('users')
.select('phone')
.in('role', ['supervisor', 'hse'])
.eq('site_id', zone.site_id)
for (const r of recipients ?? []) {
const phone = (r as { phone: string | null }).phone ?? ''
if (!phone) continue
await sendWhatsAppMessage(
phone,
'ims_incident_alert',
[incident.reference_no ?? '', input.incident_type, siteName],
phoneNumberId,
accessToken,
)
}
} catch (err) {
console.error('WhatsApp incident alert error:', err)
}
})()
```
- [ ] **Step 4: Build check**
```bash
npm run build 2>&1 | tail -20
```
Expected: zero errors
- [ ] **Step 5: Run tests**
```bash
npx vitest run tests/lib/notifications/
```
Expected: all pass (previous whatsapp.test.ts 4/4 + capa-escalation additions pass)
- [ ] **Step 6: Commit**
```bash
git add app/api/incidents/route.ts lib/notifications/capa-escalation.ts
git commit -m "feat: WhatsApp notifications — new incident alert and CAPA overdue escalation"
```
---
## Task 3: CAPA effectiveness re-check automation (30/60/90-day)
**Files:**
- Create: `lib/notifications/effectiveness-recheck.ts`
- Create: `app/api/cron/effectiveness-recheck/route.ts`
- Modify: `app/api/capa/[id]/verify/route.ts`
- Test: `tests/lib/notifications/effectiveness-recheck.test.ts`
**Interfaces:**
- Consumes: `sendWhatsAppMessage` from `lib/notifications/whatsapp.ts`
- Produces: `getNextRecheckDate(verifiedAt, currentRound): string | null` — pure, tested
- Produces: `shouldSendRecheck(recheckDate, round, today?): boolean` — pure, tested
- Produces: `sendEffectivenessRecheckNotifications(supabase): Promise<{notified: number}>`
**Re-check round semantics:**
```
effectiveness_recheck_round = 0 + recheck_date = verified_at + 30d → cron fires → send 30-day check → set round=1, date=verified_at+60d
effectiveness_recheck_round = 1 + recheck_date = verified_at + 60d → cron fires → send 60-day check → set round=2, date=verified_at+90d
effectiveness_recheck_round = 2 + recheck_date = verified_at + 90d → cron fires → send 90-day check → set round=3, date=null (done)
effectiveness_recheck_round = 3 → skip (done)
```
- [ ] **Step 1: Write the failing tests**
```typescript
// tests/lib/notifications/effectiveness-recheck.test.ts
import { describe, it, expect } from 'vitest'
import {
getNextRecheckDate,
shouldSendRecheck,
getRoundLabel,
} from '@/lib/notifications/effectiveness-recheck'
describe('getRoundLabel', () => {
it('returns "30-day" for round 0', () => {
expect(getRoundLabel(0)).toBe('30-day')
})
it('returns "60-day" for round 1', () => {
expect(getRoundLabel(1)).toBe('60-day')
})
it('returns "90-day" for round 2', () => {
expect(getRoundLabel(2)).toBe('90-day')
})
})
describe('getNextRecheckDate', () => {
// verifiedAt 2026-07-01. July has 31 days.
// +60d → July 1 + 60 = Aug 30
// +90d → July 1 + 90 = Sep 29
it('returns 60d from verifiedAt when round 0 just sent', () => {
expect(getNextRecheckDate('2026-07-01T00:00:00Z', 0)).toBe('2026-08-30')
})
it('returns 90d from verifiedAt when round 1 just sent', () => {
expect(getNextRecheckDate('2026-07-01T00:00:00Z', 1)).toBe('2026-09-29')
})
it('returns null when round 2 just sent (all done)', () => {
expect(getNextRecheckDate('2026-07-01T00:00:00Z', 2)).toBeNull()
})
})
describe('shouldSendRecheck', () => {
it('true when date equals today and round < 3', () => {
expect(shouldSendRecheck('2026-07-11', 0, '2026-07-11')).toBe(true)
})
it('true when date is in the past and round < 3', () => {
expect(shouldSendRecheck('2026-07-10', 2, '2026-07-11')).toBe(true)
})
it('false when round is 3 (all done)', () => {
expect(shouldSendRecheck('2026-07-10', 3, '2026-07-11')).toBe(false)
})
it('false when recheckDate is null', () => {
expect(shouldSendRecheck(null, 0, '2026-07-11')).toBe(false)
})
it('false when date is in the future', () => {
expect(shouldSendRecheck('2026-07-20', 0, '2026-07-11')).toBe(false)
})
})
```
- [ ] **Step 2: Run test to confirm failure**
```bash
npx vitest run tests/lib/notifications/effectiveness-recheck.test.ts
```
Expected: FAIL — module not found
- [ ] **Step 3: Create `lib/notifications/effectiveness-recheck.ts`**
```typescript
import { Resend } from 'resend'
import type { SupabaseClient } from '@supabase/supabase-js'
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
import { getApiKey } from '@/lib/settings'
const ROUND_LABELS = ['30-day', '60-day', '90-day'] as const
const NEXT_INTERVAL_DAYS: (number | null)[] = [60, 90, null]
export function getRoundLabel(round: number): string {
return ROUND_LABELS[round] ?? '90-day'
}
export function getNextRecheckDate(verifiedAt: string, currentRound: number): string | null {
const days = NEXT_INTERVAL_DAYS[currentRound]
if (days === null || days === undefined) return null
const base = new Date(verifiedAt)
base.setDate(base.getDate() + days)
return base.toISOString().split('T')[0]
}
export function shouldSendRecheck(
recheckDate: string | null,
round: number,
today: string = new Date().toISOString().split('T')[0],
): boolean {
if (!recheckDate || round >= 3) return false
return recheckDate <= today
}
export async function sendEffectivenessRecheckNotifications(
supabase: SupabaseClient,
): Promise<{ notified: number }> {
const today = new Date().toISOString().split('T')[0]
const { data: capas } = await supabase
.from('capa_actions')
.select(`
id, description, effectiveness_recheck_date, effectiveness_recheck_round,
verified_at, incident_id,
incidents (reference_no),
verifier:users!verified_by (email, name, phone)
`)
.in('status', ['verified', 'closed'])
.not('effectiveness_recheck_date', 'is', null)
.lt('effectiveness_recheck_round', 3)
if (!capas || capas.length === 0) return { notified: 0 }
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM_EMAIL ?? 'onboarding@resend.dev'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
let whatsappPhoneId: string | null = null
let whatsappToken: string | null = null
try {
whatsappPhoneId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
whatsappToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
} catch {
// WhatsApp not configured — email only
}
let notified = 0
for (const capa of capas) {
const recheckDate = (capa as { effectiveness_recheck_date: string | null }).effectiveness_recheck_date
const round = (capa as { effectiveness_recheck_round: number }).effectiveness_recheck_round
if (!shouldSendRecheck(recheckDate, round, today)) continue
const verifierEmail = (capa.verifier as unknown as { email: string } | null)?.email
const verifierName = (capa.verifier as unknown as { name: string } | null)?.name ?? 'HSE Officer'
const verifierPhone = (capa.verifier as unknown as { phone: string | null } | null)?.phone ?? ''
if (!verifierEmail) continue
const incidentRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no
?? (capa as { incident_id: string }).incident_id
const roundLabel = getRoundLabel(round)
const capaDesc = (capa as { description: string }).description
const capaUrl = `${siteUrl}/ims/hse/capa/${(capa as { id: string }).id}`
const subject = `[IMS] ${roundLabel} effectiveness check — ${incidentRef}`
const html = `
<p>Hi ${verifierName},</p>
<p>This is the <strong>${roundLabel} effectiveness check</strong> for a corrective action you verified on incident <strong>${incidentRef}</strong>.</p>
<p><strong>Action:</strong> ${capaDesc}</p>
<p>Please confirm the corrective action is still in place and effective.</p>
<p><a href="${capaUrl}">View CAPA</a></p>
`
const text = `${roundLabel} effectiveness check for ${incidentRef}\nAction: ${capaDesc}\n${capaUrl}`
const { error } = await resend.emails.send({ from, to: [verifierEmail], subject, html, text })
if (error) {
console.error('Effectiveness recheck email error:', error)
continue
}
// WhatsApp (non-blocking, best-effort)
if (whatsappPhoneId && whatsappToken && verifierPhone) {
sendWhatsAppMessage(
verifierPhone,
'ims_effectiveness_recheck',
[roundLabel, incidentRef, capaDesc],
whatsappPhoneId,
whatsappToken,
).catch(err => console.error('WhatsApp recheck error:', err))
}
// Advance round
const verifiedAt = (capa as { verified_at: string }).verified_at
const nextDate = getNextRecheckDate(verifiedAt, round)
await supabase
.from('capa_actions')
.update({
effectiveness_recheck_round: round + 1,
effectiveness_recheck_date: nextDate ?? null,
})
.eq('id', (capa as { id: string }).id)
notified++
}
return { notified }
}
```
- [ ] **Step 4: Run pure-function tests**
```bash
npx vitest run tests/lib/notifications/effectiveness-recheck.test.ts
```
Expected: 8/8 PASS
- [ ] **Step 5: Create `app/api/cron/effectiveness-recheck/route.ts`**
```typescript
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { sendEffectivenessRecheckNotifications } from '@/lib/notifications/effectiveness-recheck'
export async function GET(request: NextRequest) {
const auth = request.headers.get('authorization')
const expected = `Bearer ${process.env.CRON_SECRET}`
if (!auth || auth !== expected) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const supabase = await createClient()
const { notified } = await sendEffectivenessRecheckNotifications(supabase)
return NextResponse.json({ ok: true, notified })
}
```
- [ ] **Step 6: Modify `app/api/capa/[id]/verify/route.ts` — set recheck fields on verify**
Find the `update` object (around line 37):
```typescript
const update: Record<string, unknown> = {
status: body.verdict,
verified_by: user.id,
verified_at: new Date().toISOString(),
}
```
Change to:
```typescript
const verifiedAt = new Date()
const recheckDate = new Date(verifiedAt)
recheckDate.setDate(recheckDate.getDate() + 30)
const update: Record<string, unknown> = {
status: body.verdict,
verified_by: user.id,
verified_at: verifiedAt.toISOString(),
...(body.verdict === 'verified'
? {
effectiveness_recheck_date: recheckDate.toISOString().split('T')[0],
effectiveness_recheck_round: 0,
}
: {}),
}
```
- [ ] **Step 7: Build check**
```bash
npm run build 2>&1 | tail -20
```
Expected: zero errors
- [ ] **Step 8: Run all tests**
```bash
npx vitest run
```
Expected: all existing + new tests pass
- [ ] **Step 9: Commit**
```bash
git add lib/notifications/effectiveness-recheck.ts \
app/api/cron/effectiveness-recheck/route.ts \
app/api/capa/\[id\]/verify/route.ts \
tests/lib/notifications/effectiveness-recheck.test.ts
git commit -m "feat: CAPA effectiveness re-check cron — 30/60/90-day email + WhatsApp notifications"
```
---
## Task 4: i18n infrastructure + report form translations (EN/MS/ZH)
**Files:**
- Create: `lib/i18n/locales.ts`
- Create: `lib/i18n/context.tsx`
- Create: `lib/i18n/server.ts`
- Create: `messages/en.json`
- Create: `messages/ms.json`
- Create: `messages/zh.json`
- Create: `components/language-switcher.tsx`
- Modify: `app/layout.tsx`
- Modify: `app/report/page.tsx`
- Modify: `components/incidents/report-form.tsx`
- Test: `tests/lib/i18n/translations.test.ts`
**Interfaces:**
- Produces: `I18nProvider` (client component) — wraps layout, provides translations to tree
- Produces: `useTranslations<K>(namespace: K): Messages[K]` — client hook
- Produces: `getLocale(): Promise<Locale>` — server helper, reads `locale` cookie
- Produces: `loadMessages(locale: Locale): Promise<Messages>` — server helper
**Design decision:** No external i18n library. Cookie-based locale (no URL restructuring). `app/layout.tsx` becomes async server component to load messages. Locale switches trigger `router.refresh()` which re-renders server components with the new cookie.
- [ ] **Step 1: Write the failing translations test**
```typescript
// tests/lib/i18n/translations.test.ts
import { describe, it, expect } from 'vitest'
import en from '@/messages/en.json'
import ms from '@/messages/ms.json'
import zh from '@/messages/zh.json'
const NAMESPACES = ['ReportForm', 'IncidentType', 'MedicalStatus'] as const
NAMESPACES.forEach(ns => {
describe(`${ns} namespace`, () => {
const enKeys = Object.keys(en[ns])
it(`ms.${ns} has all keys present in en.${ns}`, () => {
const msKeys = Object.keys(ms[ns])
enKeys.forEach(key => expect(msKeys, `missing key: ${key}`).toContain(key))
})
it(`zh.${ns} has all keys present in en.${ns}`, () => {
const zhKeys = Object.keys(zh[ns])
enKeys.forEach(key => expect(zhKeys, `missing key: ${key}`).toContain(key))
})
it(`en.${ns} values are non-empty strings`, () => {
enKeys.forEach(key => {
const val = (en[ns] as Record<string, string>)[key]
expect(typeof val).toBe('string')
expect(val.length).toBeGreaterThan(0)
})
})
})
})
```
- [ ] **Step 2: Run test to confirm failure**
```bash
npx vitest run tests/lib/i18n/translations.test.ts
```
Expected: FAIL — modules not found
- [ ] **Step 3: Create `lib/i18n/locales.ts`**
```typescript
export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const
export type Locale = typeof SUPPORTED_LOCALES[number]
```
- [ ] **Step 4: Create `messages/en.json`**
```json
{
"ReportForm": {
"title": "Report an Incident",
"incidentTypeLabel": "Incident type",
"incidentTypePlaceholder": "Select type…",
"descriptionLabel": "What happened?",
"descriptionPlaceholder": "Describe what happened, where, and any immediate actions taken…",
"injuryInvolved": "Person was injured",
"treatmentLevel": "Treatment level",
"treatmentPlaceholder": "Select treatment…",
"assetInvolved": "Equipment / asset was damaged",
"filesLabel": "Photos / Videos / Documents",
"submitButton": "Submit Incident Report",
"submitting": "Submitting…",
"submitAnyway": "Submit anyway",
"qualityScoreLabel": "Report quality — {score}/10",
"errorGeneric": "Something went wrong. Please try again.",
"savedOffline": "Report saved. It will be submitted automatically when youre back online.",
"offlineBanner": "Youre offline. Your report will be saved and submitted when you reconnect."
},
"IncidentType": {
"injury": "Injury / Medical",
"near_miss": "Near Miss",
"hazard": "Hazard / Unsafe Condition",
"asset_damage": "Asset / Equipment Damage",
"environmental": "Environmental Incident",
"security": "Security Incident",
"fire": "Fire / Emergency"
},
"MedicalStatus": {
"none": "No treatment needed",
"first_aid": "First aid only",
"medical_treatment": "Medical treatment (non-LTI)",
"lti": "Lost Time Injury (LTI)"
}
}
```
- [ ] **Step 5: Create `messages/ms.json`**
```json
{
"ReportForm": {
"title": "Laporkan Insiden",
"incidentTypeLabel": "Jenis insiden",
"incidentTypePlaceholder": "Pilih jenis…",
"descriptionLabel": "Apa yang berlaku?",
"descriptionPlaceholder": "Terangkan apa yang berlaku, di mana, dan tindakan segera yang diambil…",
"injuryInvolved": "Seseorang telah cedera",
"treatmentLevel": "Tahap rawatan",
"treatmentPlaceholder": "Pilih rawatan…",
"assetInvolved": "Peralatan / aset rosak",
"filesLabel": "Foto / Video / Dokumen",
"submitButton": "Hantar Laporan Insiden",
"submitting": "Menghantar…",
"submitAnyway": "Hantar juga",
"qualityScoreLabel": "Kualiti laporan — {score}/10",
"errorGeneric": "Berlaku ralat. Sila cuba lagi.",
"savedOffline": "Laporan disimpan. Ia akan dihantar secara automatik apabila anda dalam talian semula.",
"offlineBanner": "Anda tiada sambungan. Laporan anda akan disimpan dan dihantar apabila disambungkan semula."
},
"IncidentType": {
"injury": "Kecederaan / Perubatan",
"near_miss": "Hampir Berlaku",
"hazard": "Bahaya / Keadaan Tidak Selamat",
"asset_damage": "Kerosakan Aset / Peralatan",
"environmental": "Insiden Alam Sekitar",
"security": "Insiden Keselamatan",
"fire": "Kebakaran / Kecemasan"
},
"MedicalStatus": {
"none": "Tiada rawatan diperlukan",
"first_aid": "Pertolongan cemas sahaja",
"medical_treatment": "Rawatan perubatan (bukan LTI)",
"lti": "Kecederaan Masa Hilang (LTI)"
}
}
```
- [ ] **Step 6: Create `messages/zh.json`**
```json
{
"ReportForm": {
"title": "事故报告",
"incidentTypeLabel": "事故类型",
"incidentTypePlaceholder": "选择类型…",
"descriptionLabel": "发生了什么?",
"descriptionPlaceholder": "描述发生了什么、在哪里,以及采取的即时行动…",
"injuryInvolved": "有人受伤",
"treatmentLevel": "治疗级别",
"treatmentPlaceholder": "选择治疗方式…",
"assetInvolved": "设备/资产受损",
"filesLabel": "照片/视频/文件",
"submitButton": "提交事故报告",
"submitting": "提交中…",
"submitAnyway": "仍然提交",
"qualityScoreLabel": "报告质量 — {score}/10",
"errorGeneric": "出现错误,请重试。",
"savedOffline": "报告已保存。当您重新联网时将自动提交。",
"offlineBanner": "您处于离线状态。您的报告将在重新联网时自动提交。"
},
"IncidentType": {
"injury": "受伤/医疗",
"near_miss": "未遂事故",
"hazard": "危险/不安全状况",
"asset_damage": "资产/设备损坏",
"environmental": "环境事故",
"security": "安全事故",
"fire": "火灾/紧急情况"
},
"MedicalStatus": {
"none": "无需治疗",
"first_aid": "仅急救",
"medical_treatment": "医疗治疗(非 LTI",
"lti": "工伤失时(LTI"
}
}
```
- [ ] **Step 7: Run translations test to confirm it passes**
```bash
npx vitest run tests/lib/i18n/translations.test.ts
```
Expected: 9/9 PASS (3 namespaces × 3 assertions each)
- [ ] **Step 8: Create `lib/i18n/context.tsx`**
```typescript
'use client'
import { createContext, useContext } from 'react'
import type en from '../../messages/en.json'
export type Messages = typeof en
const I18nContext = createContext<Messages | null>(null)
export function I18nProvider({
messages,
children,
}: {
messages: Messages
children: React.ReactNode
}) {
return <I18nContext.Provider value={messages}>{children}</I18nContext.Provider>
}
export function useTranslations<K extends keyof Messages>(namespace: K): Messages[K] {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useTranslations must be used inside I18nProvider')
return ctx[namespace]
}
```
- [ ] **Step 9: Create `lib/i18n/server.ts`**
```typescript
import { cookies } from 'next/headers'
import { SUPPORTED_LOCALES, type Locale } from './locales'
import type { Messages } from './context'
export async function getLocale(): Promise<Locale> {
const cookieStore = await cookies()
const lang = cookieStore.get('locale')?.value
if (lang && (SUPPORTED_LOCALES as readonly string[]).includes(lang)) {
return lang as Locale
}
return 'en'
}
export async function loadMessages(locale: Locale): Promise<Messages> {
switch (locale) {
case 'ms': return (await import('../../messages/ms.json')).default as Messages
case 'zh': return (await import('../../messages/zh.json')).default as Messages
default: return (await import('../../messages/en.json')).default as Messages
}
}
```
- [ ] **Step 10: Create `components/language-switcher.tsx`**
```typescript
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
const LOCALES: Record<string, string> = {
en: 'English',
ms: 'Bahasa Malaysia',
zh: '中文',
}
export function LanguageSwitcher() {
const router = useRouter()
const [current, setCurrent] = useState('en')
useEffect(() => {
const match = document.cookie
.split('; ')
.find(c => c.startsWith('locale='))
?.split('=')[1]
if (match && match in LOCALES) setCurrent(match)
}, [])
function handleChange(locale: string) {
document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`
setCurrent(locale)
router.refresh()
}
return (
<select
value={current}
onChange={e => handleChange(e.target.value)}
aria-label="Select language"
className="text-xs border border-gray-300 rounded px-2 py-1 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{Object.entries(LOCALES).map(([code, label]) => (
<option key={code} value={code}>{label}</option>
))}
</select>
)
}
```
- [ ] **Step 11: Modify `app/layout.tsx` — make async, add I18nProvider**
Replace the entire file with:
```typescript
import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import './globals.css'
import { I18nProvider } from '@/lib/i18n/context'
import { getLocale, loadMessages } from '@/lib/i18n/server'
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
})
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
})
export const metadata: Metadata = {
title: 'IMS — HSE Incident Management',
description: 'Setia Corporation HSE Incident Management System',
}
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
const locale = await getLocale()
const messages = await loadMessages(locale)
return (
<html
lang={locale}
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<head>
<link rel="manifest" href="/ims/manifest.json" />
</head>
<body className="min-h-full flex flex-col">
<I18nProvider messages={messages}>
{children}
</I18nProvider>
<script
dangerouslySetInnerHTML={{
__html: `if('serviceWorker'in navigator){navigator.serviceWorker.register('/ims/sw.js',{scope:'/ims/'}).catch(console.error)}`,
}}
/>
</body>
</html>
)
}
```
- [ ] **Step 12: Add LanguageSwitcher to `app/report/page.tsx`**
Read the current `app/report/page.tsx`. Find the `<h1>` heading block. Add the LanguageSwitcher import and render it next to the heading. The heading section currently looks like:
```typescript
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Report an Incident</h1>
```
Change to:
```typescript
import { LanguageSwitcher } from '@/components/language-switcher'
// inside the return:
<div className="mb-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">{/* will be translated via ReportForm */}Report an Incident</h1>
<LanguageSwitcher />
</div>
```
- [ ] **Step 13: Modify `components/incidents/report-form.tsx` to use translations**
Replace the entire file with (preserves all existing logic, replaces hardcoded strings):
```typescript
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { FileUpload } from '@/components/incidents/file-upload'
import type { IncidentType, MedicalStatus } from '@/lib/incidents/validate'
import { useTranslations } from '@/lib/i18n/context'
interface Props {
zoneToken: string | null
zoneName: string | null
siteName: string | null
}
export function ReportForm({ zoneToken }: Props) {
const router = useRouter()
const t = useTranslations('ReportForm')
const itLabels = useTranslations('IncidentType')
const msLabels = useTranslations('MedicalStatus')
const INCIDENT_TYPE_OPTIONS: [IncidentType, string][] = [
['injury', itLabels.injury],
['near_miss', itLabels.near_miss],
['hazard', itLabels.hazard],
['asset_damage', itLabels.asset_damage],
['environmental', itLabels.environmental],
['security', itLabels.security],
['fire', itLabels.fire],
]
const MEDICAL_STATUS_OPTIONS: [MedicalStatus, string][] = [
['none', msLabels.none],
['first_aid', msLabels.first_aid],
['medical_treatment', msLabels.medical_treatment],
['lti', msLabels.lti],
]
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [files, setFiles] = useState<File[]>([])
const [isOnline, setIsOnline] = useState(true)
const [savedOffline, setSavedOffline] = useState(false)
const [qualityCheck, setQualityCheck] = useState<{
score: number
passes: boolean
feedback: string
suggestions: string[]
} | null>(null)
const [overrideQuality, setOverrideQuality] = useState(false)
const [form, setForm] = useState({
incident_type: '' as IncidentType | '',
description: '',
injury_involved: false,
medical_status: '' as MedicalStatus | '',
asset_involved: false,
})
useEffect(() => {
setIsOnline(navigator.onLine)
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSubmitting(true)
// Offline path — save to IndexedDB
if (!isOnline) {
try {
const { addPendingReport } = await import('@/lib/offline/db')
await addPendingReport({
zone_token: zoneToken ?? '',
incident_type: form.incident_type as IncidentType,
description: form.description,
injury_involved: form.injury_involved,
asset_involved: form.asset_involved,
medical_status: form.medical_status || undefined,
created_at: new Date().toISOString(),
})
setSavedOffline(true)
} catch (err) {
console.error('Offline save error:', err)
setError(t.errorGeneric)
}
setSubmitting(false)
return
}
if (!overrideQuality) {
try {
const qcRes = await fetch('/api/incidents/ai/quality-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: form.description,
incident_type: form.incident_type,
}),
})
if (qcRes.ok) {
const qc = await qcRes.json() as {
score: number
passes: boolean
feedback: string
suggestions: string[]
}
setQualityCheck(qc)
if (!qc.passes) {
setSubmitting(false)
return
}
}
} catch {
// Quality check failure is non-blocking
}
}
try {
const fd = new FormData()
if (zoneToken) fd.append('zone_token', zoneToken)
fd.append('incident_type', form.incident_type)
fd.append('description', form.description)
fd.append('injury_involved', String(form.injury_involved))
fd.append('asset_involved', String(form.asset_involved))
if (form.injury_involved && form.medical_status) {
fd.append('medical_status', form.medical_status)
}
files.forEach(f => fd.append('files', f))
const res = await fetch('/api/incidents', { method: 'POST', body: fd })
const data = await res.json()
if (!res.ok) {
setError(data.details ? data.details.join('. ') : data.error)
return
}
router.push(`/report/success?ref=${data.reference_no}`)
} catch {
setError(t.errorGeneric)
} finally {
setSubmitting(false)
}
}
if (savedOffline) {
return (
<div className="bg-green-50 border border-green-200 rounded-xl p-5 text-sm text-green-700">
{t.savedOffline}
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-5 bg-white rounded-xl shadow-sm p-5">
{!isOnline && (
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 text-sm text-yellow-700">
{t.offlineBanner}
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t.incidentTypeLabel} <span className="text-red-500">*</span>
</label>
<select
required
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.incident_type}
onChange={e => setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))}
>
<option value="">{t.incidentTypePlaceholder}</option>
{INCIDENT_TYPE_OPTIONS.map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t.descriptionLabel} <span className="text-red-500">*</span>
</label>
<textarea
required
minLength={10}
rows={4}
placeholder={t.descriptionPlaceholder}
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
{qualityCheck && !qualityCheck.passes && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 space-y-2">
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide">
{t.qualityScoreLabel.replace('{score}', String(qualityCheck.score))}
</p>
<p className="text-sm text-amber-800">{qualityCheck.feedback}</p>
{qualityCheck.suggestions.length > 0 && (
<ul className="list-disc list-inside space-y-1">
{qualityCheck.suggestions.map((s, i) => (
<li key={i} className="text-xs text-amber-700">{s}</li>
))}
</ul>
)}
<label className="flex items-center gap-2 text-xs text-amber-700 cursor-pointer mt-1">
<input
type="checkbox"
checked={overrideQuality}
onChange={e => setOverrideQuality(e.target.checked)}
className="rounded border-amber-300 text-amber-600"
/>
{t.submitAnyway}
</label>
</div>
)}
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 text-blue-600"
checked={form.injury_involved}
onChange={e => setForm(f => ({ ...f, injury_involved: e.target.checked, medical_status: '' }))}
/>
<span className="text-sm font-medium text-gray-700">{t.injuryInvolved}</span>
</label>
{form.injury_involved && (
<div className="ml-7">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t.treatmentLevel} <span className="text-red-500">*</span>
</label>
<select
required={form.injury_involved}
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.medical_status}
onChange={e => setForm(f => ({ ...f, medical_status: e.target.value as MedicalStatus }))}
>
<option value="">{t.treatmentPlaceholder}</option>
{MEDICAL_STATUS_OPTIONS.map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
)}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 text-blue-600"
checked={form.asset_involved}
onChange={e => setForm(f => ({ ...f, asset_involved: e.target.checked }))}
/>
<span className="text-sm font-medium text-gray-700">{t.assetInvolved}</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t.filesLabel}
</label>
<FileUpload onFilesChange={setFiles} disabled={submitting} />
</div>
<button
type="submit"
disabled={submitting || !form.incident_type}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium text-sm
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? t.submitting : t.submitButton}
</button>
</form>
)
}
```
- [ ] **Step 14: Build check**
```bash
npm run build 2>&1 | tail -20
```
Expected: zero errors
- [ ] **Step 15: Run all tests**
```bash
npx vitest run
```
Expected: all previous tests + new translations tests pass
- [ ] **Step 16: Commit**
```bash
git add lib/i18n/ messages/ components/language-switcher.tsx \
app/layout.tsx app/report/page.tsx \
components/incidents/report-form.tsx \
tests/lib/i18n/
git commit -m "feat: i18n — EN/MS/ZH translations with cookie-based locale switching, report form translated"
```
---
## Task 5: PWA offline capture
**Files:**
- Create: `public/manifest.json`
- Create: `public/icons/icon.svg`
- Create: `public/sw.js`
- Create: `lib/offline/db.ts`
- Create: `components/incidents/offline-sync.tsx`
- Modify: `app/report/page.tsx`
- Test: `tests/lib/offline/db.test.ts`
**Interfaces:**
- Produces: `addPendingReport(report): Promise<number>` — saves to IndexedDB
- Produces: `getPendingReports(): Promise<PendingReport[]>`
- Produces: `removePendingReport(id: number): Promise<void>`
- Produces: `getPendingCount(): Promise<number>`
- Produces: `OfflineSync` (client component) — shows pending badge, auto-syncs on reconnect
**Note:** `idb` does not work in jsdom (no IndexedDB). Tests mock the `idb` module entirely — they verify the integration logic, not the browser API.
**Note on service worker scope:** `public/sw.js` is served at `/ims/sw.js` with `basePath: '/ims'`. Register with `{ scope: '/ims/' }`. Static assets at `/ims/_next/static/` are cache-first; pages are network-first with cache fallback.
- [ ] **Step 1: Install `idb`**
```bash
npm install idb
```
- [ ] **Step 2: Write the failing tests**
```typescript
// tests/lib/offline/db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockDb = {
add: vi.fn(),
getAll: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
}
vi.mock('idb', () => ({
openDB: vi.fn().mockResolvedValue(mockDb),
}))
// Import AFTER mock is set up
const { addPendingReport, getPendingReports, removePendingReport, getPendingCount } =
await import('@/lib/offline/db')
beforeEach(() => vi.clearAllMocks())
describe('addPendingReport', () => {
it('calls db.add on pending_reports store and returns the id', async () => {
mockDb.add.mockResolvedValueOnce(42)
const report = {
zone_token: 'abc',
incident_type: 'near_miss' as const,
description: 'Slippery floor',
injury_involved: false,
asset_involved: false,
created_at: '2026-07-11T00:00:00Z',
}
const id = await addPendingReport(report)
expect(mockDb.add).toHaveBeenCalledWith('pending_reports', report)
expect(id).toBe(42)
})
})
describe('getPendingReports', () => {
it('calls db.getAll on pending_reports store', async () => {
const reports = [
{ id: 1, zone_token: 'abc', incident_type: 'near_miss', description: 'test', injury_involved: false, asset_involved: false, created_at: '2026-07-11T00:00:00Z' },
]
mockDb.getAll.mockResolvedValueOnce(reports)
const result = await getPendingReports()
expect(mockDb.getAll).toHaveBeenCalledWith('pending_reports')
expect(result).toEqual(reports)
})
})
describe('removePendingReport', () => {
it('calls db.delete on pending_reports store with the id', async () => {
mockDb.delete.mockResolvedValueOnce(undefined)
await removePendingReport(1)
expect(mockDb.delete).toHaveBeenCalledWith('pending_reports', 1)
})
})
describe('getPendingCount', () => {
it('calls db.count and returns the result', async () => {
mockDb.count.mockResolvedValueOnce(3)
const count = await getPendingCount()
expect(mockDb.count).toHaveBeenCalledWith('pending_reports')
expect(count).toBe(3)
})
})
```
- [ ] **Step 3: Run test to confirm failure**
```bash
npx vitest run tests/lib/offline/db.test.ts
```
Expected: FAIL — module not found
- [ ] **Step 4: Create `lib/offline/db.ts`**
```typescript
import { openDB, type IDBPDatabase } from 'idb'
export interface PendingReport {
id?: number
zone_token: string
incident_type: string
description: string
injury_involved: boolean
asset_involved: boolean
medical_status?: string
created_at: string
}
let dbPromise: Promise<IDBPDatabase> | null = null
function getDb(): Promise<IDBPDatabase> {
if (!dbPromise) {
dbPromise = openDB('ims-offline', 1, {
upgrade(db) {
db.createObjectStore('pending_reports', { keyPath: 'id', autoIncrement: true })
},
})
}
return dbPromise
}
export async function addPendingReport(report: Omit<PendingReport, 'id'>): Promise<number> {
const db = await getDb()
return db.add('pending_reports', report) as Promise<number>
}
export async function getPendingReports(): Promise<PendingReport[]> {
const db = await getDb()
return db.getAll('pending_reports')
}
export async function removePendingReport(id: number): Promise<void> {
const db = await getDb()
return db.delete('pending_reports', id)
}
export async function getPendingCount(): Promise<number> {
const db = await getDb()
return db.count('pending_reports')
}
```
- [ ] **Step 5: Run offline db tests**
```bash
npx vitest run tests/lib/offline/db.test.ts
```
Expected: 4/4 PASS
- [ ] **Step 6: Create `public/icons/icon.svg`**
```bash
mkdir -p /Users/yapweeihan/Desktop/Projects/IMS.nosync/public/icons
```
Create `public/icons/icon.svg`:
```xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" fill="#2563eb" rx="24"/>
<text x="96" y="80" font-family="sans-serif" font-size="36" font-weight="bold" fill="white" text-anchor="middle">IMS</text>
<text x="96" y="126" font-family="sans-serif" font-size="22" fill="#bfdbfe" text-anchor="middle">HSE Report</text>
</svg>
```
- [ ] **Step 7: Create `public/manifest.json`**
```json
{
"name": "IMS — Incident Management",
"short_name": "IMS",
"description": "HSE Incident Management System — Setia Corporation",
"start_url": "/ims/report",
"scope": "/ims/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"icons": [
{
"src": "/ims/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
```
- [ ] **Step 8: Create `public/sw.js`**
```javascript
const CACHE_NAME = 'ims-v1'
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
)
self.clients.claim()
})
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
// Only handle same-origin GET requests under /ims/
if (
event.request.method !== 'GET' ||
url.origin !== self.location.origin ||
!url.pathname.startsWith('/ims/')
) {
return
}
// Skip API routes — always go to network
if (url.pathname.startsWith('/ims/api/')) return
// Cache-first for immutable Next.js static assets
if (url.pathname.startsWith('/ims/_next/static/')) {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached
return fetch(event.request).then(res => {
if (res.ok) {
const clone = res.clone()
caches.open(CACHE_NAME).then(c => c.put(event.request, clone))
}
return res
})
})
)
return
}
// Network-first for pages — fall back to cache when offline
event.respondWith(
fetch(event.request)
.then(res => {
if (res.ok) {
const clone = res.clone()
caches.open(CACHE_NAME).then(c => c.put(event.request, clone))
}
return res
})
.catch(() => caches.match(event.request))
)
})
```
- [ ] **Step 9: Create `components/incidents/offline-sync.tsx`**
```typescript
'use client'
import { useEffect, useState, useCallback } from 'react'
export function OfflineSync() {
const [pendingCount, setPendingCount] = useState(0)
const [syncing, setSyncing] = useState(false)
async function checkPending() {
const { getPendingCount } = await import('@/lib/offline/db')
setPendingCount(await getPendingCount())
}
const syncNow = useCallback(async () => {
if (syncing) return
setSyncing(true)
try {
const { getPendingReports, removePendingReport } = await import('@/lib/offline/db')
const reports = await getPendingReports()
for (const report of reports) {
const fd = new FormData()
fd.append('zone_token', report.zone_token)
fd.append('incident_type', report.incident_type)
fd.append('description', report.description)
fd.append('injury_involved', String(report.injury_involved))
fd.append('asset_involved', String(report.asset_involved))
if (report.medical_status) fd.append('medical_status', report.medical_status)
try {
const res = await fetch('/api/incidents', { method: 'POST', body: fd })
if (res.ok && report.id != null) {
await removePendingReport(report.id)
}
} catch {
// Network still unavailable — will retry on next online event
}
}
} finally {
await checkPending()
setSyncing(false)
}
}, [syncing])
useEffect(() => {
checkPending()
const handleOnline = () => { syncNow() }
window.addEventListener('online', handleOnline)
return () => window.removeEventListener('online', handleOnline)
}, [syncNow])
if (pendingCount === 0) return null
return (
<div className="fixed bottom-4 left-4 right-4 bg-yellow-50 border border-yellow-300 rounded-lg p-3 flex items-center justify-between shadow-md z-50">
<span className="text-sm text-yellow-800 font-medium">
{pendingCount} report{pendingCount > 1 ? 's' : ''} saved offline
</span>
<button
onClick={syncNow}
disabled={syncing || !navigator.onLine}
className="text-xs bg-yellow-600 text-white px-3 py-1.5 rounded font-medium
hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{syncing ? 'Syncing…' : 'Sync now'}
</button>
</div>
)
}
```
- [ ] **Step 10: Add OfflineSync to `app/report/page.tsx`**
Add import:
```typescript
import { OfflineSync } from '@/components/incidents/offline-sync'
```
Add `<OfflineSync />` at the bottom of the `<main>` element, just before its closing tag:
```typescript
<OfflineSync />
</main>
```
- [ ] **Step 11: Build check**
```bash
npm run build 2>&1 | tail -20
```
Expected: zero errors
- [ ] **Step 12: Run all tests**
```bash
npx vitest run
```
Expected: all tests pass (previous 61 + new whatsapp 4 + effectiveness-recheck 8 + translations 9 + offline db 4 = ≥86 total)
- [ ] **Step 13: Commit**
```bash
git add public/manifest.json public/icons/icon.svg public/sw.js \
lib/offline/db.ts \
components/incidents/offline-sync.tsx \
app/report/page.tsx \
tests/lib/offline/
git commit -m "feat: PWA offline capture — IndexedDB queue, service worker cache, auto-sync on reconnect"
```
---
## Post-Implementation: Cron Registration
Add effectiveness recheck to the cron schedule alongside CAPA escalation. See `docs/vps-cron.md` for the cron format. The command mirrors the existing escalation cron — daily at a different time:
```cron
# CAPA escalation (already registered)
0 8 * * * curl -s -H "Authorization: Bearer $CRON_SECRET" https://your-domain/ims/api/cron/capa-escalation
# CAPA effectiveness re-check (new)
30 8 * * * curl -s -H "Authorization: Bearer $CRON_SECRET" https://your-domain/ims/api/cron/effectiveness-recheck
```
Apply migration `20260711000015_phase4.sql` to Supabase cloud:
```bash
supabase db push
```