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 { createClient } from '@/lib/supabase/server'
|
||||||
import { ReportForm } from '@/components/incidents/report-form'
|
import { ReportForm } from '@/components/incidents/report-form'
|
||||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||||
|
import { OfflineSync } from '@/components/incidents/offline-sync'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchParams: Promise<{ zone?: string }>
|
searchParams: Promise<{ zone?: string }>
|
||||||
@@ -47,6 +48,7 @@ export default async function ReportPage({ searchParams }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ReportForm zoneToken={zone ?? null} zoneName={zoneName} siteName={siteName} />
|
<ReportForm zoneToken={zone ?? null} zoneName={zoneName} siteName={siteName} />
|
||||||
|
<OfflineSync />
|
||||||
</main>
|
</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)
|
import { openDB, type IDBPDatabase } from 'idb'
|
||||||
// This file exists so TypeScript can resolve the dynamic import in report-form.tsx.
|
|
||||||
|
|
||||||
export interface PendingReport {
|
export interface PendingReport {
|
||||||
|
id?: number
|
||||||
zone_token: string
|
zone_token: string
|
||||||
incident_type: string
|
incident_type: string
|
||||||
description: string
|
description: string
|
||||||
@@ -11,6 +11,35 @@ export interface PendingReport {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addPendingReport(_report: PendingReport): Promise<void> {
|
let dbPromise: Promise<IDBPDatabase> | null = null
|
||||||
throw new Error('IndexedDB offline store not yet implemented — Task 5 will replace this stub')
|
|
||||||
|
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