feat: PWA offline capture — IndexedDB queue, service worker cache, auto-sync on reconnect

This commit is contained in:
2026-07-11 19:07:35 +08:00
parent 8c88118b15
commit c048878600
7 changed files with 251 additions and 4 deletions
+2
View File
@@ -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) {
)}
</div>
<ReportForm zoneToken={zone ?? null} zoneName={zoneName} siteName={siteName} />
<OfflineSync />
</main>
)
}
+69
View File
@@ -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 (
<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>
)
}
+33 -4
View File
@@ -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<void> {
throw new Error('IndexedDB offline store not yet implemented — Task 5 will replace this stub')
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')
}
+5
View File
@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 371 B

+18
View File
@@ -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"
}
]
}
+60
View File
@@ -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))
)
})
+64
View File
@@ -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)
})
})