(() => { const $ = (sel) => document.querySelector(sel); const TABS = [ { id: "trips", label: "Trips" }, { id: "shopping", label: "Shopping" }, { id: "todo", label: "Pre-trip Do" }, { id: "check", label: "Pre-trip Check" }, { id: "upgrades", label: "Upgrades" }, { id: "fixes", label: "Fixes" }, ]; const state = { activeTab: "trips", data: null, dirty: false, saveTimer: null, }; const uid = () => Math.random().toString(16).slice(2) + Date.now().toString(16); const mapsLink = (address) => `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address || "")}`; function setStatus(msg) { const el = $("#status"); if (el) el.textContent = msg; } function escapeHtml(s) { return String(s ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function safeUrl(u) { const s = String(u ?? "").trim(); if (!s) return ""; if (/^https?:\/\//i.test(s)) return s; return ""; } function panel(title, subtitle, innerHTML) { return `

${escapeHtml(title)}

${escapeHtml(subtitle || "")}

${innerHTML}
`; } async function apiGet() { const res = await fetch("/api/data_get.php", { credentials: "include" }); if (!res.ok) throw new Error(`GET failed: ${res.status}`); const j = await res.json(); if (!j.ok) throw new Error(j.error || "GET error"); return j.data; } async function apiPut(payload) { const res = await fetch("/api/data_put.php", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error(`PUT failed: ${res.status}`); const j = await res.json(); if (!j.ok) throw new Error(j.error || "PUT error"); return true; } function normalize(d) { return { meta: { schema: 1, updatedAt: new Date().toISOString() }, trips: Array.isArray(d?.trips) ? d.trips : [], shopping: Array.isArray(d?.shopping) ? d.shopping : [], preTripTodos: Array.isArray(d?.preTripTodos) ? d.preTripTodos : [], preTripChecklist: Array.isArray(d?.preTripChecklist) ? d.preTripChecklist : [], upgrades: Array.isArray(d?.upgrades) ? d.upgrades : [], fixes: Array.isArray(d?.fixes) ? d.fixes : [], }; } function markDirty() { state.dirty = true; setStatus("Unsaved changes..."); if (state.saveTimer) clearTimeout(state.saveTimer); state.saveTimer = setTimeout(saveNow, 650); } async function saveNow() { if (!state.dirty) return; state.dirty = false; try { const payload = (typeof structuredClone === "function") ? structuredClone(state.data) : JSON.parse(JSON.stringify(state.data)); payload.meta = payload.meta || {}; payload.meta.schema = 1; payload.meta.updatedAt = new Date().toISOString(); setStatus("Saving..."); await apiPut(payload); setStatus("Saved."); } catch (e) { state.dirty = true; setStatus(`Save failed: ${e.message}`); } } function renderTabs() { const tabs = $("#tabs"); if (!tabs) return; tabs.innerHTML = ""; for (const t of TABS) { const b = document.createElement("button"); b.className = "tab" + (state.activeTab === t.id ? " active" : ""); b.textContent = t.label; b.onclick = () => { state.activeTab = t.id; render(); }; tabs.appendChild(b); } } function moneyLabel(v) { if (v === "" || v == null) return ""; const n = Number(v); if (Number.isNaN(n)) return String(v); return n.toLocaleString(undefined, { style: "currency", currency: "USD" }); } // ---------- Trips ---------- function renderTrips() { const trips = state.data.trips || []; const sorted = [...trips].sort((a, b) => (b.startDate || "").localeCompare(a.startDate || "")); const form = `

Upcoming Trips

Name, contact, dates, cost, paid status, venue link, and a one-click Maps link.

`; const list = sorted.length ? `
${ sorted .map((t) => { const badge = t.paid ? `Paid` : `Unpaid`; const pills = [ t.cost !== "" ? `${escapeHtml(moneyLabel(t.cost))}` : "", t.phone ? `${escapeHtml(t.phone)}` : "", (t.startDate || t.endDate) ? `${escapeHtml((t.startDate || "") + (t.endDate ? " \u2192 " + t.endDate : ""))}` : "", ].filter(Boolean).join(""); const addr = t.address ? `
${escapeHtml(t.address)}
` : ""; const mapsBtn = t.address ? `Maps` : ``; const venue = safeUrl(t.venueWebsite); const venueBtn = venue ? `Venue` : ``; return `
${escapeHtml(t.name)}
${badge}
${pills ? `
${pills}
` : ""} ${addr}
${mapsBtn} ${venueBtn}
`; }) .join("") }
` : `
No trips yet.
`; return form + list; } function bindTrips() { $("#trip_add")?.addEventListener("click", () => { const name = $("#trip_name").value.trim(); if (!name) return; const trip = { id: uid(), createdAt: new Date().toISOString(), name, address: $("#trip_address").value.trim(), phone: $("#trip_phone").value.trim(), startDate: $("#trip_start").value, endDate: $("#trip_end").value, cost: $("#trip_cost").value.trim(), paid: $("#trip_paid").checked, venueWebsite: $("#trip_website").value.trim(), }; state.data.trips = [trip, ...(state.data.trips || [])]; markDirty(); render(); }); } // ---------- Generic Checklist ---------- function renderChecklist(opts) { const items = opts.items || []; const addForm = `
${opts.allowStore ? `` : `
`} ${opts.extraForm ? opts.extraForm() : ""}
`; const list = items.length ? `
${ items .map((x) => { // NOTE: no "Notes" pill; notes render as plain text below. const pills = [ x.store ? `Store: ${escapeHtml(x.store)}` : "", x.partsNeeded ? `Parts: ${escapeHtml(x.partsNeeded)}` : "", typeof x.purchased === "boolean" ? `${x.purchased ? "Parts purchased" : "Parts NOT purchased"}` : "", ].filter(Boolean).join(""); return `
${escapeHtml(x.label || "")}
${pills ? `
${pills}
` : ""} ${x.notes ? `
${escapeHtml(x.notes)}
` : ""}
`; }) .join("") }
` : `
No items yet.
`; return panel(opts.title, opts.subtitle, addForm + "
" + list); } function bindChecklist(opts) { $(`#${opts.id}_add`)?.addEventListener("click", () => { const label = $(`#${opts.id}_label`).value.trim(); if (!label) return; const item = { id: uid(), label, done: false }; if (opts.allowStore) item.store = $(`#${opts.id}_store`).value.trim(); const notes = $(`#${opts.id}_notes`).value.trim(); if (notes) item.notes = notes; if (opts.extraRead) Object.assign(item, opts.extraRead()); const arr = state.data[opts.key] || []; state.data[opts.key] = [item, ...arr]; markDirty(); render(); }); } function renderFixes() { return renderChecklist({ id: "fixes", key: "fixes", title: "Things to Fix", subtitle: "Track issues, parts required, and whether parts are already purchased.", placeholder: "E.g., Replace anode rod", items: state.data.fixes || [], extraForm: () => ` `, extraRead: () => ({ partsNeeded: $("#fixes_parts").value.trim(), purchased: $("#fixes_purchased").checked, }), }); } function renderShopping() { return renderChecklist({ id: "shopping", key: "shopping", title: "Shopping List", subtitle: "Check items off while you're grabbing them off the shelf or buying.", placeholder: "E.g., 30A to 15A adapter", allowStore: true, items: state.data.shopping || [], }); } function renderTodo() { return renderChecklist({ id: "todo", key: "preTripTodos", title: "Pre-Trip Do List", subtitle: "Tasks that can change depending on the trip.", placeholder: "E.g., Fill fresh water tank", items: state.data.preTripTodos || [], }); } function renderCheck() { return renderChecklist({ id: "check", key: "preTripChecklist", title: "Pre-Trip Checklist", subtitle: "Repeatable checkboxes for go-time.", placeholder: "E.g., Hitch locked & pin installed", items: state.data.preTripChecklist || [], }); } function renderUpgrades() { return renderChecklist({ id: "upgrades", key: "upgrades", title: "Upgrades", subtitle: "Wishlist and planned improvements for the trailer.", placeholder: "E.g., Add A/C soft start", items: state.data.upgrades || [], }); } function bindDelegates() { const content = $("#content"); if (!content) return; content.addEventListener("click", (e) => { const btn = e.target.closest("[data-action]"); if (!btn) return; const action = btn.dataset.action; const id = btn.dataset.id; // Trips if (action === "trip_remove") { state.data.trips = (state.data.trips || []).filter((t) => t.id !== id); markDirty(); render(); return; } if (action === "trip_toggle_paid") { state.data.trips = (state.data.trips || []).map((t) => (t.id === id ? { ...t, paid: !t.paid } : t)); markDirty(); render(); return; } // Removes for lists const removeMap = { shopping_remove: "shopping", todo_remove: "preTripTodos", check_remove: "preTripChecklist", upgrades_remove: "upgrades", fixes_remove: "fixes", }; if (removeMap[action]) { const key = removeMap[action]; state.data[key] = (state.data[key] || []).filter((x) => x.id !== id); markDirty(); render(); return; } // Edits for lists (prompt-based) const editMap = { shopping_edit: "shopping", todo_edit: "preTripTodos", check_edit: "preTripChecklist", upgrades_edit: "upgrades", fixes_edit: "fixes", }; if (editMap[action]) { const key = editMap[action]; const list = state.data[key] || []; const item = list.find((z) => z.id === id); if (!item) return; const newLabel = prompt("Edit item", item.label ?? ""); if (newLabel === null) return; const newNotes = prompt("Edit notes (optional)", item.notes ?? ""); if (newNotes === null) return; let newStore = item.store ?? ""; if (key === "shopping") { const v = prompt("Edit store (optional)", item.store ?? ""); if (v === null) return; newStore = v; } let newParts = item.partsNeeded ?? ""; let newPurchased = !!item.purchased; if (key === "fixes") { const p = prompt("Edit parts needed (optional)", item.partsNeeded ?? ""); if (p === null) return; newParts = p; const bought = prompt("Parts purchased? (yes/no)", item.purchased ? "yes" : "no"); if (bought === null) return; newPurchased = /^y(es)?$/i.test((bought || "").trim()); } state.data[key] = list.map((z) => { if (z.id !== id) return z; const updated = { ...z, label: String(newLabel).trim() }; const nn = String(newNotes ?? "").trim(); if (nn) updated.notes = nn; else delete updated.notes; if (key === "shopping") { const st = String(newStore ?? "").trim(); if (st) updated.store = st; else delete updated.store; } if (key === "fixes") { const pn = String(newParts ?? "").trim(); if (pn) updated.partsNeeded = pn; else delete updated.partsNeeded; updated.purchased = !!newPurchased; } return updated; }); markDirty(); render(); return; } }); // Checkbox toggles (lists) content.addEventListener("change", (e) => { const el = e.target; if (!el.matches("[data-action]")) return; const action = el.dataset.action; const id = el.dataset.id; const toggleMap = { shopping_toggle: "shopping", todo_toggle: "preTripTodos", check_toggle: "preTripChecklist", upgrades_toggle: "upgrades", fixes_toggle: "fixes", }; if (toggleMap[action]) { const key = toggleMap[action]; state.data[key] = (state.data[key] || []).map((x) => (x.id === id ? { ...x, done: !!el.checked } : x)); markDirty(); } }); } function render() { renderTabs(); const c = $("#content"); if (!c) return; if (!state.data) { c.innerHTML = panel("Loading...", "", `
Please wait.
`); return; } let htmlOut = ""; switch (state.activeTab) { case "trips": htmlOut = renderTrips(); break; case "shopping": htmlOut = renderShopping(); break; case "todo": htmlOut = renderTodo(); break; case "check": htmlOut = renderCheck(); break; case "upgrades": htmlOut = renderUpgrades(); break; case "fixes": htmlOut = renderFixes(); break; } c.innerHTML = htmlOut; if (state.activeTab === "trips") bindTrips(); if (state.activeTab === "shopping") bindChecklist({ id: "shopping", key: "shopping", allowStore: true }); if (state.activeTab === "todo") bindChecklist({ id: "todo", key: "preTripTodos" }); if (state.activeTab === "check") bindChecklist({ id: "check", key: "preTripChecklist" }); if (state.activeTab === "upgrades") bindChecklist({ id: "upgrades", key: "upgrades" }); if (state.activeTab === "fixes") { bindChecklist({ id: "fixes", key: "fixes", extraRead: () => ({ partsNeeded: $("#fixes_parts").value.trim(), purchased: $("#fixes_purchased").checked, }), }); } } function bindTopbar() { $("#btnLogout")?.addEventListener("click", () => { window.location.href = "/api/logout.php"; }); $("#btnExport")?.addEventListener("click", () => { const blob = new Blob([JSON.stringify(state.data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `trailer-trekker-backup-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }); $("#fileImport")?.addEventListener("change", async (e) => { const f = e.target.files?.[0]; if (!f) return; try { const text = await f.text(); const parsed = JSON.parse(text); state.data = normalize(parsed); markDirty(); render(); setStatus("Imported. Saving..."); await saveNow(); } catch (err) { setStatus("Import failed: " + err.message); } finally { e.target.value = ""; } }); window.addEventListener("beforeunload", (e) => { if (state.dirty) { e.preventDefault(); e.returnValue = ""; } }); } async function init() { try { setStatus("Loading from server..."); const data = await apiGet(); state.data = normalize(data); setStatus("Ready."); } catch (e) { state.data = normalize({}); setStatus(`Server load failed: ${e.message} (using empty data)`); } render(); bindTopbar(); bindDelegates(); } init(); })();