// ════════════════════════════════════════════════════════════ // 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 `
${czechMode && p.czech ? p.czech : p.name}
${p.latin}
`; }).join(''); const html = `Caulis labels
${labels}
`; const win = window.open('', '_blank'); if (win) { win.document.write(html); win.document.close(); } setPrinted(true); setTimeout(() => setPrinted(false), 2600); }; const removePlant = (id) => { haptic('heavy'); const p = plants.find(x => x.id === id); setPlants(ps => ps.filter(x => x.id !== id)); setQueue(q => q.filter(x => x !== id)); if (p) setUndoDelete({ plant: p, queued: queue.includes(id) }); idbDel(id); if (gardenNode) deletePhotos(gardenNode, id); }; const requestRemove = (id) => { if (confirmDelete) setConfirmRemove(id); else removePlant(id); }; const undoRemove = () => { if (!undoDelete) return; const { plant, queued } = undoDelete; setPlants(ps => ps.some(x => x.id === plant.id) ? ps : [...ps, plant]); if (queued) setQueue(q => q.includes(plant.id) ? q : [...q, plant.id]); setUndoDelete(null); }; useEffect(() => { if (!undoDelete) return; const t = setTimeout(() => setUndoDelete(null), 5000); return () => clearTimeout(t); }, [undoDelete]); const movePlant = (id, room) => setPlants(ps => ps.map(p => p.id === id ? { ...p, location: room } : p)); const bulkWater = (ids) => ids.forEach(id => water(id, 0)); const waterAll = () => { haptic('medium'); const stamp = fmtLocalDate(new Date()); const wa = todayMidnight(); setPlants(ps => ps.map(p => ({ ...p, wateredAt: wa, wv: WATER_SCHEMA, days: 0, history: [...(p.history||[]), stamp].slice(-60) }))); }; const devOffsetDays = (n) => setPlants(ps => ps.map(p => { const wa = (typeof p.wateredAt === 'number' ? p.wateredAt : todayMidnight()) - n * 86400000; return { ...p, wateredAt: wa, wv: WATER_SCHEMA, days: daysSinceMidnight(wa) }; })); const devSetDays = (id, d) => setPlants(ps => ps.map(p => { if (p.id !== id) return p; const dd = Math.max(0, d | 0); const wa = todayMidnight() - dd * 86400000; return { ...p, wateredAt: wa, wv: WATER_SCHEMA, days: dd }; })); // repair tool: force-recompute wateredAt/days from the watering log // (history[]), ignoring whatever wateredAt currently says — history is the // append-only record of actual waterings, so it survives a corrupted stamp // (e.g. a bad sync bumping wateredAt/days without a matching history entry). const devResyncFromHistory = () => { let fixed = 0; setPlants(ps => ps.map(p => { const h = Array.isArray(p.history) ? p.history : []; if (!h.length) return p; const wa = midnightFromStamp(h[h.length - 1]); const days = daysSinceMidnight(wa); if (wa === p.wateredAt && days === p.days) return p; fixed++; return { ...p, wateredAt: wa, wv: WATER_SCHEMA, days }; })); return fixed; }; const adminListAllGardens = (secret) => adminListGardens(secret); const adminLoadGarden = async (secret, key) => { const data = await adminGetGarden(secret, key); return { key, data }; }; const adminSaveGarden = (secret, key, data) => adminPushGarden(secret, key, data); const adminRemoveGarden = (secret, key) => adminDeleteGarden(secret, key); const bulkQueue = (ids) => { haptic('medium'); setQueue(q => { const s = new Set(q); ids.forEach(id => s.add(id)); return [...s]; }); setPrinted(false); }; const doBulkRemove = () => { const ids = bulkRemoveIds || []; haptic('warning'); setPlants(ps => ps.filter(p => !ids.includes(p.id))); setQueue(q => q.filter(id => !ids.includes(id))); ids.forEach(id => idbDel(id)); setBulkRemoveIds(null); }; const reorderQueue = (from, to) => setQueue(q => { if (from===to||from<0||to<0||from>=q.length||to>=q.length) return q; const n=[...q]; const [x]=n.splice(from,1); n.splice(to,0,x); return n; }); const reorderPlants = (from, to) => setPlants(ps => { if (from===to||from<0||to<0||from>=ps.length||to>=ps.length) return ps; const n=[...ps]; const [x]=n.splice(from,1); n.splice(to,0,x); return n; }); const savePlant = (data) => { if (data.id) { setPlants(ps => ps.map(p => { if (p.id !== data.id) return p; const next = { ...p, name: data.name, czech: data.czech || '', latin: data.latin, location: data.location }; if (data.species) { const care = speciesCare(data.species); Object.assign(next, { every:care.every, light:care.light, care:care.care, fact:care.fact, watering:care.watering, benchmark:care.benchmark, sunlight:care.sunlight, species_id:care.species_id }); } if (data.every) { next.every = data.every; next.benchmark = `${data.every} days`; } if (data.light) next.light = data.light; if (data.care) next.care = data.care; if (data.fact) next.fact = data.fact; if (data.days != null) { next.days = data.days; next.wateredAt = todayMidnight() - data.days * 86400000; next.wv = WATER_SCHEMA; } next.photos = data.photos || []; next.image = data.presetImage != null ? data.presetImage : p.image; delete next.userImage; if (gardenNode) pushPhoto(gardenNode, next.id, next.photos); return next; })); } else { const id = Math.max(0, ...plants.map(p => p.id)) + 1; const sp = data.species; const care = sp ? speciesCare(sp) : { every:defaultEvery, light:'Bright, indirect', care:'Water when the top of the soil feels dry.', fact:'Freshly added — identify it to enrich its care notes.', watering:'Average', benchmark:`${defaultEvery} days`, sunlight:[], image:null, species_id:null }; if (gardenNode) pushPhoto(gardenNode, id, data.photos || []); setPlants(ps => [...ps, { id, name: data.name, czech: data.czech || '', latin: data.latin, location: data.location, days: data.days || 0, wateredAt: todayMidnight() - (data.days || 0) * 86400000, wv: WATER_SCHEMA, every: data.every || care.every, light: data.light || care.light, care: data.care || care.care, fact: data.fact || care.fact, watering:care.watering, benchmark: data.every ? `${data.every} days` : care.benchmark, sunlight:care.sunlight, species_id:care.species_id, image: data.presetImage != null ? data.presetImage : care.image, photos: data.photos || [], aiV: APP_VERSION, }]); setTab('garden'); } setForm(null); }; const [aiRecheck, setAiRecheck] = useState({ busy: false, done: 0, total: 0 }); const recheckAllAI = async () => { if (aiRecheck.busy || !hasAnthropicKey()) return; const targets = plants.filter(p => !p.aiV); const withLatin = targets.filter(p => p.latin && p.latin !== '—'); setAiRecheck({ busy: true, done: 0, total: withLatin.length }); for (const p of withLatin) { try { const rec = await aiReviewCare({ common_name: p.name, scientific_name: [p.latin], czech: p.czech || '', watering: p.watering || null, _aiEvery: p.every || undefined, watering_general_benchmark: p.every ? { value: String(p.every), unit: 'days' } : undefined, sunlight: p.sunlight || [], _care: p.care || '', _fact: p.fact || '', _source: 'recheck', }); const care = speciesCare(rec); const blankName = (n) => !n || ['—','-','unknown','new plant','unnamed','plant'].includes(String(n).trim().toLowerCase()); setPlants(ps => ps.map(x => x.id === p.id ? { ...x, name: (blankName(x.name) && rec._aiName) ? rec._aiName : x.name, every: care.every, light: care.light, care: care.care, fact: care.fact, watering: care.watering, benchmark: care.benchmark, sunlight: care.sunlight, czech: x.czech || care.czech || '', aiV: APP_VERSION, } : x)); } catch (e) {} setAiRecheck(r => ({ ...r, done: r.done + 1 })); } setPlants(ps => ps.map(x => x.aiV ? x : { ...x, aiV: APP_VERSION })); setAiRecheck({ busy: false, done: 0, total: 0 }); }; const exportGarden = () => { const data = { version: APP_VERSION, exportedAt: new Date().toISOString(), gardenKey, locations, queue, plants }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `caulis-${gardenKey}-${fmtLocalDate(new Date())}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const importGarden = (data, mode) => { if (!data || !Array.isArray(data.plants)) return false; const incoming = data.plants.filter(Boolean).map(p => ({ ...p, history: Array.isArray(p.history) ? p.history : [], photos: Array.isArray(p.photos) ? p.photos : [], })); if (mode === 'replace') { setPlants(incoming); setQueue(Array.isArray(data.queue) ? data.queue.filter(id => incoming.some(p => p.id === id)) : []); if (gardenNode) { incoming.forEach(p => { if (p.photos && p.photos.length) pushPhoto(gardenNode, p.id, p.photos); }); } return true; } // merge — reindex colliding ids so existing plants (and their QRs) are untouched let nextId = Math.max(0, ...plants.map(p => p.id), ...incoming.map(p => p.id || 0)) + 1; const existing = new Set(plants.map(p => p.id)); const remap = {}; const added = incoming.map(p => { let id = p.id; if (existing.has(id)) { remap[p.id] = nextId; id = nextId; nextId++; } existing.add(id); return { ...p, id }; }); setPlants([...plants, ...added]); if (gardenNode) { added.forEach(p => { if (p.photos && p.photos.length) pushPhoto(gardenNode, p.id, p.photos); }); } const importedQueue = (Array.isArray(data.queue) ? data.queue : []).map(id => remap[id] != null ? remap[id] : id); const allIds = new Set([...plants, ...added].map(p => p.id)); setQueue(q => [...q, ...importedQueue.filter(id => !q.includes(id) && allIds.has(id))]); return true; }; // ── shared overlay elements ── const detailPlant = detail ? plants.find(p => p.id === detail.id) : null; const moving = moveTarget ? plants.find(p => p.id === moveTarget.id) : null; const detailEl = detailPlant && ( { closeDetail(); setTab('print'); }} onEdit={p=>{ closeDetail(); setForm({mode:'edit', plant:p}); }} onAskDoctor={p=>{ closeDetail(); setDoctor({ plant:p }); }} isDesktop={isDesktop} czechMode={identifyLang === 'cs'} /> ); const applyCorrection = (plantId, changes) => { haptic('light'); setPlants(ps => ps.map(p => { if (p.id !== plantId) return p; const next = { ...p, ...changes }; if (changes.every != null) next.benchmark = `${changes.every} days`; return next; })); }; const doctorEl = doctor && ( p.id === doctor.plant.id) || doctor.plant : null} plants={plants} anthropicKey={anthropicKey} model={doctorModel} onApplyCorrection={applyCorrection} onBack={()=>setDoctor(null)} isDesktop={isDesktop}/> ); const moreEl = moreOpen && (
setMoreOpen(false)} style={{ position:'fixed', inset:0, zIndex:44, background:'rgba(42,42,38,0.34)', display:'flex', flexDirection:'column', justifyContent:'flex-end', animation:'fade 160ms ease' }}>
e.stopPropagation()} style={{ background:C.bg, borderTopLeftRadius:26, borderTopRightRadius:26, padding:'10px 18px calc(30px + env(safe-area-inset-bottom))', animation:'slideUp 260ms cubic-bezier(.2,.8,.2,1)', maxHeight:'72%', overflowY:'auto' }}>
All sections
{NAV_ORDER.filter(a => a !== 'more').map(a => { const meta = NAV_ACTIONS[a]; const active = meta.tab && tab === a; return (
navTo(a)} style={{ cursor:'pointer', display:'flex', flexDirection:'column', alignItems:'center', gap:8, padding:'16px 8px', borderRadius:18, background: active ? 'rgba(110,154,62,0.1)' : C.panel, border: active ? '1px solid rgba(110,154,62,0.4)' : C.hair }}> {meta.label}
); })}
); const formEl = form && ( setForm(null)} onSave={savePlant} onAddLocation={()=>{}} isDesktop={isDesktop} czechMode={identifyLang === 'cs'}/> ); const undoDeleteEl = undoDelete && (
{undoDelete.plant.name} removed
Undo
); const storageFullEl = storageFull && (
Device storage full — recent photos may not be saved. Remove some photos or back up your garden.
setStorageFull(false)} style={{ cursor:'pointer', flexShrink:0, width:26, height:26, borderRadius:999, background:'rgba(255,255,255,0.18)', display:'flex', alignItems:'center', justifyContent:'center' }}>
); const confirmRemoveEl = confirmRemove != null && (() => { const p = plants.find(x => x.id === confirmRemove); if (!p) return null; return (
setConfirmRemove(null)} style={{ position:'fixed', inset:0, zIndex:80, background:'rgba(20,30,12,0.42)', display:'flex', alignItems:'center', justifyContent:'center', padding:24, animation:'fade 160ms ease' }}>
e.stopPropagation()} style={{ width:'100%', maxWidth:320, background:C.panel, borderRadius:22, padding:'22px 22px 16px', boxShadow:'0 18px 48px rgba(0,0,0,0.3)', animation:'popUp 240ms cubic-bezier(.2,.9,.3,1.2)' }}>
Delete {p.name}?
This removes the plant from your garden. You can undo right after.
setConfirmRemove(null)} style={{ flex:1, textAlign:'center', padding:'12px 0', borderRadius:14, background:'rgba(45,80,22,0.07)', fontFamily:FONT_SANS, fontSize:14, fontWeight:600, color:C.ink, cursor:'pointer' }}>Cancel
{ removePlant(confirmRemove); setConfirmRemove(null); }} style={{ flex:1, textAlign:'center', padding:'12px 0', borderRadius:14, background:'#B4472E', fontFamily:FONT_SANS, fontSize:14, fontWeight:600, color:'#fff', cursor:'pointer' }}>Delete
); })(); const bulkRemoveEl = bulkRemoveIds && bulkRemoveIds.length > 0 && (
setBulkRemoveIds(null)} style={{ position:'fixed', inset:0, zIndex:80, background:'rgba(20,30,12,0.42)', display:'flex', alignItems:'center', justifyContent:'center', padding:24, animation:'fade 160ms ease' }}>
e.stopPropagation()} style={{ width:'100%', maxWidth:320, background:C.panel, borderRadius:22, padding:'22px 22px 16px', boxShadow:'0 18px 48px rgba(0,0,0,0.3)', animation:'popUp 240ms cubic-bezier(.2,.9,.3,1.2)' }}>
Delete {bulkRemoveIds.length} plant{bulkRemoveIds.length===1?'':'s'}?
This removes them from your garden and cannot be undone.
setBulkRemoveIds(null)} style={{ flex:1, textAlign:'center', padding:'12px 0', borderRadius:14, background:'rgba(45,80,22,0.07)', fontFamily:FONT_SANS, fontSize:14, fontWeight:600, color:C.ink, cursor:'pointer' }}>Cancel
Delete
); // ── active screen ── const screenProps = { isDesktop }; let screen = null; if (tab === 'garden') screen = openDetail(id)} onAdd={()=>setForm({mode:'add'})} onLongPress={p=>setMenuPlant(p)} onReorder={reorderPlants} density={cardDensity} gridCols={gridCols} hideHealthy={hideHealthy} onBulkWater={bulkWater} onBulkQueue={bulkQueue} onBulkMove={setBulkMove} onBulkRemove={setBulkRemoveIds} onHaptic={()=>haptic('light')} czechMode={identifyLang === 'cs'} {...screenProps}/>; if (tab === 'needs') screen = openDetail(id)} onLongPress={p=>setMenuPlant(p)} onSnooze={snooze} onWaterAll={waterAll} confirmDelete={confirmDelete} czechMode={identifyLang === 'cs'} {...screenProps}/>; if (tab === 'scanner') screen = { if (scannedGarden && scannedGarden !== gardenNode) openGuestPlant(scannedGarden, id); else openDetail(id, true); }} {...screenProps}/>; if (tab === 'print') screen = openDetail(id)} onRemove={removeQueue} onPrintAll={printAll} printed={printed} globalPrintSize={globalPrintSize} onSetGlobalSize={setGlobalPrintSize} queueSizes={queueSizes} onSetSize={setPlantSize} onReorder={reorderQueue} monochromePrint={monochromePrint} onToggleMono={toggleMono} czechMode={identifyLang === 'cs'} {...screenProps}/>; if (tab === 'settings') screen = { if(installPrompt){ installPrompt.prompt(); installPrompt.userChoice.then(()=>setInstallPrompt(null)); } }} darkMode={darkMode} onToggleDark={()=>setDarkMode(!darkMode)} gardenPassword={gardenPassword} onSavePassword={saveGardenPassword} perenualKey={perenualKey} onSavePerenualKey={savePerenualKey} housePlantsKey={housePlantsKey} onSaveHousePlantsKey={saveHousePlantsKey} anthropicKey={anthropicKey} onSaveAnthropicKey={saveAnthropicKey} onRecheckAI={recheckAllAI} aiRecheck={aiRecheck} plantIdKey={plantIdKey} onSavePlantIdKey={savePlantIdKey} identifyLang={identifyLang} onSetIdentifyLang={saveIdentifyLang} defaultEvery={defaultEvery} onSetDefaultEvery={setDefaultEvery} globalPrintSize={globalPrintSize} onSetGlobalSize={setGlobalPrintSize} monochromePrint={monochromePrint} onToggleMono={toggleMono} googleClientId={googleClientId} onSaveGoogleClientId={saveGoogleClientId} googleToken={googleToken} onConnectGoogle={connectGoogle} onSyncCalendar={syncAllToCalendar} onDisconnectGoogle={disconnectGoogle} googleSyncMode={googleSyncMode} onSetGoogleSyncMode={setGoogleSyncMode} reminderTime={reminderTime} onSetReminderTime={setReminderTime} onUpdateApp={updateApp} onExport={exportGarden} onImport={importGarden} cardDensity={cardDensity} onSetDensity={setCardDensity} hideHealthy={hideHealthy} onToggleHideHealthy={()=>setHideHealthy(!hideHealthy)} reduceMotion={reduceMotion} onToggleReduceMotion={()=>setReduceMotion(!reduceMotion)} confirmDelete={confirmDelete} onToggleConfirmDelete={()=>setConfirmDelete(!confirmDelete)} haptics={haptics} onToggleHaptics={()=>setHaptics(!haptics)} defaultTab={defaultTab} onSetDefaultTab={setDefaultTab} swipeNav={swipeNav} onToggleSwipeNav={()=>setSwipeNav(!swipeNav)} onWaterAll={waterAll} onDevOffsetDays={devOffsetDays} onDevSetDays={devSetDays} onDevResyncFromHistory={devResyncFromHistory} onAdminListGardens={adminListAllGardens} onAdminLoadGarden={adminLoadGarden} onAdminSaveGarden={adminSaveGarden} onAdminRemoveGarden={adminRemoveGarden} navConfig={navConfig} onSetNavConfig={setNavConfig} navLabels={navLabels} onToggleNavLabels={()=>setNavLabels(!navLabels)} gridCols={gridCols} onSetGridCols={setGridCols} sidebar={sidebar} onSetSidebar={setSidebar} palette={palette} onSetPalette={setPalette} doctorModel={doctorModel} onSetDoctorModel={setDoctorModel} {...screenProps}/>; // ════════════════════════════════════════ // DESKTOP LAYOUT // ════════════════════════════════════════ if (isDesktop) { return (
{screen}
{detailPlant && ( {detailEl} )} {form && ( setForm(null)} maxWidth={480} noBackdropClose> {formEl} )} {doctor &&
{doctorEl}
} {moreEl} {moving && ( setMoveTarget(null)} onPick={movePlant} onAddLocation={()=>{}} isDesktop/> )} {bulkMove && bulkMove.length > 0 && ( setBulkMove(null)} onPick={movePlant} onAddLocation={()=>{}} isDesktop/> )} {menuPlant && ( setMenuPlant(null)} onEdit={p=>setForm({mode:'edit', plant:p})} onMove={p=>setMoveTarget(p)} onRemove={requestRemove} isDesktop/> )} {plantNotFound && { setPlantNotFound(false); setTab('garden'); }}/>} {undoDeleteEl} {storageFullEl} {confirmRemoveEl} {bulkRemoveEl} {guestView === 'loading' &&
} {guestView && guestView !== 'loading' && ( setGuestView(null)} maxWidth={520}> setGuestView(null)} onWater={()=>{}} onUndoWater={()=>{}} onToggleQueue={()=>{}} onGoQueue={()=>{}} onEdit={()=>{}} isDesktop={isDesktop} czechMode={identifyLang === 'cs'}/> )}
); } // ════════════════════════════════════════ // MOBILE LAYOUT // ════════════════════════════════════════ return (
{(pull > 0 || refreshing) && (
)}
0 ? `translateY(${pull}px)` : undefined, transition: pulling ? 'none' : `transform ${MOTION.base}ms ${MOTION.out}` }}>
{screen}
{detailPlant && detailEl} {form && formEl} {doctor && doctorEl} {moreEl} {moving && ( setMoveTarget(null)} onPick={movePlant} onAddLocation={()=>{}}/> )} {bulkMove && bulkMove.length > 0 && ( setBulkMove(null)} onPick={movePlant} onAddLocation={()=>{}}/> )} {menuPlant && ( setMenuPlant(null)} onEdit={p=>setForm({mode:'edit', plant:p})} onMove={p=>setMoveTarget(p)} onRemove={requestRemove}/> )} {plantNotFound && { setPlantNotFound(false); setTab('garden'); }}/>} {undoDeleteEl} {storageFullEl} {confirmRemoveEl} {bulkRemoveEl} {guestView === 'loading' &&
} {guestView && guestView !== 'loading' && setGuestView(null)} onWater={()=>{}} onUndoWater={()=>{}} onToggleQueue={()=>{}} onGoQueue={()=>{}} onEdit={()=>{}} isDesktop={false} czechMode={identifyLang === 'cs'}/>}
); } ReactDOM.createRoot(document.getElementById('root')).render();