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 @@
+
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)
+ })
+})