// Couche API — appels vers le backend PHP/MySQL (function () { window.API_BASE = (window.API_CONFIG?.base || '') + '/api'; })(); async function apiCall(path, options = {}) { const token = localStorage.getItem('maisonnee:token') || ''; const res = await fetch(window.API_BASE + path, { headers: { 'Content-Type': 'application/json', 'X-Token': token, ...(options.headers || {}), }, ...options, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `Erreur ${res.status}`); return data; } // ── Auth ───────────────────────────────────────────── function useAuthSB() { const [user, setUser] = React.useState(() => { try { return localStorage.getItem('maisonnee:user') || null; } catch { return null; } }); const [loading] = React.useState(false); const login = async (key) => { const data = await apiCall('/auth.php', { method: 'POST', body: JSON.stringify({ username: key, password: key }), }); localStorage.setItem('maisonnee:token', data.token); localStorage.setItem('maisonnee:user', data.user); setUser(data.user); }; const logout = async () => { try { await apiCall('/auth.php', { method: 'DELETE' }); } catch {} localStorage.removeItem('maisonnee:token'); localStorage.removeItem('maisonnee:user'); localStorage.removeItem('maisonnee:items'); setUser(null); }; return { user, login, logout, loading }; } // ── Store ───────────────────────────────────────────── function useStoreSB() { const [items, setItems] = React.useState(() => { try { const raw = localStorage.getItem('maisonnee:items'); if (raw) return JSON.parse(raw); } catch {} return []; }); const [synced, setSynced] = React.useState(false); const [syncError, setSyncError] = React.useState(null); // IDs des items en cours d'écriture → le polling ne doit pas les écraser const pendingRef = React.useRef(new Set()); const loadItems = React.useCallback(async () => { if (!localStorage.getItem('maisonnee:token')) return; try { const data = await apiCall('/items.php'); const serverItems = Array.isArray(data) ? data : []; // Serveur vide mais localStorage a des items → on les remonte if (serverItems.length === 0) { let localItems = []; try { localItems = JSON.parse(localStorage.getItem('maisonnee:items') || '[]'); } catch {} if (localItems.length > 0) { await Promise.all(localItems.map(it => apiCall('/items.php', { method: 'POST', body: JSON.stringify(it) }).catch(() => {}) )); const fresh = await apiCall('/items.php'); setItems(Array.isArray(fresh) ? fresh : localItems); setSynced(true); setSyncError(null); return; } } // Fusion : on ne touche pas aux items dont le POST est encore en vol setItems(prev => { const pending = pendingRef.current; if (pending.size === 0) return serverItems; const prevMap = new Map(prev.map(x => [x.id, x])); const serverIds = new Set(serverItems.map(x => x.id)); const merged = serverItems.map(s => pending.has(s.id) ? (prevMap.get(s.id) || s) : s ); // Items locaux pending pas encore confirmés par le serveur prev.forEach(loc => { if (pending.has(loc.id) && !serverIds.has(loc.id)) merged.push(loc); }); return merged; }); setSynced(true); setSyncError(null); } catch (e) { setSyncError(e.message); setSynced(true); } }, []); // Chargement initial + reset au changement de session React.useEffect(() => { const token = localStorage.getItem('maisonnee:token'); if (!token) { setItems([]); return; } loadItems(); }, [loadItems]); // Synchro cross-appareils : polling 10s + focus React.useEffect(() => { const interval = setInterval(() => { if (!document.hidden) loadItems(); }, 10000); const onVisible = () => { if (!document.hidden) loadItems(); }; const onFocus = () => loadItems(); document.addEventListener('visibilitychange', onVisible); window.addEventListener('focus', onFocus); return () => { clearInterval(interval); document.removeEventListener('visibilitychange', onVisible); window.removeEventListener('focus', onFocus); }; }, [loadItems]); // Cache local React.useEffect(() => { try { localStorage.setItem('maisonnee:items', JSON.stringify(items)); } catch {} }, [items]); // ── Persistance ────────────────────────────────────── const persist = async (it) => { pendingRef.current.add(it.id); try { await apiCall('/items.php', { method: 'POST', body: JSON.stringify(it) }); setSyncError(null); } catch (e) { setSyncError(e.message); } finally { pendingRef.current.delete(it.id); } }; const persistDelete = async (id) => { try { await apiCall('/items.php?id=' + encodeURIComponent(id), { method: 'DELETE' }); setSyncError(null); } catch (e) { setSyncError(e.message); } }; // ── Actions (persist appelé HORS du setState) ──────── const upsert = (item) => { let merged; setItems(prev => { const i = prev.findIndex(x => x.id === item.id); merged = i === -1 ? { overrides: {}, ...item } : { ...prev[i], ...item }; return i === -1 ? [...prev, merged] : prev.map(x => x.id === item.id ? merged : x); }); if (merged) persist(merged); }; const remove = (id) => { setItems(prev => prev.filter(x => x.id !== id)); persistDelete(id); }; const setStatusOn = (id, dateStr, status) => { let updated; setItems(prev => prev.map(x => { if (x.id !== id) return x; if ((x.recurrence?.kind || 'none') === 'none') { if (status === 'not_today') updated = { ...x, date: ymd(addDays(fromYmd(x.date), 1)) }; else updated = { ...x, status }; } else { if (status === 'not_today') { const next = ymd(addDays(fromYmd(dateStr), 1)); updated = { ...x, overrides: { ...(x.overrides || {}), [dateStr]: 'cancelled' }, extraDays: Array.from(new Set([...(x.extraDays || []), next])), }; } else { updated = { ...x, overrides: { ...(x.overrides || {}), [dateStr]: status } }; } } return updated || x; })); if (updated) persist(updated); }; return { items, upsert, remove, setStatusOn, synced, syncError, reload: loadItems }; } Object.assign(window, { useAuthSB, useStoreSB });