// ════════════════════════════════════════════════════════════ // Caulis — App router & state // ════════════════════════════════════════════════════════════ // ── haptics ────────────────────────────────────────────────── // Android/desktop use navigator.vibrate. iOS Safari has no vibrate // API, so we trigger the Taptic Engine via a hidden // toggle (iOS 17.4+) — clicked inside a user gesture. (technique: // haptics.lochie.me) const _IS_IOS = typeof navigator !== 'undefined' && /iP(hone|ad|od)/.test(navigator.userAgent) && !window.MSStream; let _hapticSwitch = null; function _getHapticSwitch() { if (_hapticSwitch || typeof document === 'undefined') return _hapticSwitch; try { const label = document.createElement('label'); label.setAttribute('aria-hidden', 'true'); label.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;z-index:-1;'; const input = document.createElement('input'); input.type = 'checkbox'; input.setAttribute('switch', ''); label.appendChild(input); document.body.appendChild(label); _hapticSwitch = input; } catch (e) {} return _hapticSwitch; } // kind: 'light' | 'medium' | 'heavy' | 'success' | 'warning' function fireHaptic(kind = 'light') { if (_IS_IOS) { const sw = _getHapticSwitch(); if (sw) { const taps = kind === 'success' ? 2 : kind === 'warning' ? 3 : 1; try { sw.click(); for (let i = 1; i < taps; i++) setTimeout(() => sw.click(), i * 90); } catch (e) {} return; } } if (typeof navigator !== 'undefined' && navigator.vibrate) { const pat = { light: 8, medium: 16, heavy: 28, success: [12, 60, 12], warning: [10, 40, 10, 40, 10] }[kind] || 10; try { navigator.vibrate(pat); } catch (e) {} } } // ── photo storage: IndexedDB (huge quota, handles base64 blobs). // Local cache only — sync still flows through Firebase. ────────── const _IDB_NAME = 'caulis', _IDB_STORE = 'photos'; let _idbP = null; function _idb() { if (_idbP) return _idbP; _idbP = new Promise((res, rej) => { try { const r = indexedDB.open(_IDB_NAME, 1); r.onupgradeneeded = () => { const db = r.result; if (!db.objectStoreNames.contains(_IDB_STORE)) db.createObjectStore(_IDB_STORE); }; r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); } catch (e) { rej(e); } }); return _idbP; } async function idbSet(key, val) { const db = await _idb(); return new Promise((res, rej) => { const tx = db.transaction(_IDB_STORE, 'readwrite'); tx.objectStore(_IDB_STORE).put(val, key); tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); }); } async function idbDel(key) { try { const db = await _idb(); return await new Promise((res) => { const tx = db.transaction(_IDB_STORE, 'readwrite'); tx.objectStore(_IDB_STORE).delete(key); tx.oncomplete = () => res(); tx.onerror = () => res(); }); } catch (e) {} } async function idbGetAll() { const db = await _idb(); return new Promise((res) => { const tx = db.transaction(_IDB_STORE, 'readonly'); const store = tx.objectStore(_IDB_STORE); const kq = store.getAllKeys(), vq = store.getAll(); const out = {}; tx.oncomplete = () => { const ks = kq.result || [], vs = vq.result || []; ks.forEach((k, i) => { out[k] = vs[i]; }); res(out); }; tx.onerror = () => res({}); }); } function scheduleWatering(plants) { const today = new Date(); today.setHours(0,0,0,0); const tally = {}, result = {}; const sorted = [...plants].sort((a,b) => (b.days/b.every) - (a.days/a.every)); for (const p of sorted) { const due = new Date(today); due.setDate(due.getDate() + Math.max(0, p.every - p.days)); let best = null, bestScore = -Infinity; for (let i = -1; i <= 4; i++) { const d = new Date(due); d.setDate(d.getDate() + i); if (d < today) continue; const ds = fmtLocalDate(d); const dow = d.getDay(); const score = ((dow===0||dow===6)?2:0) - Math.abs(i)*0.5 - (tally[ds]||0)*1.5 - (i<0?0.5:0); if (score > bestScore) { bestScore = score; best = d; } } if (best) { const ds = fmtLocalDate(best); tally[ds]=(tally[ds]||0)+1; result[p.id]=ds; } } return result; } function App() { const vw = useWindowWidth(); const isDesktop = vw >= DESKTOP_BP; const lsGet = (k, fallback) => { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fallback; } catch(e) { return fallback; } }; const lsSet = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch(e) { if (e && (e.name === 'QuotaExceededError' || e.code === 22 || e.code === 1014)) setStorageFull(true); return false; } }; const [storageFull, setStorageFull] = useState(false); const [plants, setPlants] = useState(() => lsGet('caulis_plants', []).map(p => { const photos = lsGet('caulis_imgs_' + p.id, null); const legacy = lsGet('caulis_img_' + p.id, null); const history = Array.isArray(p.history) ? p.history : []; const wateredAt = deriveWateredAt({ ...p, history }); return { ...p, history, wateredAt, wv: WATER_SCHEMA, days: daysSinceMidnight(wateredAt), photos: photos || (legacy ? [legacy] : (p.photos || [])) }; })); const locations = [...new Set(plants.map(p => p.location).filter(Boolean))].sort(); const photosReady = useRef(false); const [tab, setTab] = useState(() => lsGet('caulis_default_tab', 'garden')); const [detail, setDetail] = useState(null); const [form, setForm] = useState(null); const [moveTarget, setMoveTarget] = useState(null); const [menuPlant, setMenuPlant] = useState(null); const [undoDelete, setUndoDelete] = useState(null); const [queue, setQueue] = useState(() => lsGet('caulis_queue', [])); const [printed, setPrinted] = useState(false); const [globalPrintSize, setGlobalPrintSizeRaw] = useState(() => lsGet('caulis_print_size', 40)); const [queueSizes, setQueueSizes] = useState(() => lsGet('caulis_queue_sizes', {})); const [cardDensity, setCardDensityRaw] = useState(() => lsGet('caulis_density', 'comfy')); const setCardDensity = (v) => { setCardDensityRaw(v); lsSet('caulis_density', v); }; const [hideHealthy, setHideHealthyRaw] = useState(() => lsGet('caulis_hide_healthy', false)); const setHideHealthy = (v) => { setHideHealthyRaw(v); lsSet('caulis_hide_healthy', v); }; const [reduceMotion, setReduceMotionRaw] = useState(() => lsGet('caulis_reduce_motion', false)); const setReduceMotion = (v) => { setReduceMotionRaw(v); lsSet('caulis_reduce_motion', v); }; const [confirmDelete, setConfirmDeleteRaw] = useState(() => lsGet('caulis_confirm_delete', false)); const setConfirmDelete = (v) => { setConfirmDeleteRaw(v); lsSet('caulis_confirm_delete', v); }; const [haptics, setHapticsRaw] = useState(() => lsGet('caulis_haptics', true)); const setHaptics = (v) => { setHapticsRaw(v); lsSet('caulis_haptics', v); }; const [defaultTab, setDefaultTabRaw] = useState(() => lsGet('caulis_default_tab', 'garden')); const setDefaultTab = (v) => { setDefaultTabRaw(v); lsSet('caulis_default_tab', v); }; const [swipeNav, setSwipeNavRaw] = useState(() => lsGet('caulis_swipe_nav', true)); const setSwipeNav = (v) => { setSwipeNavRaw(v); lsSet('caulis_swipe_nav', v); }; const [navConfig, setNavConfigRaw] = useState(() => normalizeNav(lsGet('caulis_navbar', null))); const setNavConfig = (cfg) => { const n = normalizeNav(cfg); setNavConfigRaw(n); lsSet('caulis_navbar', n); }; const [navLabels, setNavLabelsRaw] = useState(() => lsGet('caulis_nav_labels', true)); const setNavLabels = (v) => { setNavLabelsRaw(v); lsSet('caulis_nav_labels', v); }; const [gridCols, setGridColsRaw] = useState(() => lsGet('caulis_grid_cols', 0)); // 0 = follow density const setGridCols = (v) => { setGridColsRaw(v); lsSet('caulis_grid_cols', v); }; const [sidebar, setSidebarRaw] = useState(() => ({ width:220, collapsed:false, side:'left', footer:'grown with care', ...lsGet('caulis_sidebar', {}) })); const setSidebar = (patch) => setSidebarRaw(prev => { const n = { ...prev, ...patch }; lsSet('caulis_sidebar', n); return n; }); const [moreOpen, setMoreOpen] = useState(false); const onNavAction = (action) => { if (action === 'add') setForm({ mode:'add' }); else if (action === 'doctor') setDoctor({}); else if (action === 'more') setMoreOpen(true); }; const navTo = (action) => { setMoreOpen(false); const meta = NAV_ACTIONS[action]; if (!meta) return; if (meta.tab) setTab(action); else onNavAction(action); }; // launch: if the saved/default tab isn't in the customized bar, snap to its first tab useEffect(() => { const order = navTabOrder(navConfig); if (!order.includes(tab)) setTab(order[0]); }, [navConfig]); const [confirmRemove, setConfirmRemove] = useState(null); const [bulkMove, setBulkMove] = useState(null); const [bulkRemoveIds, setBulkRemoveIds] = useState(null); useEffect(() => { try { document.documentElement.setAttribute('data-rm', reduceMotion ? '1' : '0'); } catch(e) {} }, [reduceMotion]); const haptic = (kind = 'light') => { if (haptics) fireHaptic(kind); }; const [monochromePrint, setMonochromePrintRaw] = useState(() => lsGet('caulis_print_mono', false)); const [gardenPassword, setGardenPassword] = useState(() => { try { return localStorage.getItem('caulis_garden_pw') || ''; } catch(e) { return ''; } }); const [gardenNode, setGardenNode] = useState(null); const [gardenHistory, setGardenHistory] = useState(() => { const hist = lsGet('caulis_gardens', []); const currentKey = localStorage.getItem('caulis_garden_key'); const currentPw = localStorage.getItem('caulis_garden_pw') || ''; if (currentKey && !hist.find(h => h.key === currentKey)) { return [{ key: currentKey, password: currentPw }, ...hist].slice(0, 10); } return hist; }); const [perenualKey, setPerenualKeyState] = useState(() => { try { return localStorage.getItem('caulis_perenual_key') || ''; } catch(e) { return ''; } }); const [plantIdKey, setPlantIdKeyState] = useState(() => { try { return localStorage.getItem('caulis_plantid_key') || ''; } catch(e) { return ''; } }); const [housePlantsKey, setHousePlantsKeyState] = useState(() => { try { return localStorage.getItem('caulis_houseplants_key') || ''; } catch(e) { return ''; } }); const [anthropicKey, setAnthropicKeyState] = useState(() => { try { return localStorage.getItem('caulis_anthropic_key') || ''; } catch(e) { return ''; } }); const [doctor, setDoctor] = useState(null); // { plant? } | null const [doctorModel, setDoctorModelState] = useState(() => { try { return localStorage.getItem('caulis_doctor_model') || 'claude-haiku-4-5'; } catch(e) { return 'claude-haiku-4-5'; } }); const setDoctorModel = (m) => { try { localStorage.setItem('caulis_doctor_model', m); } catch(e) {} setDoctorModelState(m); }; const [identifyLang, setIdentifyLangState] = useState(() => { try { return localStorage.getItem('caulis_identify_lang') || 'en'; } catch(e) { return 'en'; } }); const [googleClientId, setGoogleClientIdState] = useState(() => { try { return localStorage.getItem('caulis_gcal_cid') || ''; } catch(e) { return ''; } }); const [googleToken, setGoogleToken] = useState(null); const [googleEventIds, setGoogleEventIds] = useState(() => lsGet('caulis_gcal_eids', {})); const [googleSyncMode, setGoogleSyncModeState] = useState(() => { try { return localStorage.getItem('caulis_gsync_mode') || 'tasks'; } catch(e) { return 'tasks'; } }); const saveGoogleClientId = (id) => { try { localStorage.setItem('caulis_gcal_cid', id); } catch(e) {} setGoogleClientIdState(id); }; const [defaultEvery, setDefaultEvery] = useState(() => lsGet('caulis_default_every', 7)); const [reminderTime, setReminderTimeState] = useState(() => { try { return localStorage.getItem('caulis_reminder_time') || '09:00'; } catch(e) { return '09:00'; } }); const setReminderTime = (t) => { try { localStorage.setItem('caulis_reminder_time', t); } catch(e) {} setReminderTimeState(t); }; const setGlobalPrintSize = v => { lsSet('caulis_print_size', v); setGlobalPrintSizeRaw(v); }; const toggleMono = () => { const v = !monochromePrint; lsSet('caulis_print_mono', v); setMonochromePrintRaw(v); }; const saveGardenPassword = async (pw) => { const next = pw || ''; if (next === gardenPassword) return; await changeGardenPassword(gardenKey, next); try { localStorage.setItem('caulis_garden_pw', next); } catch(e) {} setGardenPassword(next); }; const tokenClientRef = useRef(null); const getTokenClient = () => { if (tokenClientRef.current) return tokenClientRef.current; if (!googleClientId || typeof google === 'undefined') return null; tokenClientRef.current = google.accounts.oauth2.initTokenClient({ client_id: googleClientId, scope: 'https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/calendar', callback: (res) => { if (res.access_token) { try { localStorage.setItem('caulis_gcal_seen', '1'); } catch(e) {} setGoogleToken(res.access_token); } }, error_callback: () => {}, }); return tokenClientRef.current; }; const connectGoogle = () => { const c = getTokenClient(); if (c) c.requestAccessToken(); }; const TASKS_API = 'https://tasks.googleapis.com/tasks/v1'; const CAL_API = 'https://www.googleapis.com/calendar/v3'; // ── Tasks backend ── const getTaskListId = async (token) => { try { const cached = localStorage.getItem('caulis_gtasks_listid'); if (cached) return cached; } catch(e) {} const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; try { const lr = await fetch(`${TASKS_API}/users/@me/lists?maxResults=100`, { headers }); if (lr.status === 401) { setGoogleToken(null); return null; } const items = (await lr.json()).items || []; const found = items.find(l => l.title === 'Caulis Plants'); if (found) { try { localStorage.setItem('caulis_gtasks_listid', found.id); } catch(e) {} return found.id; } } catch(e) {} try { const cr = await fetch(`${TASKS_API}/users/@me/lists`, { method: 'POST', headers, body: JSON.stringify({ title: 'Caulis Plants' }) }); if (!cr.ok) return null; const list = await cr.json(); try { localStorage.setItem('caulis_gtasks_listid', list.id); } catch(e) {} return list.id; } catch(e) { return null; } }; const upsertTask = async (plant, dateStr, token, existingId, listId) => { if (!listId) return null; const every = Math.max(1, Math.round(plant.every || 7)); const task = { title: `💧 ${plant.name}`, notes: `Water every ${every} days\n${plant.latin||''}\n${plant.location}`, due: `${dateStr}T00:00:00.000Z`, status: 'needsAction', completed: null, }; const base = `${TASKS_API}/lists/${listId}/tasks`; const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; let res; if (existingId) { res = await fetch(`${base}/${existingId}`, { method: 'PATCH', headers, body: JSON.stringify(task) }); if (res.status === 401) { setGoogleToken(null); return null; } if (!res.ok) res = await fetch(base, { method: 'POST', headers, body: JSON.stringify(task) }); } else { res = await fetch(base, { method: 'POST', headers, body: JSON.stringify(task) }); } if (res.status === 401) { setGoogleToken(null); return null; } if (!res.ok) return null; return (await res.json()).id; }; // ── Calendar backend (dedicated, togglable "Caulis Plants" calendar) ── const getCalendarId = async (token) => { try { const cached = localStorage.getItem('caulis_gcal_calid'); if (cached) return cached; } catch(e) {} const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; try { const lr = await fetch(`${CAL_API}/users/me/calendarList`, { headers }); if (lr.status === 401) { setGoogleToken(null); return null; } const items = (await lr.json()).items || []; const found = items.find(c => c.summary === 'Caulis Plants'); if (found) { try { localStorage.setItem('caulis_gcal_calid', found.id); } catch(e) {} return found.id; } } catch(e) {} try { const cr = await fetch(`${CAL_API}/calendars`, { method: 'POST', headers, body: JSON.stringify({ summary: 'Caulis Plants', description: 'Watering reminders from Caulis', timeZone: tz }) }); if (!cr.ok) return null; const cal = await cr.json(); try { await fetch(`${CAL_API}/users/me/calendarList/${encodeURIComponent(cal.id)}`, { method: 'PATCH', headers, body: JSON.stringify({ backgroundColor: '#2D5016', foregroundColor: '#FFFFFF' }) }); } catch(e) {} try { localStorage.setItem('caulis_gcal_calid', cal.id); } catch(e) {} return cal.id; } catch(e) { return null; } }; const upsertCalendarEvent = async (plant, dateStr, token, existingId, calId) => { if (!calId) return null; const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; const every = Math.max(1, Math.round(plant.every || 7)); const [h, m] = (reminderTime || '09:00').split(':').map(Number); const pad = n => String(n).padStart(2, '0'); const endH = (h + (m + 30 >= 60 ? 1 : 0)) % 24; const endM = (m + 30) % 60; const event = { summary: `💧 ${plant.name}`, description: `Water every ${every} days\n${plant.latin||''}\n${plant.location}`, start: { dateTime: `${dateStr}T${pad(h)}:${pad(m)}:00`, timeZone: tz }, end: { dateTime: `${dateStr}T${pad(endH)}:${pad(endM)}:00`, timeZone: tz }, recurrence: [`RRULE:FREQ=DAILY;INTERVAL=${every}`], reminders: { useDefault: false, overrides: [{ method: 'popup', minutes: 0 }] }, }; const base = `${CAL_API}/calendars/${encodeURIComponent(calId)}/events`; const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; let res; if (existingId) { res = await fetch(`${base}/${existingId}`, { method: 'PUT', headers, body: JSON.stringify(event) }); if (res.status === 401) { setGoogleToken(null); return null; } if (!res.ok) { try { await fetch(`${base}/${existingId}`, { method: 'DELETE', headers }); } catch(e) {} res = await fetch(base, { method: 'POST', headers, body: JSON.stringify(event) }); } } else { res = await fetch(base, { method: 'POST', headers, body: JSON.stringify(event) }); } if (res.status === 401) { setGoogleToken(null); return null; } if (!res.ok) return null; return (await res.json()).id; }; // ── mode dispatch ── const getSyncTargetId = (token, mode = googleSyncMode) => mode === 'calendar' ? getCalendarId(token) : getTaskListId(token); const upsertItem = (plant, dateStr, token, existingId, targetId, mode = googleSyncMode) => mode === 'calendar' ? upsertCalendarEvent(plant, dateStr, token, existingId, targetId) : upsertTask(plant, dateStr, token, existingId, targetId); // delete the remote container (calendar or task list) for a given mode, keep token const purgeRemote = async (token, mode) => { const headers = { Authorization: `Bearer ${token}` }; if (mode === 'calendar') { let calId = null; try { calId = localStorage.getItem('caulis_gcal_calid'); } catch(e) {} if (calId) { try { await fetch(`${CAL_API}/calendars/${encodeURIComponent(calId)}`, { method: 'DELETE', headers }); } catch(e) {} } try { localStorage.removeItem('caulis_gcal_calid'); } catch(e) {} } else { let listId = null; try { listId = localStorage.getItem('caulis_gtasks_listid'); } catch(e) {} if (listId) { try { await fetch(`${TASKS_API}/users/@me/lists/${listId}`, { method: 'DELETE', headers }); } catch(e) {} } try { localStorage.removeItem('caulis_gtasks_listid'); } catch(e) {} } }; const syncAllToCalendar = async () => { if (!googleToken || !plants.length) return; const targetId = await getSyncTargetId(googleToken); if (!googleToken) return; const schedule = scheduleWatering(plants); const ids = { ...googleEventIds }; for (const plant of plants) { const dateStr = schedule[plant.id]; if (!dateStr) continue; const itemId = await upsertItem(plant, dateStr, googleToken, ids[plant.id], targetId); if (itemId) ids[plant.id] = itemId; if (!googleToken) break; } setGoogleEventIds(ids); }; const setGoogleSyncMode = async (mode) => { if (mode === googleSyncMode) return; if (googleToken) await purgeRemote(googleToken, googleSyncMode); // tear down the old backend setGoogleEventIds({}); try { localStorage.setItem('caulis_gsync_mode', mode); } catch(e) {} setGoogleSyncModeState(mode); // resync fires via the effect below }; const disconnectGoogle = async () => { if (googleToken) await purgeRemote(googleToken, googleSyncMode); setGoogleEventIds({}); setGoogleToken(null); }; const savePerenualKey = (k) => { setApiKey(k); setPerenualKeyState(k || ''); }; const savePlantIdKey = (k) => { setPlantIdKey(k); setPlantIdKeyState(k || ''); }; const saveHousePlantsKey = (k) => { setHousePlantsKey(k); setHousePlantsKeyState(k || ''); }; const saveAnthropicKey = (k) => { setAnthropicKey(k); setAnthropicKeyState(k || ''); }; const saveIdentifyLang = (lang) => { setIdentifyLang(lang); setIdentifyLangState(lang); }; const genKey = () => { const adj = ['green','mossy','sunny','leafy','dewy','wild','quiet','calm','bright','soft','deep','cool']; const noun = ['fern','oak','sage','moss','leaf','vine','seed','root','grove','bloom','stem','bud']; return adj[Math.random()*adj.length|0]+'-'+noun[Math.random()*noun.length|0]+'-'+((Math.random()*90+10)|0); }; const [gardenKey, setGardenKeyState] = useState(() => { try { let k = localStorage.getItem('caulis_garden_key'); if (!k) { k = genKey(); localStorage.setItem('caulis_garden_key', k); } return k; } catch(e) { return 'local-garden'; } }); const switchingGardenRef = useRef( (() => { try { return false; } catch(e) { return false; } })() ); const [darkMode, setDarkModeState] = useState(() => { try { return localStorage.getItem('caulis_dark') === '1'; } catch(e) { return false; } }); const setDarkMode = (v) => { try { localStorage.setItem('caulis_dark', v ? '1' : '0'); } catch(e) {} setDarkModeState(v); }; const [palette, setPaletteRaw] = useState(() => lsGet('caulis_palette', 'forest')); const setPalette = (v) => { setPaletteRaw(v); lsSet('caulis_palette', v); }; applyTheme(darkMode, palette); useEffect(() => { const meta = document.querySelector('meta[name="theme-color"]'); if (meta) meta.content = C.bg === '#111610' ? '#111610' : C.forest; }, [darkMode, palette]); const fromRemoteRef = useRef(false); const [plantNotFound, setPlantNotFound] = useState(false); const [guestView, setGuestView] = useState(null); const openGuestPlant = async (gKey, plantId) => { setGuestView('loading'); const data = await fetchGardenOnce(gKey); if (!data) { setGuestView(null); setPlantNotFound(true); return; } const arr = data.plants ? (Array.isArray(data.plants) ? data.plants : Object.values(data.plants)).filter(Boolean) : []; const plant = arr.find(p => p.id === plantId); if (!plant) { setGuestView(null); setPlantNotFound(true); return; } setGuestView({ plant, fromGardenKey: gKey }); }; const [installPrompt, setInstallPrompt] = useState(null); const [pendingLink] = useState(() => { try { const sp = new URLSearchParams(window.location.search); const id = parseInt(sp.get('plant'), 10); const g = sp.get('g'); if (!isNaN(id)) { window.history.replaceState({}, '', window.location.pathname); return { id, node: g || null }; } } catch(e) {} return null; }); const pendingPlantId = pendingLink && !pendingLink.node ? pendingLink.id : null; // open a shared garden's plant as a read-only guest (capability = node in ?g=) useEffect(() => { if (pendingLink && pendingLink.node) openGuestPlant(pendingLink.node, pendingLink.id); }, []); useEffect(() => { const handler = (e) => { e.preventDefault(); setInstallPrompt(e); }; window.addEventListener('beforeinstallprompt', handler); return () => window.removeEventListener('beforeinstallprompt', handler); }, []); const setGardenKey = (k, pw = '') => { if (k === gardenKey && pw === gardenPassword) return; try { localStorage.setItem('caulis_garden_key', k); localStorage.setItem('caulis_garden_pw', pw || ''); localStorage.removeItem('caulis_garden_node'); const hist = [{ key: k, password: pw }, ...gardenHistory.filter(h => h.key !== k)].slice(0, 10); setGardenHistory(hist); lsSet('caulis_gardens', hist); } catch(e) {} switchingGardenRef.current = true; setGardenNode(null); setGardenKeyState(k); setGardenPassword(pw || ''); setPlants([]); setQueue([]); }; const removeGardenFromHistory = (key) => { const hist = gardenHistory.filter(h => h.key !== key); setGardenHistory(hist); lsSet('caulis_gardens', hist); }; const renameGardenKey = async (newKey) => { const newNode = await gardenNodeId(newKey, gardenPassword); const cleanPlants = plants.map(({ photos, ...rest }) => rest); const data = { plants: cleanPlants, locations, queue, perenualKey: perenualKey || null, plantIdKey: plantIdKey || null, housePlantsKey: housePlantsKey || null, anthropicKey: anthropicKey || null}; const ok = gardenNode ? await renameGarden(gardenNode, newNode, data) : true; if (ok) { try { localStorage.setItem('caulis_garden_key', newKey); localStorage.setItem('caulis_garden_node', newNode); } catch(e) {} switchingGardenRef.current = true; setGardenNode(newNode); setGardenKeyState(newKey); } return ok; }; // ── derive the secret storage node from key + password ── useEffect(() => { let cancelled = false; gardenNodeId(gardenKey, gardenPassword).then(node => { if (cancelled) return; setGardenNode(node); setActiveGarden(node); try { localStorage.setItem('caulis_garden_node', node); } catch(e) {} }); return () => { cancelled = true; }; }, [gardenKey, gardenPassword]); // recompute days from the absolute watered timestamp when the day rolls // over or the tab regains focus (a left-open tab would otherwise freeze) useEffect(() => { const sync = () => setPlants(ps => ps.map(p => { const d = daysSinceMidnight(p.wateredAt); return d === p.days ? p : { ...p, days: d }; })); document.addEventListener('visibilitychange', sync); window.addEventListener('focus', sync); const t = setInterval(sync, 3600000); return () => { document.removeEventListener('visibilitychange', sync); window.removeEventListener('focus', sync); clearInterval(t); }; }, []); // ── Persist: plant blob (photos stripped) to localStorage, photos to // IndexedDB (big quota). Sync still goes through Firebase separately. ── useEffect(() => { lsSet('caulis_plants', plants.map(({ photos, userImage, ...rest }) => rest)); if (!photosReady.current) return; // wait until initial photo load/migration done plants.forEach(p => { if (p.photos && p.photos.length) idbSet(p.id, p.photos).catch(()=>{}); else idbDel(p.id); }); }, [plants]); // load photos from IndexedDB once, migrating any left in localStorage useEffect(() => { let cancelled = false; (async () => { let all = {}; try { all = await idbGetAll(); } catch (e) {} try { const legacyKeys = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.indexOf('caulis_imgs_') === 0) legacyKeys.push(k); } for (const k of legacyKeys) { const id = +k.slice('caulis_imgs_'.length); try { const v = JSON.parse(localStorage.getItem(k)); if (v && v.length && !(all[id] && all[id].length)) { await idbSet(id, v); all[id] = v; } } catch (e) {} try { localStorage.removeItem(k); } catch (e) {} } } catch (e) {} if (!cancelled && Object.keys(all).length) { setPlants(ps => ps.map(p => (all[p.id] && all[p.id].length && !(p.photos && p.photos.length)) ? { ...p, photos: all[p.id] } : p)); } photosReady.current = true; })(); return () => { cancelled = true; }; }, []); useEffect(() => { lsSet('caulis_queue', queue); }, [queue]); useEffect(() => { lsSet('caulis_queue_sizes', queueSizes); }, [queueSizes]); useEffect(() => { lsSet('caulis_default_every', defaultEvery); }, [defaultEvery]); useEffect(() => { lsSet('caulis_gcal_eids', googleEventIds); }, [googleEventIds]); // re-sync whenever the calendar connects — recreates anything deleted/crossed // off in Google and re-anchors every reminder to its next due date useEffect(() => { if (googleToken) syncAllToCalendar(); }, [googleToken, googleSyncMode, reminderTime]); // silently re-acquire a token on start if access was granted before — no clicks useEffect(() => { if (!googleClientId) return; try { if (localStorage.getItem('caulis_gcal_seen') !== '1') return; } catch(e) { return; } let tries = 0; const t = setInterval(() => { if (typeof google === 'undefined') { if (++tries > 40) clearInterval(t); return; } clearInterval(t); const c = getTokenClient(); if (c) c.requestAccessToken({ prompt: 'none' }); }, 150); return () => clearInterval(t); }, []); // ── Open plant from URL param once data loads ── useEffect(() => { if (!pendingPlantId || !plants.length) return; if (plants.find(p => p.id === pendingPlantId)) openDetail(pendingPlantId, true); }, [plants]); // ── Firebase sync: listen for remote changes ── useEffect(() => { if (!gardenNode) return; const toArr = v => v ? (Array.isArray(v) ? v : Object.values(v)) : []; const unsubscribe = listenGarden(gardenNode, (data) => { fromRemoteRef.current = true; if (data.plants) { const incoming = toArr(data.plants).filter(Boolean); if (incoming.length) setPlants(prev => incoming.map(p => { const local = prev.find(lp => lp.id === p.id); // prefer synced photos; fall back to whatever this device already had const photos = (p.photos && p.photos.length) ? p.photos : (local ? (local.photos || []) : []); if (!photos.length) { // Lazy load from discrete photo node fetchPhotos(gardenNode, p.id).then(ph => { if (ph && ph.length) { setPlants(curr => curr.map(cp => cp.id === p.id && (!cp.photos || !cp.photos.length) ? { ...cp, photos: ph } : cp)); } }); } const history = Array.isArray(p.history) ? p.history : []; const wateredAt = deriveWateredAt({ ...p, history }); return { ...p, history, wateredAt, wv: WATER_SCHEMA, days: daysSinceMidnight(wateredAt), photos }; })); } if (data.queue) setQueue(toArr(data.queue).filter(Boolean)); if (data.perenualKey) { setApiKey(data.perenualKey); setPerenualKeyState(data.perenualKey); } if (data.plantIdKey) { setPlantIdKey(data.plantIdKey); setPlantIdKeyState(data.plantIdKey); } if (data.housePlantsKey) { setHousePlantsKey(data.housePlantsKey); setHousePlantsKeyState(data.housePlantsKey); } if (data.anthropicKey) { setAnthropicKey(data.anthropicKey); setAnthropicKeyState(data.anthropicKey); } }); return unsubscribe; }, [gardenNode]); // ── Firebase sync: push local changes ── useEffect(() => { if (!gardenNode) return; if (fromRemoteRef.current) { fromRemoteRef.current = false; return; } if (switchingGardenRef.current) { switchingGardenRef.current = false; return; } const timer = setTimeout(() => { const cleanPlants = plants.map(({ photos, ...rest }) => rest); pushGarden(gardenNode, { plants: cleanPlants, locations, queue, perenualKey: perenualKey || null, plantIdKey: plantIdKey || null, housePlantsKey: housePlantsKey || null, anthropicKey: anthropicKey || null}); }, 800); return () => clearTimeout(timer); }, [plants, locations, queue, gardenNode, perenualKey, plantIdKey, housePlantsKey, anthropicKey]); const updateApp = async () => { // Force a sync of any pending changes before wiping caches and reloading if (gardenNode) { const cleanPlants = plants.map(({ photos, ...rest }) => rest); pushGarden(gardenNode, { plants: cleanPlants, locations, queue, perenualKey: perenualKey || null, plantIdKey: plantIdKey || null, housePlantsKey: housePlantsKey || null, anthropicKey: anthropicKey || null }); } try { if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); await Promise.all(regs.map(r => r.update())); } if (window.caches) { const keys = await caches.keys(); await Promise.all(keys.map(k => caches.delete(k))); } } catch(e) {} window.location.reload(); }; const tintFor = (id) => TINTS[(id - 1) % TINTS.length]; // unified mobile gesture: horizontal swipe = change tab (if enabled), // vertical pull at scroll-top = pull-to-refresh. Ignored over overlays // and horizontally-scrollable zones (marked data-noswipe). const TAB_ORDER = navTabOrder(navConfig); // swipes follow the customized bar order const PULL_MAX = 88, PULL_TRIG = 62; const swipeRef = useRef(null); const [pull, setPull] = useState(0); const [pulling, setPulling] = useState(false); const [refreshing, setRefreshing] = useState(false); const doRefresh = async () => { setRefreshing(true); setPull(PULL_TRIG); haptic('medium'); const start = Date.now(); try { if (SYNC_READY && gardenNode) { const data = await fetchGardenOnce(gardenNode); if (data) { const toArr = v => v ? (Array.isArray(v) ? v : Object.values(v)) : []; const incoming = toArr(data.plants).filter(Boolean); if (incoming.length) setPlants(prev => incoming.map(p => { const local = prev.find(lp => lp.id === p.id); const photos = (p.photos && p.photos.length) ? p.photos : (local ? (local.photos || []) : []); if (!photos.length) { fetchPhotos(gardenNode, p.id).then(ph => { if (ph && ph.length) setPlants(curr => curr.map(cp => cp.id === p.id && (!cp.photos || !cp.photos.length) ? { ...cp, photos: ph } : cp)); }); } const history = Array.isArray(p.history) ? p.history : []; const wateredAt = deriveWateredAt({ ...p, history }); return { ...p, history, wateredAt, wv: WATER_SCHEMA, days: daysSinceMidnight(wateredAt), photos }; })); if (data.queue) setQueue(toArr(data.queue).filter(Boolean)); } } } catch (e) {} setTimeout(() => { setRefreshing(false); setPull(0); }, Math.max(0, 650 - (Date.now() - start))); }; const onSwipeStart = (e) => { if (e.pointerType === 'mouse' || refreshing) { swipeRef.current = null; return; } if (detail || form || moveTarget || menuPlant || guestView || confirmRemove != null || bulkMove || bulkRemoveIds) { swipeRef.current = null; return; } const noswipe = !!(e.target.closest && e.target.closest('[data-noswipe]')); swipeRef.current = { x: e.clientX, y: e.clientY, lx: e.clientX, ly: e.clientY, top: e.currentTarget.scrollTop <= 0, noswipe, mode: null }; }; const onSwipeMove = (e) => { const s = swipeRef.current; if (!s) return; s.lx = e.clientX; s.ly = e.clientY; const dx = s.lx - s.x, dy = s.ly - s.y; if (!s.mode) { if (s.top && dy > 8 && dy > Math.abs(dx) * 1.3) s.mode = 'pull'; else if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) s.mode = 'swipe'; } if (s.mode === 'pull') { if (!pulling) setPulling(true); setPull(Math.min(PULL_MAX, dy * 0.5)); } }; const onSwipeEnd = () => { const s = swipeRef.current; swipeRef.current = null; setPulling(false); if (!s) return; if (s.mode === 'pull') { if ((s.ly - s.y) * 0.5 >= PULL_TRIG) doRefresh(); else setPull(0); return; } if (!swipeNav || s.noswipe) return; const dx = s.lx - s.x, dy = s.ly - s.y; if (Math.abs(dx) < 55 || Math.abs(dx) < Math.abs(dy) * 1.4) return; const i = TAB_ORDER.indexOf(tab); const ni = dx < 0 ? Math.min(TAB_ORDER.length - 1, i + 1) : Math.max(0, i - 1); if (ni !== i) setTab(TAB_ORDER[ni]); }; const prevTabRef = useRef(tab); const tabDir = TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTabRef.current) ? 1 : -1; useEffect(() => { prevTabRef.current = tab; }, [tab]); const tabAnim = reduceMotion ? undefined : `${tabDir > 0 ? 'slideFromR' : 'slideFromL'} 280ms cubic-bezier(.2,.8,.2,1)`; // ── actions ── const openDetail = (id, fromScan = false) => setDetail({ id, fromScan }); const closeDetail = () => setDetail(null); const water = (id, daysAgo = 0) => { haptic('light'); const d = Math.max(0, daysAgo || 0); const when = new Date(); when.setHours(0,0,0,0); when.setDate(when.getDate() - d); const stamp = fmtLocalDate(when); setPlants(ps => ps.map(p => p.id === id ? { ...p, wateredAt: when.getTime(), wv: WATER_SCHEMA, days: d, history: [...(p.history||[]), stamp].slice(-60) } : p)); if (googleToken) { const plant = plants.find(p => p.id === id); if (plant) { const updated = { ...plant, days: d }; const ds = scheduleWatering([updated])[id]; if (ds) getSyncTargetId(googleToken) .then(targetId => upsertItem(updated, ds, googleToken, googleEventIds[id], targetId)) .then(itemId => { if (itemId) setGoogleEventIds(prev => ({ ...prev, [id]: itemId })); }); } } }; const undoWater = (id, prevDays) => setPlants(ps => ps.map(p => p.id === id ? { ...p, wateredAt: todayMidnight() - prevDays * 86400000, wv: WATER_SCHEMA, days: prevDays, history: (p.history||[]).slice(0, -1) } : p)); const snooze = (id, n = 2) => { haptic('light'); setPlants(ps => ps.map(p => { if (p.id !== id) return p; const wa = Math.min(todayMidnight(), (p.wateredAt ?? todayMidnight()) + n * 86400000); return { ...p, wateredAt: wa, wv: WATER_SCHEMA, days: daysSinceMidnight(wa) }; })); }; const toggleQueue = (id) => { setQueue(q => q.includes(id) ? q.filter(x => x !== id) : [...q, id]); setPrinted(false); }; const removeQueue = (id) => { setQueue(q => q.filter(x => x !== id)); setQueueSizes(s => { const n = {...s}; delete n[id]; return n; }); }; const setPlantSize = (id, mm) => setQueueSizes(s => { if (mm === null) { const n = {...s}; delete n[id]; return n; } return {...s, [id]: mm}; }); const printAll = () => { const items = queue.map(id => plants.find(p => p.id === id)).filter(Boolean); if (!items.length) return; const mono = monochromePrint; const czechMode = identifyLang === 'cs'; const qrInk = mono ? '000000' : '2D5016'; const qrGround = mono ? 'FFFFFF' : 'FAFAF7'; const labelBg = mono ? '#fff' : '#FAFAF7'; const nameCol = mono ? '#111' : '#2D5016'; const latinCol = mono ? '#666' : '#6B4C2A'; const qrSrc = (data, px) => `https://api.qrserver.com/v1/create-qr-code/?size=${px}x${px}&margin=0&qzone=1&color=${qrInk}&bgcolor=${qrGround}&data=${encodeURIComponent(data)}`; const labels = items.map(p => { const mm = queueSizes[p.id] || globalPrintSize; const qrMm = (mm * 0.64).toFixed(1); const qrPx = Math.max(200, Math.round(mm * 0.64 * 9)); const wrapMm = mm + 9; const namePt = (mm * 0.20).toFixed(1); const latinPt = (mm * 0.115).toFixed(1); const textW = (mm * 0.88).toFixed(1); return `