mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
feat: complete offline write support with mutation queue + runtime SW cache config
- Add offline CRUD to todoRepo, budgetRepo, reservationRepo, accommodationRepo, dayRepo, tripRepo, fileRepo with optimistic Dexie writes and mutation queue - Wire all store slices (todo, budget, reservations, files, dayNotes, assignments, tripStore) through repos for offline-aware writes - Cover archive/unarchive, file toggleStar/update/delete, assignment create/delete, day title/notes update offline paths - Migrate service worker from generateSW to injectManifest (custom sw.ts) with runtime-configurable api-data (7d/500) and map-tiles (30d/1000) cache policies - Add Settings → Offline cache configuration UI with save/reset and live SW postMessage - Extend mutationQueue flush to cover all writable Dexie tables
This commit is contained in:
@@ -5,6 +5,7 @@ import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, M
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { filesApi } from '../../api/client'
|
||||
import { fileRepo } from '../../repo/fileRepo'
|
||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -290,7 +291,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
|
||||
const handleStar = async (fileId: number) => {
|
||||
try {
|
||||
await filesApi.toggleStar(tripId, fileId)
|
||||
await fileRepo.toggleStar(tripId, fileId)
|
||||
refreshFiles()
|
||||
} catch { /* */ }
|
||||
}
|
||||
@@ -409,7 +410,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
|
||||
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
||||
try {
|
||||
await filesApi.update(tripId, fileId, data)
|
||||
await fileRepo.update(tripId, fileId, data as Record<string, unknown>)
|
||||
refreshFiles()
|
||||
} catch {
|
||||
toast.error(t('files.toast.assignError'))
|
||||
|
||||
@@ -5,6 +5,7 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
|
||||
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||
import { weatherApi, accommodationsApi } from '../../api/client'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -117,7 +118,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const handleSaveAccommodation = async () => {
|
||||
if (!hotelForm.place_id) return
|
||||
try {
|
||||
const data = await accommodationsApi.create(tripId, {
|
||||
const data = await accommodationRepo.create(tripId, {
|
||||
place_id: hotelForm.place_id,
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
@@ -142,7 +143,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const updateAccommodationField = async (field, value) => {
|
||||
if (!accommodation) return
|
||||
try {
|
||||
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
|
||||
const data = await accommodationRepo.update(tripId, accommodation.id, { [field]: value || null })
|
||||
setAccommodation(data.accommodation)
|
||||
onAccommodationChange?.()
|
||||
} catch {}
|
||||
@@ -151,7 +152,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const handleRemoveAccommodation = async () => {
|
||||
if (!accommodation) return
|
||||
try {
|
||||
await accommodationsApi.delete(tripId, accommodation.id)
|
||||
await accommodationRepo.delete(tripId, accommodation.id)
|
||||
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
||||
setAccommodations(updated)
|
||||
setDayAccommodations(updated.filter(a =>
|
||||
@@ -583,7 +584,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<button onClick={async () => {
|
||||
if (showHotelPicker === 'edit' && accommodation) {
|
||||
// Update existing
|
||||
await accommodationsApi.update(tripId, accommodation.id, {
|
||||
await accommodationRepo.update(tripId, accommodation.id, {
|
||||
place_id: hotelForm.place_id,
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* Offline settings tab — shows cached trips, storage info, and controls
|
||||
* to re-sync or clear the offline cache.
|
||||
* to re-sync or clear the offline cache. Also exposes runtime SW cache config.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import { offlineDb, clearAll } from '../../db/offlineDb'
|
||||
import { tripSyncManager } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import {
|
||||
DEFAULT_SW_CONFIG,
|
||||
loadSwConfig,
|
||||
saveSwConfig,
|
||||
validateSwConfig,
|
||||
SW_CONFIG_BOUNDS,
|
||||
type SwCacheConfig,
|
||||
} from '../../sync/swConfig'
|
||||
import type { SyncMeta } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
@@ -25,6 +33,12 @@ export default function OfflineTab(): React.ReactElement {
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Cache config state
|
||||
const [cacheConfig, setCacheConfig] = useState<SwCacheConfig>({ ...DEFAULT_SW_CONFIG })
|
||||
const [configSaving, setConfigSaving] = useState(false)
|
||||
const [configApplied, setConfigApplied] = useState<Date | null>(null)
|
||||
const appliedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -53,6 +67,59 @@ export default function OfflineTab(): React.ReactElement {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Load persisted cache config on mount
|
||||
useEffect(() => {
|
||||
loadSwConfig().then(setCacheConfig).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Listen for SW acknowledgement
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'CACHE_CONFIG_APPLIED') {
|
||||
setConfigApplied(new Date())
|
||||
setConfigSaving(false)
|
||||
if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current)
|
||||
appliedTimerRef.current = setTimeout(() => setConfigApplied(null), 5000)
|
||||
}
|
||||
}
|
||||
navigator.serviceWorker?.addEventListener('message', handler)
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener('message', handler)
|
||||
if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleSaveConfig() {
|
||||
const validated = validateSwConfig(cacheConfig)
|
||||
setCacheConfig(validated)
|
||||
setConfigSaving(true)
|
||||
try {
|
||||
await saveSwConfig(validated)
|
||||
const controller = navigator.serviceWorker?.controller
|
||||
if (controller) {
|
||||
controller.postMessage({ type: 'UPDATE_CACHE_CONFIG', config: validated })
|
||||
// configSaving cleared by the SW message handler
|
||||
} else {
|
||||
// No active SW yet (e.g. first install) — config saved to IDB, applied on next SW activation
|
||||
setConfigApplied(new Date())
|
||||
setConfigSaving(false)
|
||||
}
|
||||
} catch {
|
||||
setConfigSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetConfig() {
|
||||
setCacheConfig({ ...DEFAULT_SW_CONFIG })
|
||||
}
|
||||
|
||||
function updateField(field: keyof SwCacheConfig) {
|
||||
return (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!isNaN(v)) setCacheConfig(prev => ({ ...prev, [field]: v }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
@@ -120,6 +187,86 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cache configuration */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Settings2 size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Cache configuration</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
|
||||
Changes apply immediately to the service worker and persist across reloads.
|
||||
Existing cached entries follow their original TTL; new entries use the updated settings.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<CacheField
|
||||
label="API cache TTL (days)"
|
||||
value={cacheConfig.apiTtlDays}
|
||||
min={SW_CONFIG_BOUNDS.ttlMin}
|
||||
max={SW_CONFIG_BOUNDS.ttlMax}
|
||||
onChange={updateField('apiTtlDays')}
|
||||
/>
|
||||
<CacheField
|
||||
label="API max entries"
|
||||
value={cacheConfig.apiMaxEntries}
|
||||
min={SW_CONFIG_BOUNDS.entriesMin}
|
||||
max={SW_CONFIG_BOUNDS.entriesMax}
|
||||
onChange={updateField('apiMaxEntries')}
|
||||
/>
|
||||
<CacheField
|
||||
label="Map tiles TTL (days)"
|
||||
value={cacheConfig.tilesTtlDays}
|
||||
min={SW_CONFIG_BOUNDS.ttlMin}
|
||||
max={SW_CONFIG_BOUNDS.ttlMax}
|
||||
onChange={updateField('tilesTtlDays')}
|
||||
/>
|
||||
<CacheField
|
||||
label="Map tiles max entries"
|
||||
value={cacheConfig.tilesMaxEntries}
|
||||
min={SW_CONFIG_BOUNDS.entriesMin}
|
||||
max={SW_CONFIG_BOUNDS.entriesMax}
|
||||
onChange={updateField('tilesMaxEntries')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={configSaving}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
|
||||
cursor: configSaving ? 'not-allowed' : 'pointer',
|
||||
fontSize: 13, fontWeight: 500, opacity: configSaving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} style={configSaving ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{configSaving ? 'Applying…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetConfig}
|
||||
disabled={configSaving}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-muted)',
|
||||
cursor: configSaving ? 'not-allowed' : 'pointer',
|
||||
fontSize: 13, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Reset to defaults
|
||||
</button>
|
||||
{configApplied && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: '#22c55e' }}>
|
||||
<CheckCircle size={12} />
|
||||
Applied at {configApplied.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cached trip list */}
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading…</p>
|
||||
@@ -139,24 +286,32 @@ export default function OfflineTab(): React.ReactElement {
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
|
||||
{trip.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: trip.title ? 'var(--text-primary)' : 'var(--text-muted)', fontStyle: trip.title ? 'normal' : 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.title || 'Unnamed trip'}
|
||||
</span>
|
||||
{trip.description ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.description.length > 72 ? trip.description.slice(0, 72) + '…' : trip.description}
|
||||
</span>
|
||||
) : null}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{trip.start_date
|
||||
? `${formatDate(trip.start_date)} – ${formatDate(trip.end_date)}`
|
||||
: 'No dates set'}
|
||||
{' · '}
|
||||
{placeCount} place{placeCount !== 1 ? 's' : ''}
|
||||
{fileCount > 0 ? ` · ${fileCount} file${fileCount !== 1 ? 's' : ''}` : null}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
|
||||
{meta.lastSyncedAt
|
||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{formatDate(trip.start_date)} – {formatDate(trip.end_date)}
|
||||
{' · '}
|
||||
{placeCount} place{placeCount !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -178,3 +333,32 @@ function Stat({ label, value }: { label: string; value: number }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CacheField({
|
||||
label, value, min, max, onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 500 }}>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={onChange}
|
||||
style={{
|
||||
padding: '6px 10px', borderRadius: 6,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
|
||||
fontSize: 13, width: '100%', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -791,7 +791,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
|
||||
const handleArchive = async (id) => {
|
||||
try {
|
||||
const data = await tripsApi.archive(id)
|
||||
const data = await tripRepo.update(id, { is_archived: true })
|
||||
setTrips(prev => prev.filter(t => t.id !== id))
|
||||
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.archived'))
|
||||
@@ -802,7 +802,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
|
||||
const handleUnarchive = async (id) => {
|
||||
try {
|
||||
const data = await tripsApi.unarchive(id)
|
||||
const data = await tripRepo.update(id, { is_archived: false })
|
||||
setArchivedTrips(prev => prev.filter(t => t.id !== id))
|
||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.restored'))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { accommodationsApi } from '../api/client'
|
||||
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import type { Accommodation } from '../types'
|
||||
|
||||
export const accommodationRepo = {
|
||||
@@ -13,4 +14,77 @@ export const accommodationRepo = {
|
||||
upsertAccommodations(result.accommodations || []).catch(() => {})
|
||||
return result
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ accommodation: Accommodation }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempAccommodation: Accommodation = {
|
||||
...(data as Partial<Accommodation>),
|
||||
id: tempId,
|
||||
trip_id: Number(tripId),
|
||||
name: (data.name as string) ?? 'New accommodation',
|
||||
address: null,
|
||||
check_in: null,
|
||||
check_in_end: null,
|
||||
check_out: null,
|
||||
confirmation_number: null,
|
||||
notes: null,
|
||||
url: null,
|
||||
created_at: new Date().toISOString(),
|
||||
} as Accommodation
|
||||
await offlineDb.accommodations.put(tempAccommodation)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'POST',
|
||||
url: `/trips/${tripId}/accommodations`,
|
||||
body: data,
|
||||
resource: 'accommodations',
|
||||
tempId,
|
||||
})
|
||||
return { accommodation: tempAccommodation }
|
||||
}
|
||||
const result = await accommodationsApi.create(tripId, data)
|
||||
offlineDb.accommodations.put(result.accommodation)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ accommodation: Accommodation }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.accommodations.get(id)
|
||||
const optimistic: Accommodation = { ...(existing ?? {} as Accommodation), ...(data as Partial<Accommodation>), id }
|
||||
await offlineDb.accommodations.put(optimistic)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/accommodations/${id}`,
|
||||
body: data,
|
||||
resource: 'accommodations',
|
||||
})
|
||||
return { accommodation: optimistic }
|
||||
}
|
||||
const result = await accommodationsApi.update(tripId, id, data)
|
||||
offlineDb.accommodations.put(result.accommodation)
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.accommodations.delete(id)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/accommodations/${id}`,
|
||||
body: undefined,
|
||||
resource: 'accommodations',
|
||||
entityId: id,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const result = await accommodationsApi.delete(tripId, id)
|
||||
offlineDb.accommodations.delete(id)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { budgetApi } from '../api/client'
|
||||
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import type { BudgetItem } from '../types'
|
||||
|
||||
export const budgetRepo = {
|
||||
@@ -15,4 +16,72 @@ export const budgetRepo = {
|
||||
upsertBudgetItems(result.items)
|
||||
return result
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: BudgetItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempItem: BudgetItem = {
|
||||
...(data as Partial<BudgetItem>),
|
||||
id: tempId,
|
||||
trip_id: Number(tripId),
|
||||
name: (data.name as string) ?? 'New expense',
|
||||
amount: (data.amount as number) ?? 0,
|
||||
currency: (data.currency as string) ?? 'USD',
|
||||
members: [],
|
||||
} as BudgetItem
|
||||
await offlineDb.budgetItems.put(tempItem)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'POST',
|
||||
url: `/trips/${tripId}/budget`,
|
||||
body: data,
|
||||
resource: 'budgetItems',
|
||||
tempId,
|
||||
})
|
||||
return { item: tempItem }
|
||||
}
|
||||
const result = await budgetApi.create(tripId, data)
|
||||
offlineDb.budgetItems.put(result.item)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: BudgetItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.budgetItems.get(id)
|
||||
const optimistic: BudgetItem = { ...(existing ?? {} as BudgetItem), ...(data as Partial<BudgetItem>), id }
|
||||
await offlineDb.budgetItems.put(optimistic)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/budget/${id}`,
|
||||
body: data,
|
||||
resource: 'budgetItems',
|
||||
})
|
||||
return { item: optimistic }
|
||||
}
|
||||
const result = await budgetApi.update(tripId, id, data)
|
||||
offlineDb.budgetItems.put(result.item)
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.budgetItems.delete(id)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/budget/${id}`,
|
||||
body: undefined,
|
||||
resource: 'budgetItems',
|
||||
entityId: id,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const result = await budgetApi.delete(tripId, id)
|
||||
offlineDb.budgetItems.delete(id)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { daysApi } from '../api/client'
|
||||
import { offlineDb, upsertDays } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import type { Day } from '../types'
|
||||
|
||||
export const dayRepo = {
|
||||
@@ -15,4 +16,24 @@ export const dayRepo = {
|
||||
upsertDays(result.days)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, dayId: number | string, data: Record<string, unknown>): Promise<{ day: Day }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.days.get(Number(dayId))
|
||||
const optimistic: Day = { ...(existing ?? {} as Day), ...(data as Partial<Day>), id: Number(dayId) }
|
||||
await offlineDb.days.put(optimistic)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/days/${dayId}`,
|
||||
body: data,
|
||||
resource: 'days',
|
||||
})
|
||||
return { day: optimistic }
|
||||
}
|
||||
const result = await daysApi.update(tripId, dayId, data)
|
||||
offlineDb.days.put(result.day)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { filesApi } from '../api/client'
|
||||
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import type { TripFile } from '../types'
|
||||
|
||||
export const fileRepo = {
|
||||
@@ -15,4 +16,61 @@ export const fileRepo = {
|
||||
upsertTripFiles(result.files)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.tripFiles.get(id)
|
||||
if (existing) await offlineDb.tripFiles.put({ ...existing, ...(data as Partial<TripFile>) })
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/files/${id}`,
|
||||
body: data,
|
||||
resource: 'tripFiles',
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const result = await filesApi.update(tripId, id, data)
|
||||
const file = (result as { file?: TripFile }).file
|
||||
if (file) offlineDb.tripFiles.put(file)
|
||||
return result
|
||||
},
|
||||
|
||||
async toggleStar(tripId: number | string, id: number): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.tripFiles.get(id)
|
||||
if (existing) {
|
||||
await offlineDb.tripFiles.put({ ...existing, starred: existing.starred ? 0 : 1 })
|
||||
}
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'PATCH',
|
||||
url: `/trips/${tripId}/files/${id}/star`,
|
||||
body: undefined,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
return filesApi.toggleStar(tripId, id)
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.tripFiles.delete(id)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/files/${id}`,
|
||||
body: undefined,
|
||||
resource: 'tripFiles',
|
||||
entityId: id,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const result = await filesApi.delete(tripId, id)
|
||||
offlineDb.tripFiles.delete(id)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { reservationsApi } from '../api/client'
|
||||
import { offlineDb, upsertReservations } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import type { Reservation } from '../types'
|
||||
|
||||
export const reservationRepo = {
|
||||
@@ -15,4 +16,77 @@ export const reservationRepo = {
|
||||
upsertReservations(result.reservations)
|
||||
return result
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ reservation: Reservation }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempReservation: Reservation = {
|
||||
...(data as Partial<Reservation>),
|
||||
id: tempId,
|
||||
trip_id: Number(tripId),
|
||||
name: (data.name as string) ?? 'New reservation',
|
||||
type: (data.type as string) ?? 'other',
|
||||
status: 'pending',
|
||||
date: (data.date as string) ?? null,
|
||||
time: null,
|
||||
confirmation_number: null,
|
||||
notes: null,
|
||||
url: null,
|
||||
created_at: new Date().toISOString(),
|
||||
} as Reservation
|
||||
await offlineDb.reservations.put(tempReservation)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'POST',
|
||||
url: `/trips/${tripId}/reservations`,
|
||||
body: data,
|
||||
resource: 'reservations',
|
||||
tempId,
|
||||
})
|
||||
return { reservation: tempReservation }
|
||||
}
|
||||
const result = await reservationsApi.create(tripId, data)
|
||||
offlineDb.reservations.put(result.reservation)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ reservation: Reservation }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.reservations.get(id)
|
||||
const optimistic: Reservation = { ...(existing ?? {} as Reservation), ...(data as Partial<Reservation>), id }
|
||||
await offlineDb.reservations.put(optimistic)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/reservations/${id}`,
|
||||
body: data,
|
||||
resource: 'reservations',
|
||||
})
|
||||
return { reservation: optimistic }
|
||||
}
|
||||
const result = await reservationsApi.update(tripId, id, data)
|
||||
offlineDb.reservations.put(result.reservation)
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.reservations.delete(id)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/reservations/${id}`,
|
||||
body: undefined,
|
||||
resource: 'reservations',
|
||||
entityId: id,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const result = await reservationsApi.delete(tripId, id)
|
||||
offlineDb.reservations.delete(id)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { todoApi } from '../api/client'
|
||||
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import type { TodoItem } from '../types'
|
||||
|
||||
export const todoRepo = {
|
||||
@@ -15,4 +16,75 @@ export const todoRepo = {
|
||||
upsertTodoItems(result.items)
|
||||
return result
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: TodoItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempItem: TodoItem = {
|
||||
...(data as Partial<TodoItem>),
|
||||
id: tempId,
|
||||
trip_id: Number(tripId),
|
||||
name: (data.name as string) ?? 'New todo',
|
||||
checked: 0,
|
||||
sort_order: 0,
|
||||
due_date: null,
|
||||
description: null,
|
||||
assigned_user_id: null,
|
||||
priority: 0,
|
||||
} as TodoItem
|
||||
await offlineDb.todoItems.put(tempItem)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'POST',
|
||||
url: `/trips/${tripId}/todo`,
|
||||
body: data,
|
||||
resource: 'todoItems',
|
||||
tempId,
|
||||
})
|
||||
return { item: tempItem }
|
||||
}
|
||||
const result = await todoApi.create(tripId, data)
|
||||
offlineDb.todoItems.put(result.item)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: TodoItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.todoItems.get(id)
|
||||
const optimistic: TodoItem = { ...(existing ?? {} as TodoItem), ...(data as Partial<TodoItem>), id }
|
||||
await offlineDb.todoItems.put(optimistic)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/todo/${id}`,
|
||||
body: data,
|
||||
resource: 'todoItems',
|
||||
})
|
||||
return { item: optimistic }
|
||||
}
|
||||
const result = await todoApi.update(tripId, id, data)
|
||||
offlineDb.todoItems.put(result.item)
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.todoItems.delete(id)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/todo/${id}`,
|
||||
body: undefined,
|
||||
resource: 'todoItems',
|
||||
entityId: id,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const result = await todoApi.delete(tripId, id)
|
||||
offlineDb.todoItems.delete(id)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { tripsApi } from '../api/client'
|
||||
import { offlineDb, upsertTrip } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import type { Trip } from '../types'
|
||||
|
||||
export const tripRepo = {
|
||||
@@ -30,4 +31,24 @@ export const tripRepo = {
|
||||
upsertTrip(result.trip)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, data: Partial<Trip>): Promise<{ trip: Trip }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.trips.get(Number(tripId))
|
||||
const optimistic: Trip = { ...(existing ?? {} as Trip), ...(data as Partial<Trip>), id: Number(tripId) }
|
||||
await offlineDb.trips.put(optimistic)
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}`,
|
||||
body: data as Record<string, unknown>,
|
||||
resource: 'trips',
|
||||
})
|
||||
return { trip: optimistic }
|
||||
}
|
||||
const result = await tripsApi.update(tripId, data as Record<string, unknown>)
|
||||
upsertTrip(result.trip)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { assignmentsApi } from '../../api/client'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Assignment, AssignmentsMap } from '../../types'
|
||||
@@ -40,6 +42,23 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
|
||||
}
|
||||
}))
|
||||
|
||||
if (!navigator.onLine) {
|
||||
const day = await offlineDb.days.get(parseInt(String(dayId)))
|
||||
if (day) {
|
||||
const updated = [...(day.assignments || [])]
|
||||
updated.splice(insertIdx, 0, tempAssignment)
|
||||
await offlineDb.days.put({ ...day, assignments: updated })
|
||||
}
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'POST',
|
||||
url: `/trips/${tripId}/days/${dayId}/assignments`,
|
||||
body: { place_id: placeId },
|
||||
})
|
||||
return tempAssignment
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
|
||||
const newAssignment: Assignment = {
|
||||
@@ -99,6 +118,24 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
|
||||
}
|
||||
}))
|
||||
|
||||
if (!navigator.onLine) {
|
||||
const day = await offlineDb.days.get(parseInt(String(dayId)))
|
||||
if (day) {
|
||||
await offlineDb.days.put({
|
||||
...day,
|
||||
assignments: (day.assignments || []).filter(a => a.id !== assignmentId),
|
||||
})
|
||||
}
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/days/${dayId}/assignments/${assignmentId}`,
|
||||
body: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await assignmentsApi.delete(tripId, dayId, assignmentId)
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
|
||||
addBudgetItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await budgetApi.create(tripId, data)
|
||||
const result = await budgetRepo.create(tripId, data as Record<string, unknown>)
|
||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
@@ -41,7 +41,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
|
||||
updateBudgetItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await budgetApi.update(tripId, id, data)
|
||||
const result = await budgetRepo.update(tripId, id, data as Record<string, unknown>)
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
@@ -58,7 +58,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
const prev = get().budgetItems
|
||||
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await budgetApi.delete(tripId, id)
|
||||
await budgetRepo.delete(tripId, id)
|
||||
} catch (err: unknown) {
|
||||
set({ budgetItems: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { daysApi, dayNotesApi } from '../../api/client'
|
||||
import { dayNotesApi } from '../../api/client'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
import { dayRepo } from '../../repo/dayRepo'
|
||||
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { DayNote } from '../../types'
|
||||
@@ -19,7 +22,7 @@ export interface DayNotesSlice {
|
||||
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
|
||||
updateDayNotes: async (tripId, dayId, notes) => {
|
||||
try {
|
||||
await daysApi.update(tripId, dayId, { notes })
|
||||
await dayRepo.update(tripId, dayId, { notes })
|
||||
set(state => ({
|
||||
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d)
|
||||
}))
|
||||
@@ -30,7 +33,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
|
||||
|
||||
updateDayTitle: async (tripId, dayId, title) => {
|
||||
try {
|
||||
await daysApi.update(tripId, dayId, { title })
|
||||
await dayRepo.update(tripId, dayId, { title })
|
||||
set(state => ({
|
||||
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d)
|
||||
}))
|
||||
@@ -48,6 +51,22 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
|
||||
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
|
||||
}
|
||||
}))
|
||||
|
||||
if (!navigator.onLine) {
|
||||
const day = await offlineDb.days.get(Number(dayId))
|
||||
if (day) {
|
||||
await offlineDb.days.put({ ...day, notes_items: [...(day.notes_items || []), tempNote] })
|
||||
}
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'POST',
|
||||
url: `/trips/${tripId}/days/${dayId}/notes`,
|
||||
body: data as Record<string, unknown>,
|
||||
})
|
||||
return tempNote
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dayNotesApi.create(tripId, dayId, data)
|
||||
set(state => ({
|
||||
@@ -69,6 +88,32 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
|
||||
},
|
||||
|
||||
updateDayNote: async (tripId, dayId, id, data) => {
|
||||
if (!navigator.onLine) {
|
||||
const existing = get().dayNotes[String(dayId)]?.find(n => n.id === id)
|
||||
const optimistic: DayNote = { ...(existing ?? {} as DayNote), ...(data as Partial<DayNote>), id }
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? optimistic : n),
|
||||
}
|
||||
}))
|
||||
const day = await offlineDb.days.get(Number(dayId))
|
||||
if (day) {
|
||||
await offlineDb.days.put({
|
||||
...day,
|
||||
notes_items: (day.notes_items || []).map(n => n.id === id ? optimistic : n),
|
||||
})
|
||||
}
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/days/${dayId}/notes/${id}`,
|
||||
body: data as Record<string, unknown>,
|
||||
})
|
||||
return optimistic
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dayNotesApi.update(tripId, dayId, id, data)
|
||||
set(state => ({
|
||||
@@ -91,6 +136,25 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
|
||||
}
|
||||
}))
|
||||
|
||||
if (!navigator.onLine) {
|
||||
const day = await offlineDb.days.get(Number(dayId))
|
||||
if (day) {
|
||||
await offlineDb.days.put({
|
||||
...day,
|
||||
notes_items: (day.notes_items || []).filter(n => n.id !== id),
|
||||
})
|
||||
}
|
||||
await mutationQueue.enqueue({
|
||||
id: generateUUID(),
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/days/${dayId}/notes/${id}`,
|
||||
body: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await dayNotesApi.delete(tripId, dayId, id)
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -35,10 +35,12 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
|
||||
},
|
||||
|
||||
deleteFile: async (tripId, id) => {
|
||||
const prev = get().files
|
||||
set(state => ({ files: state.files.filter(f => f.id !== id) }))
|
||||
try {
|
||||
await filesApi.delete(tripId, id)
|
||||
set(state => ({ files: state.files.filter(f => f.id !== id) }))
|
||||
await fileRepo.delete(tripId, id)
|
||||
} catch (err: unknown) {
|
||||
set({ files: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { reservationsApi } from '../../api/client'
|
||||
import { reservationRepo } from '../../repo/reservationRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
@@ -28,7 +27,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
|
||||
|
||||
addReservation: async (tripId, data) => {
|
||||
try {
|
||||
const result = await reservationsApi.create(tripId, data)
|
||||
const result = await reservationRepo.create(tripId, data as Record<string, unknown>)
|
||||
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
|
||||
return result.reservation
|
||||
} catch (err: unknown) {
|
||||
@@ -38,7 +37,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
|
||||
|
||||
updateReservation: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await reservationsApi.update(tripId, id, data)
|
||||
const result = await reservationRepo.update(tripId, id, data as Record<string, unknown>)
|
||||
set(state => ({
|
||||
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
|
||||
}))
|
||||
@@ -57,17 +56,19 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
|
||||
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
|
||||
}))
|
||||
try {
|
||||
await reservationsApi.update(tripId, id, { status: newStatus })
|
||||
await reservationRepo.update(tripId, id, { status: newStatus })
|
||||
} catch {
|
||||
set({ reservations: prev })
|
||||
}
|
||||
},
|
||||
|
||||
deleteReservation: async (tripId, id) => {
|
||||
const prev = get().reservations
|
||||
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
|
||||
try {
|
||||
await reservationsApi.delete(tripId, id)
|
||||
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
|
||||
await reservationRepo.delete(tripId, id)
|
||||
} catch (err: unknown) {
|
||||
set({ reservations: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { todoApi } from '../../api/client'
|
||||
import { todoRepo } from '../../repo/todoRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { TodoItem } from '../../types'
|
||||
@@ -17,7 +17,7 @@ export interface TodoSlice {
|
||||
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
||||
addTodoItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await todoApi.create(tripId, data)
|
||||
const result = await todoRepo.create(tripId, data as Record<string, unknown>)
|
||||
set(state => ({ todoItems: [...state.todoItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
@@ -27,7 +27,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
||||
|
||||
updateTodoItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await todoApi.update(tripId, id, data)
|
||||
const result = await todoRepo.update(tripId, id, data as Record<string, unknown>)
|
||||
set(state => ({
|
||||
todoItems: state.todoItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
@@ -41,7 +41,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
||||
const prev = get().todoItems
|
||||
set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await todoApi.delete(tripId, id)
|
||||
await todoRepo.delete(tripId, id)
|
||||
} catch (err: unknown) {
|
||||
set({ todoItems: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting todo'))
|
||||
@@ -55,7 +55,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
|
||||
)
|
||||
}))
|
||||
try {
|
||||
await todoApi.update(tripId, id, { checked })
|
||||
await todoRepo.update(tripId, id, { checked })
|
||||
} catch {
|
||||
set(state => ({
|
||||
todoItems: state.todoItems.map(item =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
|
||||
import { tagsApi, categoriesApi } from '../api/client'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { tripRepo } from '../repo/tripRepo'
|
||||
import { dayRepo } from '../repo/dayRepo'
|
||||
@@ -146,16 +146,18 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
|
||||
try {
|
||||
const result = await tripsApi.update(tripId, data)
|
||||
const result = await tripRepo.update(tripId, data)
|
||||
set({ trip: result.trip })
|
||||
const daysData = await dayRepo.list(tripId)
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
if (navigator.onLine) {
|
||||
const daysData = await dayRepo.list(tripId)
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
}
|
||||
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
|
||||
}
|
||||
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
|
||||
return result.trip
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating trip'))
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
import {
|
||||
precacheAndRoute,
|
||||
cleanupOutdatedCaches,
|
||||
createHandlerBoundToURL,
|
||||
} from 'workbox-precaching';
|
||||
import { registerRoute, NavigationRoute } from 'workbox-routing';
|
||||
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||
import {
|
||||
DEFAULT_SW_CONFIG,
|
||||
readSwConfigFromIDB,
|
||||
validateSwConfig,
|
||||
type SwCacheConfig,
|
||||
} from './sync/swConfig';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
self.skipWaiting();
|
||||
clientsClaim();
|
||||
|
||||
// Inject precache manifest (replaced by vite-plugin-pwa at build time)
|
||||
// @ts-expect-error __WB_MANIFEST is injected at build time
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
// ── Static routes (not user-configurable) ─────────────────────────────────────
|
||||
|
||||
registerRoute(
|
||||
new NavigationRoute(createHandlerBoundToURL('index.html'), {
|
||||
denylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
|
||||
}),
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
/^https:\/\/unpkg\.com\/.*/i,
|
||||
new CacheFirst({
|
||||
cacheName: 'cdn-libs',
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 }),
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
],
|
||||
}),
|
||||
'GET',
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
/\/uploads\/(?:covers|avatars)\/.*/i,
|
||||
new CacheFirst({
|
||||
cacheName: 'user-uploads',
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }),
|
||||
new CacheableResponsePlugin({ statuses: [200] }),
|
||||
],
|
||||
}),
|
||||
'GET',
|
||||
);
|
||||
|
||||
// ── Configurable routes ────────────────────────────────────────────────────────
|
||||
// Routes are registered once. Strategy instances are replaced on config change
|
||||
// so the stable handler wrapper always delegates to the current instance.
|
||||
|
||||
const DAY = 24 * 60 * 60;
|
||||
|
||||
function buildApiStrategy(cfg: SwCacheConfig): NetworkFirst {
|
||||
return new NetworkFirst({
|
||||
cacheName: 'api-data',
|
||||
networkTimeoutSeconds: 5,
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: cfg.apiMaxEntries,
|
||||
maxAgeSeconds: cfg.apiTtlDays * DAY,
|
||||
}),
|
||||
new CacheableResponsePlugin({ statuses: [200] }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function buildTilesStrategy(cfg: SwCacheConfig): CacheFirst {
|
||||
return new CacheFirst({
|
||||
cacheName: 'map-tiles',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: cfg.tilesMaxEntries,
|
||||
maxAgeSeconds: cfg.tilesTtlDays * DAY,
|
||||
}),
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
let apiStrategy = buildApiStrategy(DEFAULT_SW_CONFIG);
|
||||
let cartoStrategy = buildTilesStrategy(DEFAULT_SW_CONFIG);
|
||||
let osmStrategy = buildTilesStrategy(DEFAULT_SW_CONFIG);
|
||||
|
||||
function applyConfig(cfg: SwCacheConfig): void {
|
||||
apiStrategy = buildApiStrategy(cfg);
|
||||
cartoStrategy = buildTilesStrategy(cfg);
|
||||
osmStrategy = buildTilesStrategy(cfg);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
registerRoute(/\/api\/(?!auth|admin|backup|settings).*/i, { handle: (o: any) => apiStrategy.handle(o) }, 'GET');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
registerRoute(/^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i, { handle: (o: any) => cartoStrategy.handle(o) }, 'GET');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
registerRoute(/^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i, { handle: (o: any) => osmStrategy.handle(o) }, 'GET');
|
||||
|
||||
// Load persisted config asynchronously; replaces defaults if user has saved settings
|
||||
readSwConfigFromIDB()
|
||||
.then(cfg => { if (cfg) applyConfig(cfg); })
|
||||
.catch(() => {});
|
||||
|
||||
// ── Message handler ────────────────────────────────────────────────────────────
|
||||
|
||||
self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
||||
const data = event.data as { type?: string; config?: unknown };
|
||||
if (data?.type !== 'UPDATE_CACHE_CONFIG' || !data.config) return;
|
||||
|
||||
const validated = validateSwConfig(data.config as Partial<SwCacheConfig>);
|
||||
applyConfig(validated);
|
||||
|
||||
// Acknowledge back to the sending client
|
||||
(event.source as WindowClient | null)?.postMessage({ type: 'CACHE_CONFIG_APPLIED' });
|
||||
});
|
||||
@@ -13,12 +13,15 @@ import type { Table } from 'dexie'
|
||||
// Map Dexie table names used in `resource` field → actual Dexie tables.
|
||||
function getTable(resource: string): Table | undefined {
|
||||
const map: Record<string, Table> = {
|
||||
places: offlineDb.places,
|
||||
packingItems: offlineDb.packingItems,
|
||||
todoItems: offlineDb.todoItems,
|
||||
budgetItems: offlineDb.budgetItems,
|
||||
reservations: offlineDb.reservations,
|
||||
tripFiles: offlineDb.tripFiles,
|
||||
trips: offlineDb.trips,
|
||||
days: offlineDb.days,
|
||||
places: offlineDb.places,
|
||||
packingItems: offlineDb.packingItems,
|
||||
todoItems: offlineDb.todoItems,
|
||||
budgetItems: offlineDb.budgetItems,
|
||||
reservations: offlineDb.reservations,
|
||||
accommodations: offlineDb.accommodations,
|
||||
tripFiles: offlineDb.tripFiles,
|
||||
}
|
||||
return map[resource]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* SW cache configuration — shared between the service worker and the main thread.
|
||||
* Uses a dedicated 'trek-sw-config' IndexedDB database (separate from trek-offline)
|
||||
* so the SW can read it without needing to know the full trek-offline schema versions.
|
||||
*/
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
|
||||
export interface SwCacheConfig {
|
||||
apiTtlDays: number;
|
||||
apiMaxEntries: number;
|
||||
tilesTtlDays: number;
|
||||
tilesMaxEntries: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SW_CONFIG: SwCacheConfig = {
|
||||
apiTtlDays: 7,
|
||||
apiMaxEntries: 500,
|
||||
tilesTtlDays: 30,
|
||||
tilesMaxEntries: 1000,
|
||||
};
|
||||
|
||||
export const SW_CONFIG_BOUNDS = {
|
||||
ttlMin: 1,
|
||||
ttlMax: 365,
|
||||
entriesMin: 10,
|
||||
entriesMax: 5000,
|
||||
};
|
||||
|
||||
export function validateSwConfig(raw: Partial<SwCacheConfig>): SwCacheConfig {
|
||||
const clamp = (v: unknown, min: number, max: number, def: number): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n > 0 ? Math.max(min, Math.min(max, Math.round(n))) : def;
|
||||
};
|
||||
return {
|
||||
apiTtlDays: clamp(raw.apiTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.apiTtlDays),
|
||||
apiMaxEntries: clamp(raw.apiMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.apiMaxEntries),
|
||||
tilesTtlDays: clamp(raw.tilesTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.tilesTtlDays),
|
||||
tilesMaxEntries:clamp(raw.tilesMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.tilesMaxEntries),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Dedicated IDB for SW config ───────────────────────────────────────────────
|
||||
|
||||
interface SwConfigRow extends SwCacheConfig {
|
||||
id: 'singleton';
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
class SwConfigDb extends Dexie {
|
||||
config!: Table<SwConfigRow, 'singleton'>;
|
||||
constructor() {
|
||||
super('trek-sw-config');
|
||||
this.version(1).stores({ config: 'id' });
|
||||
}
|
||||
}
|
||||
|
||||
let _db: SwConfigDb | null = null;
|
||||
|
||||
function getDb(): SwConfigDb {
|
||||
if (!_db) _db = new SwConfigDb();
|
||||
return _db;
|
||||
}
|
||||
|
||||
export async function readSwConfigFromIDB(): Promise<SwCacheConfig | null> {
|
||||
try {
|
||||
const row = await getDb().config.get('singleton');
|
||||
return row ? validateSwConfig(row) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSwConfig(cfg: SwCacheConfig): Promise<void> {
|
||||
const validated = validateSwConfig(cfg);
|
||||
await getDb().config.put({ id: 'singleton', ...validated, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
export async function loadSwConfig(): Promise<SwCacheConfig> {
|
||||
return (await readSwConfigFromIDB()) ?? { ...DEFAULT_SW_CONFIG };
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ export interface User {
|
||||
|
||||
export interface Trip {
|
||||
id: number
|
||||
name: string
|
||||
title: string
|
||||
description: string | null
|
||||
start_date: string
|
||||
end_date: string
|
||||
|
||||
Reference in New Issue
Block a user