File size: 6,401 Bytes
cd99321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
/**
 * Trip sync manager β€” seeds Dexie with trip data for offline use.
 *
 * Cache scope: trips where end_date >= today OR end_date is null/empty.
 * Eviction: trips where end_date < today - 7 days.
 * File blobs: all non-photo files (MIME type != image/*) for cached trips.
 *
 * Call syncAll() on:
 *   - login success
 *   - trip list refresh (DashboardPage)
 *   - WS reconnect (phase 7)
 */
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import {
  offlineDb,
  upsertTrip,
  upsertDays,
  upsertPlaces,
  upsertPackingItems,
  upsertTodoItems,
  upsertBudgetItems,
  upsertReservations,
  upsertTripFiles,
  upsertAccommodations,
  upsertTripMembers,
  upsertTags,
  upsertCategories,
  upsertSyncMeta,
  clearTripData,
  enforceBlobBudget,
} from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher'
import { isAuthed } from './authGate'
import { useSettingsStore } from '../store/settingsStore'
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'

// ── Types ─────────────────────────────────────────────────────────────────────

interface TripBundle {
  trip: Trip
  days: Day[]
  places: Place[]
  packingItems: PackingItem[]
  todoItems: TodoItem[]
  budgetItems: BudgetItem[]
  reservations: Reservation[]
  files: TripFile[]
  accommodations: Accommodation[]
  members: TripMember[]
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function todayStr(): string {
  return new Date().toISOString().slice(0, 10)
}

function shouldCache(trip: Trip): boolean {
  if (!trip.end_date) return true            // no end date β†’ cache forever
  return trip.end_date >= todayStr()          // ongoing or future
}

function isStale(trip: Trip): boolean {
  if (!trip.end_date) return false
  const cutoff = new Date()
  cutoff.setDate(cutoff.getDate() - 7)
  return trip.end_date < cutoff.toISOString().slice(0, 10)
}

function isPhoto(file: TripFile): boolean {
  return file.mime_type.startsWith('image/')
}

// ── Core logic ────────────────────────────────────────────────────────────────

/** Fetch bundle + write all entities for one trip into Dexie. */
async function syncTrip(tripId: number): Promise<void> {
  const bundle = await tripsApi.bundle(tripId) as TripBundle

  await upsertTrip(bundle.trip)
  await upsertDays(bundle.days)
  await upsertPlaces(bundle.places)
  await upsertPackingItems(bundle.packingItems)
  await upsertTodoItems(bundle.todoItems)
  await upsertBudgetItems(bundle.budgetItems)
  await upsertReservations(bundle.reservations)
  await upsertTripFiles(bundle.files)
  await upsertAccommodations(bundle.accommodations || [])
  await upsertTripMembers(tripId, bundle.members || [])
  await upsertSyncMeta({
    tripId,
    lastSyncedAt: Date.now(),
    status: 'idle',
    tilesBbox: null,
    filesCachedCount: 0,
  })
}

/** Cache non-photo file blobs for a trip. Fire-and-forget safe. */
async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
  const nonPhotos = files.filter(f => f.url && !isPhoto(f))
  let cached = 0

  for (const file of nonPhotos) {
    // Skip if already cached
    const existing = await offlineDb.blobCache.get(file.url!)
    if (existing) { cached++; continue }

    try {
      const resp = await fetch(file.url!, { credentials: 'include' })
      if (!resp.ok) continue
      const blob = await resp.blob()
      await offlineDb.blobCache.put({ url: file.url!, tripId: file.trip_id, blob, bytes: blob.size, mime: file.mime_type, cachedAt: Date.now() })
      cached++
    } catch {
      // Network failure β€” skip this file, will retry next sync
    }
  }

  // Keep the blob cache within its size/count budget after adding new files.
  if (cached > 0) await enforceBlobBudget().catch(() => {})

  // Update filesCachedCount in syncMeta
  const tripId = files[0]?.trip_id
  if (tripId) {
    const meta = await offlineDb.syncMeta.get(tripId)
    if (meta) await upsertSyncMeta({ ...meta, filesCachedCount: cached })
  }
}

// ── Public API ────────────────────────────────────────────────────────────────

let _syncing = false

export const tripSyncManager = {
  /**
   * Sync all cache-eligible trips.
   * Evicts stale trips. Caches file blobs in the background.
   * No-ops when offline.
   */
  async syncAll(): Promise<void> {
    if (_syncing || !navigator.onLine || !isAuthed()) return
    _syncing = true
    try {
      const { trips } = await tripsApi.list() as { trips: Trip[] }

      // Evict stale trips first
      const stale = trips.filter(isStale)
      await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))

      // Sync eligible trips
      const toSync = trips.filter(shouldCache)
      for (const trip of toSync) {
        try {
          await syncTrip(trip.id)
        } catch (err) {
          console.error(`[tripSync] failed for trip ${trip.id}:`, err)
        }
      }

      // Cache global user data (tags + categories) β€” fire-and-forget
      tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {})
      categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {})

      // Cache file blobs + map tiles in background (don't block syncAll)
      const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
      for (const trip of toSync) {
        const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
        cacheFilesForTrip(files).catch(console.error)

        const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
        prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
      }
    } finally {
      _syncing = false
    }
  },

  /** Reset syncing flag β€” useful in tests. */
  _resetSyncing(): void {
    _syncing = false
  },
}