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

60 KiB
Raw Blame History

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
// 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: 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:
// 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: 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
// 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, 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
// 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 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
{
  "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.tsx to 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