From c048878600624cbe806aa00b38952d9b12839c18 Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 11 Jul 2026 19:07:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20PWA=20offline=20capture=20=E2=80=94=20I?= =?UTF-8?q?ndexedDB=20queue,=20service=20worker=20cache,=20auto-sync=20on?= =?UTF-8?q?=20reconnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/report/page.tsx | 2 + components/incidents/offline-sync.tsx | 69 +++++++++++++++++++++++++++ lib/offline/db.ts | 37 ++++++++++++-- public/icons/icon.svg | 5 ++ public/manifest.json | 18 +++++++ public/sw.js | 60 +++++++++++++++++++++++ tests/lib/offline/db.test.ts | 64 +++++++++++++++++++++++++ 7 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 components/incidents/offline-sync.tsx create mode 100644 public/icons/icon.svg create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 tests/lib/offline/db.test.ts diff --git a/app/report/page.tsx b/app/report/page.tsx index 228fe3d..fc3160f 100644 --- a/app/report/page.tsx +++ b/app/report/page.tsx @@ -4,6 +4,7 @@ import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { ReportForm } from '@/components/incidents/report-form' import { LanguageSwitcher } from '@/components/language-switcher' +import { OfflineSync } from '@/components/incidents/offline-sync' interface Props { searchParams: Promise<{ zone?: string }> @@ -47,6 +48,7 @@ export default async function ReportPage({ searchParams }: Props) { )} + ) } diff --git a/components/incidents/offline-sync.tsx b/components/incidents/offline-sync.tsx new file mode 100644 index 0000000..3ff20c3 --- /dev/null +++ b/components/incidents/offline-sync.tsx @@ -0,0 +1,69 @@ +'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(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + checkPending() + const handleOnline = () => { syncNow() } + window.addEventListener('online', handleOnline) + return () => window.removeEventListener('online', handleOnline) + }, [syncNow]) + + if (pendingCount === 0) return null + + return ( +
+ + {pendingCount} report{pendingCount > 1 ? 's' : ''} saved offline + + +
+ ) +} diff --git a/lib/offline/db.ts b/lib/offline/db.ts index 95b3653..7795e80 100644 --- a/lib/offline/db.ts +++ b/lib/offline/db.ts @@ -1,7 +1,7 @@ -// Stub — full implementation in Task 5 (IndexedDB offline queue) -// This file exists so TypeScript can resolve the dynamic import in report-form.tsx. +import { openDB, type IDBPDatabase } from 'idb' export interface PendingReport { + id?: number zone_token: string incident_type: string description: string @@ -11,6 +11,35 @@ export interface PendingReport { created_at: string } -export async function addPendingReport(_report: PendingReport): Promise { - throw new Error('IndexedDB offline store not yet implemented — Task 5 will replace this stub') +let dbPromise: Promise | null = null + +function getDb(): Promise { + 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): Promise { + const db = await getDb() + return db.add('pending_reports', report) as Promise +} + +export async function getPendingReports(): Promise { + const db = await getDb() + return db.getAll('pending_reports') +} + +export async function removePendingReport(id: number): Promise { + const db = await getDb() + return db.delete('pending_reports', id) +} + +export async function getPendingCount(): Promise { + const db = await getDb() + return db.count('pending_reports') } diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..c28729b --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,5 @@ + + + IMS + HSE Report + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..433b482 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,18 @@ +{ + "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" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..4f3af3f --- /dev/null +++ b/public/sw.js @@ -0,0 +1,60 @@ +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)) + ) +}) diff --git a/tests/lib/offline/db.test.ts b/tests/lib/offline/db.test.ts new file mode 100644 index 0000000..4e831d1 --- /dev/null +++ b/tests/lib/offline/db.test.ts @@ -0,0 +1,64 @@ +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) + }) +})