-
Notifications
You must be signed in to change notification settings - Fork 32
feat: implement PWA offline mode with IndexedDB queue and sync manager #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,11 +4,14 @@ import posthog from 'posthog-js' | |||||||||||||
| import { PostHogProvider } from 'posthog-js/react' | ||||||||||||||
| import './index.css' | ||||||||||||||
| import App from './App.tsx' | ||||||||||||||
| import { initTheme } from './lib/theme'; | ||||||||||||||
| import { syncOfflineScans } from './utils/syncManager' | ||||||||||||||
| import { initTheme } from './lib/theme' | ||||||||||||||
|
Comment on lines
+7
to
9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove duplicate import.
🔧 Fix: Remove the duplicate import import { initTheme } from './lib/theme';
import { syncOfflineScans } from './utils/syncManager'
-import { initTheme } from './lib/theme'
import "./i18n";📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| import "./i18n"; | ||||||||||||||
|
|
||||||||||||||
| // Initialize theme before rendering the app to prevent flicker | ||||||||||||||
| initTheme(); | ||||||||||||||
| syncOfflineScans() | ||||||||||||||
|
|
||||||||||||||
| // PostHog is only initialized when the key is present. | ||||||||||||||
| // Contributors running locally without the key will have it silently disabled. | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| const DB_NAME = 'FreshScanDB' | ||
| const STORE_NAME = 'scanQueue' | ||
|
|
||
| export function openDB(): Promise<IDBDatabase> { | ||
| return new Promise((resolve, reject) => { | ||
| const req = indexedDB.open(DB_NAME, 1) | ||
| req.onupgradeneeded = (e: IDBVersionChangeEvent) => { | ||
| const db = (e.target as IDBOpenDBRequest).result | ||
| db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }) | ||
| } | ||
| req.onsuccess = () => resolve(req.result) | ||
| req.onerror = () => reject(req.error) | ||
| }) | ||
| } | ||
|
|
||
| export async function queueScan(scanData: Record<string, unknown>) { | ||
| const db = await openDB() | ||
| return new Promise((resolve, reject) => { | ||
| const tx = db.transaction(STORE_NAME, 'readwrite') | ||
| tx.objectStore(STORE_NAME).add({ ...scanData, timestamp: Date.now() }) | ||
| tx.oncomplete = resolve | ||
| tx.onerror = () => reject(tx.error) | ||
| }) | ||
| } | ||
|
SaumyaSngh323 marked this conversation as resolved.
|
||
|
|
||
| export async function getPendingScans() { | ||
| const db = await openDB() | ||
| return new Promise<Record<string, unknown>[]>((resolve, reject) => { | ||
| const tx = db.transaction(STORE_NAME, 'readonly') | ||
| const req = tx.objectStore(STORE_NAME).getAll() | ||
| req.onsuccess = () => resolve(req.result as Record<string, unknown>[]) | ||
| req.onerror = () => reject(req.error) | ||
| }) | ||
| } | ||
|
Comment on lines
+26
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Return type should match the queue item interface. The return type 📝 Proposed type fix-export async function getPendingScans() {
+export async function getPendingScans(): Promise<(ScanQueueItem & { id: number; timestamp: number })[]> {
const db = await openDB()
- return new Promise<Record<string, unknown>[]>((resolve, reject) => {
+ return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).getAll()
- req.onsuccess = () => resolve(req.result as Record<string, unknown>[])
+ req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}🤖 Prompt for AI Agents |
||
|
|
||
| export async function removeScan(id: number) { | ||
| const db = await openDB() | ||
| return new Promise((resolve, reject) => { | ||
| const tx = db.transaction(STORE_NAME, 'readwrite') | ||
| tx.objectStore(STORE_NAME).delete(id) | ||
| tx.oncomplete = resolve | ||
| tx.onerror = () => reject(tx.error) | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { getPendingScans, removeScan } from './offlineQueue' | ||
|
|
||
| // 1. Define a clean interface for a Scan item to resolve type issues | ||
| interface ScanItem { | ||
| id: number; | ||
| [key: string]: unknown; // Allows any other dynamic fields | ||
| } | ||
|
|
||
| async function uploadScan(scan: ScanItem) { | ||
| const response = await fetch('/api/scans', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(scan), | ||
| }) | ||
| if (!response.ok) throw new Error('Upload failed') | ||
| return response.json() | ||
| } | ||
|
SaumyaSngh323 marked this conversation as resolved.
|
||
|
|
||
| export async function syncOfflineScans() { | ||
| if (!navigator.onLine) return | ||
|
|
||
| // Cast the pending scans array to our ScanItem interface | ||
| const pending = (await getPendingScans()) as ScanItem[] | ||
|
|
||
| for (const scan of pending) { | ||
| try { | ||
| await uploadScan(scan) | ||
| await removeScan(scan.id) | ||
| console.log(`✅ Synced scan ID: ${scan.id}`) | ||
| } catch (err) { | ||
| console.error(`❌ Failed to sync scan ${scan.id}:`, err) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Auto sync when internet comes back | ||
| window.addEventListener('online', () => { | ||
| console.log('🌐 Back online – syncing offline scans...') | ||
| syncOfflineScans() | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,7 @@ | ||||||||
| import { defineConfig } from 'vite' | ||||||||
| import react from '@vitejs/plugin-react' | ||||||||
| import tailwindcss from '@tailwindcss/vite' | ||||||||
| import { VitePWA } from 'vite-plugin-pwa' | ||||||||
| import { defineConfig } from 'vite'; | ||||||||
| import react from '@vitejs/plugin-react'; | ||||||||
| import tailwindcss from '@tailwindcss/vite'; | ||||||||
|
|
@@ -10,6 +14,45 @@ export default defineConfig({ | |||||||
| tailwindcss(), | ||||||||
| VitePWA({ | ||||||||
| registerType: 'autoUpdate', | ||||||||
| includeAssets: ['favicon.ico', 'apple-touch-icon.png'], | ||||||||
| manifest: { | ||||||||
| name: 'FreshScan AI', | ||||||||
| short_name: 'FreshScan', | ||||||||
| description: 'AI-powered fish freshness scanner', | ||||||||
| theme_color: '#ffffff', | ||||||||
| background_color: '#ffffff', | ||||||||
| display: 'standalone', | ||||||||
| icons: [ | ||||||||
| { | ||||||||
| src: 'pwa-192x192.png', | ||||||||
| sizes: '192x192', | ||||||||
| type: 'image/png' | ||||||||
| }, | ||||||||
| { | ||||||||
| src: 'pwa-512x512.png', | ||||||||
| sizes: '512x512', | ||||||||
| type: 'image/png' | ||||||||
| } | ||||||||
| ] | ||||||||
| }, | ||||||||
|
SaumyaSngh323 marked this conversation as resolved.
|
||||||||
| workbox: { | ||||||||
| globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], | ||||||||
| runtimeCaching: [ | ||||||||
| { | ||||||||
| urlPattern: /^https?:\/\/localhost:8000\/api\//, | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Runtime caching pattern only matches localhost development requests. The You need to use a pattern that matches production API URLs or use a relative path pattern. ♻️ Proposed fix to cache production API requests- urlPattern: /^https?:\/\/localhost:8000\/api\//,
+ urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
handler: 'NetworkFirst',This will match 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||
| handler: 'NetworkFirst', | ||||||||
| options: { | ||||||||
| cacheName: 'api-cache', | ||||||||
| expiration: { | ||||||||
| maxEntries: 50, | ||||||||
| maxAgeSeconds: 60 * 60 * 24 | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
| ] | ||||||||
| } | ||||||||
| }) | ||||||||
|
Comment on lines
15
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: VitePWA plugin configured twice — incomplete merge removed critical caching rules. The PR adds a new
This directly confirms the reviewer's concern: "The PR reportedly overwrote vite.config.ts and removed a recent globIgnores fix. If merged, the app would attempt to precache a large .wasm file (~26MB) and crash." 🔧 Proposed fix to merge configurations correctlyRemove the duplicate old configuration and merge the runtime caching rules: VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
manifest: {
name: 'FreshScan AI',
short_name: 'FreshScan',
description: 'AI-powered fish freshness scanner',
theme_color: '`#ffffff`',
background_color: '`#ffffff`',
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
},
workbox: {
- globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
+ globPatterns: ['**/*.{js,css,html,ico,svg,gif,png}'],
runtimeCaching: [
+ {
+ urlPattern: /\/models\/.*\.onnx$/i,
+ handler: 'CacheFirst',
+ options: {
+ cacheName: 'onnx-models',
+ expiration: { maxAgeSeconds: 30 * 24 * 60 * 60 },
+ cacheableResponse: { statuses: [0, 200] },
+ },
+ },
+ {
+ urlPattern: /.*\.wasm$/i,
+ handler: 'CacheFirst',
+ options: {
+ cacheName: 'wasm-cache',
+ expiration: { maxAgeSeconds: 30 * 24 * 60 * 60 },
+ cacheableResponse: { statuses: [0, 200] },
+ },
+ },
{
urlPattern: /^https?:\/\/localhost:8000\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24
}
}
}
]
}
})
],
-
- // Use the existing manifest.json in public/
- manifest: false,
- workbox: {
- // Precache ONLY small app-shell assets (JS/CSS/HTML/icons).
- // DO NOT include .wasm or .onnx here — they are large (12–26 MB each)
- // and would cause the Service Worker install to time out.
- // They are handled below with lazy runtime caching instead.
- globPatterns: ['**/*.{js,css,html,ico,svg,gif,png}'],
- // Runtime caching: WASM and ONNX files are cached on first use
- // (CacheFirst) so subsequent offline scans load instantly.
- runtimeCaching: [
- {
- urlPattern: /\/models\/.*\.onnx$/i,
- handler: 'CacheFirst',
- options: {
- cacheName: 'onnx-models',
- expiration: { maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30 days
- cacheableResponse: { statuses: [0, 200] },
- },
- },
- {
- urlPattern: /.*\.wasm$/i,
- handler: 'CacheFirst',
- options: {
- cacheName: 'wasm-cache',
- expiration: { maxAgeSeconds: 30 * 24 * 60 * 60 },
- cacheableResponse: { statuses: [0, 200] },
- },
- },
- ],
- },
- }),
- ],
-Note: Also exclude Also applies to: 56-87 🤖 Prompt for AI Agents |
||||||||
| ], | ||||||||
| // Use the existing manifest.json in public/ | ||||||||
| manifest: false, | ||||||||
| workbox: { | ||||||||
|
|
@@ -45,15 +88,12 @@ export default defineConfig({ | |||||||
| ], | ||||||||
|
|
||||||||
| server: { | ||||||||
| // In local dev, proxy /api/* to the FastAPI backend at :8000. | ||||||||
| // This avoids CORS issues and means the frontend never needs to | ||||||||
| // hard-code the backend port. | ||||||||
| // In production, VITE_API_URL is set externally so this block is unused. | ||||||||
| proxy: { | ||||||||
| '/api': { | ||||||||
| target: 'http://localhost:8000', | ||||||||
| changeOrigin: true, | ||||||||
| }, | ||||||||
| }, | ||||||||
|
Comment on lines
92
to
96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify backend API endpoints and scan upload implementation
echo "=== Checking if /api/scans endpoint exists in backend ==="
fd -e py -e ts -e js . | xargs rg -n "POST.*['\"]/?scans['\"]|@.*route.*['\"]/?scans['\"]" 2>/dev/null || echo "No POST /scans route found"
echo ""
echo "=== Checking syncManager upload format ==="
rg -A 5 "fetch\('/api/scans'" src/utils/syncManager.ts
echo ""
echo "=== Checking if backend expects multipart/form-data ==="
fd -e py . | xargs rg -n "multipart|UploadFile|File\(" 2>/dev/null | head -20Repository: jpdevhub/FreshScanAi Length of output: 932 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "=== Find any occurrences of '/scans' or 'scans' route strings in backend ==="
rg -n --hidden --no-ignore -S '"/scans"|'\''/scans'\''|/scans|scans' backend || true
echo ""
echo "=== Inspect backend/main.py around UploadFile/File handlers (likely scan upload endpoints) ==="
python3 - <<'PY'
import re, pathlib
p = pathlib.Path("backend/main.py")
txt = p.read_text(encoding="utf-8", errors="ignore").splitlines()
for i,line in enumerate(txt, start=1):
if "UploadFile" in line or "File(" in line:
start=max(1,i-10); end=min(len(txt), i+20)
print(f"\n--- backend/main.py:{i} ---")
for j in range(start,end+1):
print(f"{j:5d}:{txt[j-1]}")
PY
echo ""
echo "=== Check FastAPI app/prefix/router mounting in backend/main.py ==="
rg -n 'include_router|APIRouter|prefix=|app\.' backend/main.py || trueRepository: jpdevhub/FreshScanAi Length of output: 17815 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "=== Show syncManager.ts upload request code around fetch('/api/scans') ==="
fd -e ts -e tsx -e js -e jsx syncManager src || true
fd -e ts -e tsx -e js -e jsx syncManager.ts . || true
FILE="$(fd -e ts -e tsx -e js -e jsx syncManager.ts . | head -n 1)"
echo "Using: $FILE"
nl -ba "$FILE" | sed -n '1,200p' | sed -n '1,120p' >/dev/null || true
# Print from around the fetch call
nl -ba "$FILE" | rg -n "fetch\\('\\/api\\/scans'\\)" -n --context=12 || trueRepository: jpdevhub/FreshScanAi Length of output: 425 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="src/utils/syncManager.ts"
[ -f "$FILE" ] || { echo "Missing $FILE"; exit 1; }
echo "=== Locate fetch('/api/scans' usage in $FILE ==="
rg -n "fetch\\('\\/api\\/scans'\\)|\\/api\\/scans" "$FILE" || true
echo ""
echo "=== Print syncManager.ts around first /api/scans occurrence ==="
LINE="$(rg -n "fetch\\('\\/api\\/scans'\\)|\\/api\\/scans" "$FILE" | head -n 1 | cut -d: -f1 || true)"
if [ -n "${LINE:-}" ]; then
START=$((LINE-20)); END=$((LINE+25));
START=$(( START<1 ? 1 : START ))
sed -n "${START},${END}p" "$FILE" | cat -n
else
echo "No /api/scans occurrence found."
fi
echo ""
echo "=== Also check whether syncManager calls /api/v1/scan or /api/v1/scan-auto ==="
rg -n "\\/api\\/v1\\/scan|\\/api\\/v1\\/scan-auto" "$FILE" || trueRepository: jpdevhub/FreshScanAi Length of output: 652 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="src/utils/syncManager.ts"
[ -f "$FILE" ] || { echo "Missing $FILE"; exit 1; }
echo "=== Fixed-string matches for endpoints in $FILE ==="
for s in "/api/scans" "/api/v1/scan" "/api/v1/scan-auto" "/api/v1/scans"; do
echo "--- $s ---"
rg -nF "$s" "$FILE" || true
echo ""
done
echo "=== Print around first '/api/scans' match ==="
LINE="$(rg -nF "/api/scans" "$FILE" | head -n 1 | cut -d: -f1 || true)"
if [ -n "${LINE:-}" ]; then
START=$((LINE-30)); END=$((LINE+30));
START=$(( START<1 ? 1 : START ))
sed -n "${START},${END}p" "$FILE" | cat -n
else
echo "No /api/scans match found."
fiRepository: jpdevhub/FreshScanAi Length of output: 1723 🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n "getPendingScans|offlineQueue" src/utils -S
fd -e ts offlineQueue src/utils -a 2>/dev/null || true
FILE="$(fd -e ts offlineQueue src/utils | head -n 1 || true)"
echo "Offline queue file: $FILE"
[ -f "$FILE" ] && sed -n '1,220p' "$FILE" | cat -n
rg -n "uploadScan\\(|syncOfflineScans\\(|scan\\.id|pending" src/utils -SRepository: jpdevhub/FreshScanAi Length of output: 2919 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="vite.config.ts"
[ -f "$FILE" ] || { echo "Missing $FILE"; exit 1; }
echo "=== vite.config.ts: showing lines 70-120 ==="
sed -n '70,120p' "$FILE" | cat -n
echo ""
echo "=== Find proxy config for /api ==="
rg -n "\"/api\"|'/api'|proxy" "$FILE" || trueRepository: jpdevhub/FreshScanAi Length of output: 1081 Fix sync manager upload: wrong backend route and wrong content type File: vite.config.ts
Adjust 🧰 Tools🪛 Biome (2.4.16)[error] 92-92: Expected a semicolon or an implicit semicolon after a statement, but found none (parse) [error] 94-94: Expected a semicolon or an implicit semicolon after a statement, but found none (parse) [error] 95-95: Expected a statement but instead found ','. (parse) [error] 96-96: Expected a statement but instead found ','. (parse) 🤖 Prompt for AI Agents |
||||||||
| }, | ||||||||
| }) | ||||||||
| }); | ||||||||
|
SaumyaSngh323 marked this conversation as resolved.
|
||||||||
Uh oh!
There was an error while loading. Please reload this page.