/* Sproutly Frontend — Vanilla JS SPA */
const API = '/api';
// ===== Auth =====
const Auth = (() => {
function showTab(tab) {
document.getElementById('auth-login-panel').classList.toggle('hidden', tab !== 'login');
document.getElementById('auth-register-panel').classList.toggle('hidden', tab !== 'register');
document.getElementById('tab-login').classList.toggle('active', tab === 'login');
document.getElementById('tab-register').classList.toggle('active', tab === 'register');
document.getElementById('auth-error').classList.add('hidden');
document.getElementById('reg-error').classList.add('hidden');
}
async function submit() {
const email = document.getElementById('auth-email').value.trim();
const password = document.getElementById('auth-password').value;
const errEl = document.getElementById('auth-error');
errEl.classList.add('hidden');
try {
const res = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
errEl.textContent = err.detail || 'Login failed';
errEl.classList.remove('hidden');
return;
}
const data = await res.json();
localStorage.setItem('sproutly_token', data.access_token);
localStorage.setItem('sproutly_user', email);
showApp();
initApp();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
}
}
async function submitRegister() {
const email = document.getElementById('reg-email').value.trim();
const password = document.getElementById('reg-password').value;
const errEl = document.getElementById('reg-error');
errEl.classList.add('hidden');
if (password.length < 8) {
errEl.textContent = 'Password must be at least 8 characters';
errEl.classList.remove('hidden');
return;
}
try {
const res = await fetch(API + '/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Registration failed' }));
errEl.textContent = err.detail || 'Registration failed';
errEl.classList.remove('hidden');
return;
}
// Auto-login after register
const loginRes = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await loginRes.json();
localStorage.setItem('sproutly_token', data.access_token);
localStorage.setItem('sproutly_user', email);
showApp();
initApp();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
}
}
function logout() {
localStorage.removeItem('sproutly_token');
localStorage.removeItem('sproutly_user');
document.getElementById('app-shell').classList.add('hidden');
document.getElementById('auth-screen').classList.remove('hidden');
showTab('login');
}
return { showTab, submit, submitRegister, logout };
})();
function showApp() {
document.getElementById('auth-screen').classList.add('hidden');
document.getElementById('app-shell').classList.remove('hidden');
const email = localStorage.getItem('sproutly_user') || '';
document.getElementById('sidebar-user').textContent = email;
}
// ===== API Helpers =====
async function apiFetch(path, opts = {}) {
const token = localStorage.getItem('sproutly_token');
const res = await fetch(API + path, {
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(opts.headers || {}),
},
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) {
Auth.logout();
throw new Error('Session expired — please log in again');
}
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 = `
| Plant |
Category |
Start Seeds |
Greenhouse |
Transplant |
Germination |
Sun |
Actions |
${list.map(v => `
${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)} |
|
`).join('')}
`;
}
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 initApp() {
document.getElementById('sidebar-date').textContent =
new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
function handleNav() {
const page = (location.hash.replace('#','') || 'dashboard');
navigate(['dashboard','varieties','garden','settings'].includes(page) ? page : 'dashboard');
}
window.removeEventListener('hashchange', handleNav);
window.addEventListener('hashchange', handleNav);
handleNav();
}
async function init() {
const token = localStorage.getItem('sproutly_token');
if (!token) return; // auth screen is visible by default
try {
await apiFetch('/auth/me');
showApp();
initApp();
} catch (e) {
// token invalid — auth screen stays visible
}
}
// ===== Public API =====
window.App = {
showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety,
showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch,
filterVarieties, filterBatches,
saveSettings, toggleNtfyAuth, sendTestNotification, sendDailySummary,
closeModal: (e) => closeModal(e),
};
window.Auth = {
showTab: (t) => Auth.showTab(t),
submit: () => Auth.submit(),
submitRegister: () => Auth.submitRegister(),
logout: () => Auth.logout(),
};
document.addEventListener('DOMContentLoaded', init);