feat: PWA offline capture — IndexedDB queue, service worker cache, auto-sync on reconnect
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user