- 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
60 KiB
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.rolecheck → 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')fromlib/settings.tsfor all API credentials — DB first, env fallback.basePath: '/ims'— hard-code/ims/prefix in manifeststart_url, SW registration path, and WhatsApp notification URLs. Client-sidefetch()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
.envsecrets committed. - Cron routes:
GETprotected byAuthorization: Bearer $CRON_SECRETheader. next.config.jsuses 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.tslib/notifications/effectiveness-recheck.tslib/i18n/locales.tslib/i18n/context.tsxlib/i18n/server.tsmessages/en.jsonmessages/ms.jsonmessages/zh.jsoncomponents/language-switcher.tsxcomponents/incidents/offline-sync.tsxlib/offline/db.tspublic/manifest.jsonpublic/icons/icon.svgpublic/sw.jsapp/api/cron/effectiveness-recheck/route.tssupabase/migrations/20260711000015_phase4.sqltests/lib/notifications/whatsapp.test.tstests/lib/notifications/effectiveness-recheck.test.tstests/lib/i18n/translations.test.tstests/lib/offline/db.test.ts
Modified files:
app/api/settings/route.ts— expand ALLOWED_KEYS with WhatsApp credentialsapp/api/incidents/route.ts— fire WhatsApp alongside emaillib/notifications/capa-escalation.ts— add WhatsApp for urgent thresholdsapp/api/capa/[id]/verify/route.ts— set effectiveness_recheck_date + round on verifyapp/layout.tsx— I18nProvider wrap + manifest link + SW registration scriptapp/report/page.tsx— add LanguageSwitchercomponents/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
// 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
npx vitest run tests/lib/notifications/whatsapp.test.ts
Expected: FAIL — "Cannot find module '@/lib/notifications/whatsapp'"
- Step 3: Create
lib/notifications/whatsapp.ts
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
npx vitest run tests/lib/notifications/whatsapp.test.ts
Expected: 4/4 PASS
- Step 5: Create migration
supabase/migrations/20260711000015_phase4.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:
const ALLOWED_KEYS = ['ANTHROPIC_API_KEY', 'VOYAGE_API_KEY'] as const
type SettingKey = typeof ALLOWED_KEYS[number]
To:
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
npm run build 2>&1 | tail -20
Expected: zero TypeScript/ESLint errors
- Step 8: Commit
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:
sendWhatsAppMessagefromlib/notifications/whatsapp.ts - Consumes:
getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')fromlib/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:
- Add
vi.mock('@/lib/notifications/whatsapp', () => ({ sendWhatsAppMessage: vi.fn().mockResolvedValue(undefined) }))at the top of the file (alongside the existing vi.mock calls). - Import
sendWhatsAppMessagefrom the mock:import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp' - Add the following two test cases inside the existing
describeblock, after existing tests:
// 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):
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):
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:
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:
owner:users!owner_user_id (email, name)
To:
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):
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
import { getApiKey } from '@/lib/settings'
Find the fire-and-forget email call (around line 109):
sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type)
.catch(err => console.error('email notification failed:', err))
Add immediately after it:
// 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
npm run build 2>&1 | tail -20
Expected: zero errors
- Step 5: Run tests
npx vitest run tests/lib/notifications/
Expected: all pass (previous whatsapp.test.ts 4/4 + capa-escalation additions pass)
- Step 6: Commit
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:
sendWhatsAppMessagefromlib/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
// 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
npx vitest run tests/lib/notifications/effectiveness-recheck.test.ts
Expected: FAIL — module not found
- Step 3: Create
lib/notifications/effectiveness-recheck.ts
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
npx vitest run tests/lib/notifications/effectiveness-recheck.test.ts
Expected: 8/8 PASS
- Step 5: Create
app/api/cron/effectiveness-recheck/route.ts
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):
const update: Record<string, unknown> = {
status: body.verdict,
verified_by: user.id,
verified_at: new Date().toISOString(),
}
Change to:
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
npm run build 2>&1 | tail -20
Expected: zero errors
- Step 8: Run all tests
npx vitest run
Expected: all existing + new tests pass
- Step 9: Commit
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, readslocalecookie - 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
// 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
npx vitest run tests/lib/i18n/translations.test.ts
Expected: FAIL — modules not found
- Step 3: Create
lib/i18n/locales.ts
export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const
export type Locale = typeof SUPPORTED_LOCALES[number]
- Step 4: Create
messages/en.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 you’re back online.",
"offlineBanner": "You’re 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
{
"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
{
"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
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
'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
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
'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:
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:
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Report an Incident</h1>
Change to:
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.tsxto use translations
Replace the entire file with (preserves all existing logic, replaces hardcoded strings):
'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
npm run build 2>&1 | tail -20
Expected: zero errors
- Step 15: Run all tests
npx vitest run
Expected: all previous tests + new translations tests pass
- Step 16: Commit
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
npm install idb
- Step 2: Write the failing tests
// 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
npx vitest run tests/lib/offline/db.test.ts
Expected: FAIL — module not found
- Step 4: Create
lib/offline/db.ts
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
npx vitest run tests/lib/offline/db.test.ts
Expected: 4/4 PASS
- Step 6: Create
public/icons/icon.svg
mkdir -p /Users/yapweeihan/Desktop/Projects/IMS.nosync/public/icons
Create public/icons/icon.svg:
<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
{
"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
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
'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:
import { OfflineSync } from '@/components/incidents/offline-sync'
Add <OfflineSync /> at the bottom of the <main> element, just before its closing tag:
<OfflineSync />
</main>
- Step 11: Build check
npm run build 2>&1 | tail -20
Expected: zero errors
- Step 12: Run all tests
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
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:
# 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:
supabase db push