/* Sproutly Frontend — Vanilla JS SPA */ const API = '/api'; // ===== API Helpers ===== async function apiFetch(path, opts = {}) { const res = await fetch(API + path, { headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, ...opts, body: opts.body ? JSON.stringify(opts.body) : undefined, }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || res.statusText); } if (res.status === 204) return null; return res.json(); } const api = { get: (p) => apiFetch(p), post: (p, body) => apiFetch(p, { method: 'POST', body }), put: (p, body) => apiFetch(p, { method: 'PUT', body }), delete: (p) => apiFetch(p, { method: 'DELETE' }), }; // ===== State ===== let state = { varieties: [], batches: [], settings: {}, }; // ===== Toast ===== let toastTimer; function toast(msg, isError = false) { const el = document.getElementById('toast'); el.textContent = msg; el.className = 'toast' + (isError ? ' error' : ''); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.add('hidden'), 3500); } // ===== Utility ===== function fmt(dateStr) { if (!dateStr) return '—'; const d = new Date(dateStr + 'T00:00:00'); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } function daysAway(dateStr) { if (!dateStr) return null; const d = new Date(dateStr + 'T00:00:00'); const today = new Date(); today.setHours(0,0,0,0); return Math.round((d - today) / 86400000); } function relDate(dateStr) { const d = daysAway(dateStr); if (d === null) return ''; if (d < -1) return `${Math.abs(d)} days ago`; if (d === -1) return 'Yesterday'; if (d === 0) return 'Today'; if (d === 1) return 'Tomorrow'; if (d <= 7) return `In ${d} days`; return fmt(dateStr); } function statusLabel(s) { return { planned: 'Planned', germinating: 'Germinating', seedling: 'Seedling', potted_up: 'Potted Up', hardening: 'Hardening Off', garden: 'In Garden', harvested: 'Harvested', failed: 'Failed', }[s] || s; } function sunLabel(s) { return { full_sun: 'Full Sun', part_shade: 'Partial Shade', full_shade: 'Full Shade' }[s] || s; } function esc(str) { return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ===== Navigation ===== function navigate(page) { document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active')); document.getElementById('page-' + page)?.classList.add('active'); document.querySelector(`[data-page="${page}"]`)?.classList.add('active'); if (page === 'dashboard') loadDashboard(); if (page === 'varieties') loadVarieties(); if (page === 'garden') loadGarden(); if (page === 'settings') loadSettings(); } // ===== Dashboard ===== async function loadDashboard() { try { const data = await api.get('/dashboard/'); // Subtitle const sub = document.getElementById('dash-subtitle'); const today = new Date().toLocaleDateString('en-US', { weekday:'long', month:'long', day:'numeric', year:'numeric' }); sub.textContent = data.location_name ? `${data.location_name} — ${today}` : today; // Stats document.getElementById('stat-varieties').textContent = data.stats.total_varieties; document.getElementById('stat-active').textContent = data.stats.active_batches; document.getElementById('stat-garden').textContent = data.stats.in_garden; document.getElementById('stat-tasks').textContent = data.stats.tasks_count; // Frost badge const fb = document.getElementById('frost-badge'); if (data.last_frost_date) { const d = daysAway(data.last_frost_date); if (d !== null && d >= 0 && d <= 60) { fb.textContent = `Last frost in ${d} days (${fmt(data.last_frost_date)})`; fb.style.display = ''; } else if (d !== null && d < 0) { fb.textContent = `Last frost was ${fmt(data.last_frost_date)}`; fb.style.display = ''; } else { fb.textContent = `Last frost: ${fmt(data.last_frost_date)}`; fb.style.display = ''; } } else { fb.textContent = ''; } // Tasks renderTasks(data); // Timeline renderTimeline(data.timeline, data.last_frost_date); // Active batches renderActiveBatches(data.active_batches); } catch (e) { console.error(e); document.getElementById('tasks-container').innerHTML = `
Error loading dashboard: ${esc(e.message)}
`; } } function renderTasks(data) { const container = document.getElementById('tasks-container'); const allGroups = [ { key: 'overdue', label: 'Overdue', tasks: data.tasks_overdue }, { key: 'today', label: 'Today', tasks: data.tasks_today }, { key: 'week', label: 'This Week', tasks: data.tasks_week }, { key: 'month', label: 'This Month', tasks: data.tasks_month }, ].filter(g => g.tasks.length > 0); if (!allGroups.length) { container.innerHTML = '
No upcoming tasks in the next 30 days. Check your settings to set a last frost date.
'; return; } container.innerHTML = allGroups.map(g => `
${g.label}
${g.tasks.map(t => renderTask(t)).join('')}
`).join(''); } function renderTask(t) { const dateClass = t.urgency === 'overdue' ? 'overdue' : ''; const dateText = t.urgency === 'overdue' ? `${Math.abs(t.days_away)} days overdue` : t.days_away === 0 ? 'Today' : relDate(t.due_date); const typeIcon = { start_seeds: '🌱', pot_up: '🪴', transplant: '🌿', check_batch: '🔎' }[t.type] || '📋'; return `
${typeIcon} ${esc(t.title)}
${esc(t.detail)}
${dateText}
`; } // ===== Timeline ===== function renderTimeline(entries, lastFrostDate) { const container = document.getElementById('timeline-container'); if (!entries || !entries.length) { container.innerHTML = '
No varieties configured.
'; return; } const today = new Date(); const startOfYear = new Date(today.getFullYear(), 0, 1); const todayDoy = Math.floor((today - startOfYear) / 86400000) + 1; const daysInYear = 365; const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const monthStarts = [1,32,60,91,121,152,182,213,244,274,305,335]; let html = `
${months.map((m, i) => { const pct = ((monthStarts[i] - 1) / daysInYear * 100).toFixed(2); return `${m}`; }).join('')}
`; // Today marker (positioned in bar area) const todayPct = ((todayDoy - 1) / daysInYear * 100).toFixed(2); entries.forEach(e => { if (!e.start_day && !e.greenhouse_day && !e.garden_day) return; const segments = []; // Indoor start to greenhouse if (e.start_day && e.greenhouse_day) { segments.push({ from: e.start_day, to: e.greenhouse_day, opacity: '0.5', label: 'start' }); } else if (e.start_day && e.garden_day) { segments.push({ from: e.start_day, to: e.garden_day, opacity: '0.45', label: 'start' }); } // Greenhouse to garden if (e.greenhouse_day && e.garden_day) { segments.push({ from: e.greenhouse_day, to: e.garden_day, opacity: '0.7', label: 'greenhouse' }); } // Garden onwards if (e.garden_day) { const endDay = Math.min(e.end_day || e.garden_day + 70, daysInYear); segments.push({ from: e.garden_day, to: endDay, opacity: '1', label: 'garden' }); } html += `
${esc(e.name)}
${segments.map(s => { const left = ((Math.max(s.from, 1) - 1) / daysInYear * 100).toFixed(2); const width = ((Math.min(s.to, daysInYear) - Math.max(s.from, 1)) / daysInYear * 100).toFixed(2); return `
`; }).join('')}
`; }); html += '
'; container.innerHTML = html; } // ===== Active Batches ===== function renderActiveBatches(batches) { const container = document.getElementById('active-batches-container'); if (!batches || !batches.length) { container.innerHTML = '
No active batches yet. Log a batch to get started!
'; return; } container.innerHTML = batches.map(b => batchCard(b, true)).join(''); } function batchCard(b, compact = false) { const v = b.variety || {}; const color = v.color || '#52b788'; const name = b.label || `${v.name}${v.variety_name ? ' ('+v.variety_name+')' : ''}`; const meta = []; if (b.quantity > 1) meta.push(`${b.quantity} plants`); if (b.sow_date) meta.push(`Sown ${fmt(b.sow_date)}`); if (b.garden_date) meta.push(`Garden ${fmt(b.garden_date)}`); return `
${statusLabel(b.status)}
${esc(name)}
${esc(v.name || '')}${v.variety_name ? ' — ' + esc(v.variety_name) : ''}
${meta.map(m => `${esc(m)}`).join('')}
${b.notes ? `
${esc(b.notes)}
` : ''}
`; } // ===== Varieties ===== async function loadVarieties() { try { state.varieties = await api.get('/varieties/'); renderVarieties(); } catch (e) { document.getElementById('varieties-container').innerHTML = `
Error: ${esc(e.message)}
`; } } function renderVarieties() { const search = (document.getElementById('variety-search')?.value || '').toLowerCase(); const cat = document.getElementById('variety-cat-filter')?.value || ''; const list = state.varieties.filter(v => (!search || `${v.name} ${v.variety_name || ''}`.toLowerCase().includes(search)) && (!cat || v.category === cat) ); const container = document.getElementById('varieties-container'); if (!list.length) { container.innerHTML = '
No varieties found.
'; return; } container.innerHTML = ` ${list.map(v => ` `).join('')}
Plant Category Start Seeds Greenhouse Transplant Germination Sun Actions
${esc(v.name)} ${v.variety_name ? `
${esc(v.variety_name)}` : ''}
${v.category} ${v.weeks_to_start ? `${v.weeks_to_start}wk before frost` : '—'} ${v.weeks_to_greenhouse ? `${v.weeks_to_greenhouse}wk before frost` : '—'} ${v.weeks_to_garden != null ? `${v.weeks_to_garden >= 0 ? v.weeks_to_garden+'wk after frost' : Math.abs(v.weeks_to_garden)+'wk before frost'}` : '—'} ${v.days_to_germinate ? `${v.days_to_germinate}d` : '—'} ${sunLabel(v.sun_requirement)}
`; } function filterVarieties() { renderVarieties(); } // ===== Garden ===== async function loadGarden() { try { state.batches = await api.get('/batches/'); renderGarden(); } catch (e) { document.getElementById('garden-container').innerHTML = `
Error: ${esc(e.message)}
`; } } function renderGarden() { const statusFilter = document.getElementById('garden-status-filter')?.value || ''; const list = state.batches.filter(b => !statusFilter || b.status === statusFilter); const container = document.getElementById('garden-container'); if (!list.length) { container.innerHTML = '
No batches found.
'; return; } container.innerHTML = list.map(b => batchCard(b)).join(''); } function filterBatches() { renderGarden(); } // ===== Settings ===== function toggleNtfyAuth(type) { document.getElementById('ntfy-basic-auth').classList.toggle('hidden', type !== 'basic'); document.getElementById('ntfy-token-auth').classList.toggle('hidden', type !== 'token'); } async function loadSettings() { try { state.settings = await api.get('/settings/'); const s = state.settings; document.getElementById('s-location').value = s.location_name || ''; document.getElementById('s-last-frost').value = s.last_frost_date || ''; document.getElementById('s-first-frost').value = s.first_frost_fall_date || ''; document.getElementById('s-timezone').value = s.timezone || 'UTC'; document.getElementById('s-ntfy-server').value = s.ntfy_server || 'https://ntfy.sh'; document.getElementById('s-ntfy-topic').value = s.ntfy_topic || ''; document.getElementById('s-notif-time').value = s.notification_time || '07:00'; // Auth type let authType = 'none'; if (s.ntfy_api_key) authType = 'token'; else if (s.ntfy_username) authType = 'basic'; document.querySelector(`input[name="ntfy-auth-type"][value="${authType}"]`).checked = true; toggleNtfyAuth(authType); document.getElementById('s-ntfy-username').value = s.ntfy_username || ''; document.getElementById('s-ntfy-password').value = s.ntfy_password ? '••••••••' : ''; document.getElementById('s-ntfy-api-key').value = s.ntfy_api_key ? '••••••••' : ''; } catch (e) { toast('Failed to load settings: ' + e.message, true); } } async function saveSettings() { try { const authType = document.querySelector('input[name="ntfy-auth-type"]:checked').value; const rawPassword = document.getElementById('s-ntfy-password').value; const rawApiKey = document.getElementById('s-ntfy-api-key').value; // If the placeholder mask is still showing, don't overwrite const isMask = v => v === '••••••••'; const payload = { location_name: document.getElementById('s-location').value || null, last_frost_date: document.getElementById('s-last-frost').value || null, first_frost_fall_date: document.getElementById('s-first-frost').value || null, timezone: document.getElementById('s-timezone').value || 'UTC', ntfy_server: document.getElementById('s-ntfy-server').value || 'https://ntfy.sh', ntfy_topic: document.getElementById('s-ntfy-topic').value || null, notification_time: document.getElementById('s-notif-time').value || '07:00', ntfy_username: authType === 'basic' ? (document.getElementById('s-ntfy-username').value || null) : null, ntfy_password: authType === 'basic' && !isMask(rawPassword) ? (rawPassword || null) : (authType === 'basic' ? undefined : null), ntfy_api_key: authType === 'token' && !isMask(rawApiKey) ? (rawApiKey || null) : (authType === 'token' ? undefined : null), }; // Remove undefined keys so they are excluded from the PUT (server keeps existing value) Object.keys(payload).forEach(k => payload[k] === undefined && delete payload[k]); await api.put('/settings/', payload); toast('Settings saved!'); document.getElementById('settings-status').textContent = 'Saved!'; setTimeout(() => document.getElementById('settings-status').textContent = '', 3000); } catch (e) { toast('Save failed: ' + e.message, true); } } async function sendTestNotification() { try { await api.post('/notifications/test', {}); toast('Test notification sent!'); } catch (e) { toast('Failed: ' + e.message, true); } } async function sendDailySummary() { try { await api.post('/notifications/daily', {}); toast('Daily summary sent!'); } catch (e) { toast('Failed: ' + e.message, true); } } // ===== Modals ===== function openModal(title, bodyHtml) { document.getElementById('modal-title').textContent = title; document.getElementById('modal-body').innerHTML = bodyHtml; document.getElementById('modal-overlay').classList.remove('hidden'); } function closeModal(e) { if (e && e.target !== document.getElementById('modal-overlay')) return; document.getElementById('modal-overlay').classList.add('hidden'); } function varietyFormHtml(v = {}) { const colorOpts = ['#e76f51','#f4a261','#e9c46a','#52b788','#40916c','#2d6a4f','#95d5b2','#2d9cdb','#a8dadc','#e63946']; return `
`; } function collectVarietyForm() { const wg = document.getElementById('f-wks-garden').value; return { name: document.getElementById('f-name').value.trim(), variety_name: document.getElementById('f-variety-name').value.trim() || null, category: document.getElementById('f-category').value, color: document.getElementById('f-color').value, weeks_to_start: parseInt(document.getElementById('f-wks-start').value) || null, weeks_to_greenhouse: parseInt(document.getElementById('f-wks-gh').value) || null, weeks_to_garden: wg !== '' ? parseInt(wg) : null, days_to_germinate: parseInt(document.getElementById('f-germinate').value) || 7, sun_requirement: document.getElementById('f-sun').value, water_needs: document.getElementById('f-water').value, direct_sow_ok: document.getElementById('f-direct-sow').checked, frost_tolerant: document.getElementById('f-frost-tolerant').checked, notes: document.getElementById('f-notes').value.trim() || null, }; } function showAddVarietyModal() { openModal('Add Seed Variety', ` ${varietyFormHtml()}
`); } async function submitAddVariety() { try { const data = collectVarietyForm(); if (!data.name) { toast('Plant name is required', true); return; } await api.post('/varieties/', data); closeModal(); toast('Variety added!'); await loadVarieties(); } catch (e) { toast('Error: ' + e.message, true); } } function showEditVarietyModal(id) { const v = state.varieties.find(x => x.id === id); if (!v) return; openModal('Edit ' + v.name, ` ${varietyFormHtml(v)}
`); } async function submitEditVariety(id) { try { const data = collectVarietyForm(); if (!data.name) { toast('Plant name is required', true); return; } await api.put(`/varieties/${id}`, data); closeModal(); toast('Variety updated!'); await loadVarieties(); } catch (e) { toast('Error: ' + e.message, true); } } async function deleteVariety(id) { const v = state.varieties.find(x => x.id === id); if (!confirm(`Delete ${v ? v.name : 'this variety'}? This will also delete all associated batches.`)) return; try { await api.delete(`/varieties/${id}`); toast('Variety deleted'); await loadVarieties(); } catch (e) { toast('Error: ' + e.message, true); } } // ===== Batch Modals ===== function batchFormHtml(b = {}) { const varOpts = state.varieties.map(v => `` ).join(''); return `
`; } function collectBatchForm() { return { variety_id: parseInt(document.getElementById('bf-variety').value), label: document.getElementById('bf-label').value.trim() || null, quantity: parseInt(document.getElementById('bf-qty').value) || 1, status: document.getElementById('bf-status').value, sow_date: document.getElementById('bf-sow').value || null, germination_date: document.getElementById('bf-germ').value || null, greenhouse_date: document.getElementById('bf-gh').value || null, garden_date: document.getElementById('bf-garden').value || null, notes: document.getElementById('bf-notes').value.trim() || null, }; } function showAddBatchModal() { if (!state.varieties.length) { // Load varieties first if not loaded api.get('/varieties/').then(v => { state.varieties = v; showAddBatchModal(); }); return; } openModal('Log a Batch', ` ${batchFormHtml()}
`); } async function submitAddBatch() { try { const data = collectBatchForm(); await api.post('/batches/', data); closeModal(); toast('Batch logged!'); state.batches = await api.get('/batches/'); renderGarden(); } catch (e) { toast('Error: ' + e.message, true); } } async function showEditBatchModal(id) { try { if (!state.varieties.length) state.varieties = await api.get('/varieties/'); const b = state.batches.find(x => x.id === id) || await api.get(`/batches/${id}`); openModal('Edit Batch', ` ${batchFormHtml(b)}
`); } catch (e) { toast('Error: ' + e.message, true); } } async function submitEditBatch(id) { try { const data = collectBatchForm(); await api.put(`/batches/${id}`, data); closeModal(); toast('Batch updated!'); state.batches = await api.get('/batches/'); renderGarden(); renderActiveBatches(state.batches.filter(b => !['harvested','failed'].includes(b.status) )); } catch (e) { toast('Error: ' + e.message, true); } } async function deleteBatch(id) { if (!confirm('Delete this batch?')) return; try { await api.delete(`/batches/${id}`); toast('Batch deleted'); state.batches = await api.get('/batches/'); renderGarden(); } catch (e) { toast('Error: ' + e.message, true); } } // ===== Init ===== function init() { // Sidebar date document.getElementById('sidebar-date').textContent = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); // Navigation via hash function handleNav() { const page = (location.hash.replace('#','') || 'dashboard'); navigate(['dashboard','varieties','garden','settings'].includes(page) ? page : 'dashboard'); } window.addEventListener('hashchange', handleNav); handleNav(); } // ===== Public API ===== window.App = { showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety, showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch, filterVarieties, filterBatches, saveSettings, toggleNtfyAuth, sendTestNotification, sendDailySummary, closeModal: (e) => closeModal(e), }; document.addEventListener('DOMContentLoaded', init);