Initial commit: Sproutly plant tracking app
This commit is contained in:
786
nginx/html/js/app.js
Normal file
786
nginx/html/js/app.js
Normal file
@@ -0,0 +1,786 @@
|
||||
/* 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,'>').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 = `<div class="empty-state">Error loading dashboard: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="empty-state">No upcoming tasks in the next 30 days. Check your settings to set a last frost date.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = allGroups.map(g => `
|
||||
<div class="tasks-group">
|
||||
<span class="tasks-group-label ${g.key}">${g.label}</span>
|
||||
<div class="task-list">
|
||||
${g.tasks.map(t => renderTask(t)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).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 `
|
||||
<div class="task-card ${t.urgency}">
|
||||
<div class="task-dot" style="background:${esc(t.variety_color)}"></div>
|
||||
<div class="task-info">
|
||||
<div class="task-title">${typeIcon} ${esc(t.title)}</div>
|
||||
<div class="task-detail">${esc(t.detail)}</div>
|
||||
</div>
|
||||
<div class="task-date ${dateClass}">${dateText}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ===== Timeline =====
|
||||
function renderTimeline(entries, lastFrostDate) {
|
||||
const container = document.getElementById('timeline-container');
|
||||
if (!entries || !entries.length) {
|
||||
container.innerHTML = '<div class="empty-state">No varieties configured.</div>';
|
||||
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 = `
|
||||
<div style="position:relative; min-width:700px">
|
||||
<div class="timeline-months">
|
||||
<span></span>
|
||||
${months.map((m, i) => {
|
||||
const pct = ((monthStarts[i] - 1) / daysInYear * 100).toFixed(2);
|
||||
return `<span>${m}</span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 += `
|
||||
<div class="timeline-row">
|
||||
<div class="timeline-label" title="${esc(e.full_name)}">${esc(e.name)}</div>
|
||||
<div class="timeline-bar-area">
|
||||
<div class="timeline-today-line" style="left:${todayPct}%"></div>
|
||||
${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 `<div class="timeline-segment" style="left:${left}%;width:${width}%;background:${e.color};opacity:${s.opacity}" title="${e.full_name}: ${s.label}"></div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ===== Active Batches =====
|
||||
function renderActiveBatches(batches) {
|
||||
const container = document.getElementById('active-batches-container');
|
||||
if (!batches || !batches.length) {
|
||||
container.innerHTML = '<div class="empty-state">No active batches yet. Log a batch to get started!</div>';
|
||||
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 `
|
||||
<div class="batch-card">
|
||||
<div class="batch-card-top" style="background:${color}"></div>
|
||||
<div class="batch-card-body">
|
||||
<div class="badge-status status-${b.status}">${statusLabel(b.status)}</div>
|
||||
<div class="batch-card-name">${esc(name)}</div>
|
||||
<div class="batch-card-variety">${esc(v.name || '')}${v.variety_name ? ' — ' + esc(v.variety_name) : ''}</div>
|
||||
<div class="batch-card-meta">${meta.map(m => `<span class="batch-meta-item">${esc(m)}</span>`).join('')}</div>
|
||||
${b.notes ? `<div class="batch-card-variety" style="margin-bottom:.5rem">${esc(b.notes)}</div>` : ''}
|
||||
<div class="batch-card-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="App.showEditBatchModal(${b.id})">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="App.deleteBatch(${b.id})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ===== Varieties =====
|
||||
async function loadVarieties() {
|
||||
try {
|
||||
state.varieties = await api.get('/varieties/');
|
||||
renderVarieties();
|
||||
} catch (e) {
|
||||
document.getElementById('varieties-container').innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="empty-state">No varieties found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="variety-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plant</th>
|
||||
<th>Category</th>
|
||||
<th>Start Seeds</th>
|
||||
<th>Greenhouse</th>
|
||||
<th>Transplant</th>
|
||||
<th>Germination</th>
|
||||
<th>Sun</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${list.map(v => `
|
||||
<tr>
|
||||
<td>
|
||||
<span class="variety-color-dot" style="background:${v.color}"></span>
|
||||
<strong>${esc(v.name)}</strong>
|
||||
${v.variety_name ? `<br><small style="color:var(--text-muted)">${esc(v.variety_name)}</small>` : ''}
|
||||
</td>
|
||||
<td><span class="cat-badge cat-${v.category}">${v.category}</span></td>
|
||||
<td>${v.weeks_to_start ? `<span class="weeks-chip">${v.weeks_to_start}wk before frost</span>` : '—'}</td>
|
||||
<td>${v.weeks_to_greenhouse ? `<span class="weeks-chip">${v.weeks_to_greenhouse}wk before frost</span>` : '—'}</td>
|
||||
<td>${v.weeks_to_garden != null
|
||||
? `<span class="weeks-chip">${v.weeks_to_garden >= 0 ? v.weeks_to_garden+'wk after frost' : Math.abs(v.weeks_to_garden)+'wk before frost'}</span>`
|
||||
: '—'}</td>
|
||||
<td>${v.days_to_germinate ? `${v.days_to_germinate}d` : '—'}</td>
|
||||
<td>${sunLabel(v.sun_requirement)}</td>
|
||||
<td>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-secondary btn-sm" onclick="App.showEditVarietyModal(${v.id})">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="App.deleteVariety(${v.id})">Del</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function filterVarieties() { renderVarieties(); }
|
||||
|
||||
// ===== Garden =====
|
||||
async function loadGarden() {
|
||||
try {
|
||||
state.batches = await api.get('/batches/');
|
||||
renderGarden();
|
||||
} catch (e) {
|
||||
document.getElementById('garden-container').innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="empty-state">No batches found.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = list.map(b => batchCard(b)).join('');
|
||||
}
|
||||
|
||||
function filterBatches() { renderGarden(); }
|
||||
|
||||
// ===== Settings =====
|
||||
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';
|
||||
} catch (e) {
|
||||
toast('Failed to load settings: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
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',
|
||||
};
|
||||
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 `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Plant Name *</label>
|
||||
<input type="text" id="f-name" class="form-input" value="${esc(v.name||'')}" placeholder="e.g. Tomato" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Variety Name</label>
|
||||
<input type="text" id="f-variety-name" class="form-input" value="${esc(v.variety_name||'')}" placeholder="e.g. Roma" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Category</label>
|
||||
<select id="f-category" class="form-select">
|
||||
${['vegetable','herb','flower','fruit'].map(c => `<option value="${c}" ${v.category===c?'selected':''}>${c}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Color Tag</label>
|
||||
<select id="f-color" class="form-select">
|
||||
${colorOpts.map(c => `<option value="${c}" ${v.color===c?'selected':''}>${c}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Weeks before last frost to start indoors</label>
|
||||
<input type="number" id="f-wks-start" class="form-input" value="${v.weeks_to_start||''}" placeholder="e.g. 8" min="0" max="20" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Weeks before last frost to pot up / greenhouse</label>
|
||||
<input type="number" id="f-wks-gh" class="form-input" value="${v.weeks_to_greenhouse||''}" placeholder="e.g. 2" min="0" max="20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Weeks to transplant (+ after frost, - before frost)</label>
|
||||
<input type="number" id="f-wks-garden" class="form-input" value="${v.weeks_to_garden!=null?v.weeks_to_garden:''}" placeholder="e.g. 2 or -2" min="-8" max="12" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Days to germinate</label>
|
||||
<input type="number" id="f-germinate" class="form-input" value="${v.days_to_germinate||7}" min="1" max="60" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sun Requirement</label>
|
||||
<select id="f-sun" class="form-select">
|
||||
${['full_sun','part_shade','full_shade'].map(s => `<option value="${s}" ${v.sun_requirement===s?'selected':''}>${sunLabel(s)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Water Needs</label>
|
||||
<select id="f-water" class="form-select">
|
||||
${['low','medium','high'].map(w => `<option value="${w}" ${v.water_needs===w?'selected':''}>${w}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-check">
|
||||
<input type="checkbox" id="f-direct-sow" ${v.direct_sow_ok?'checked':''} />
|
||||
Can Direct Sow Outdoors
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-check">
|
||||
<input type="checkbox" id="f-frost-tolerant" ${v.frost_tolerant?'checked':''} />
|
||||
Frost Tolerant
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea id="f-notes" class="form-textarea">${esc(v.notes||'')}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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()}
|
||||
<div class="btn-row" style="margin-top:1rem">
|
||||
<button class="btn btn-primary" onclick="App.submitAddVariety()">Add Variety</button>
|
||||
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
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)}
|
||||
<div class="btn-row" style="margin-top:1rem">
|
||||
<button class="btn btn-primary" onclick="App.submitEditVariety(${id})">Save Changes</button>
|
||||
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
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 =>
|
||||
`<option value="${v.id}" ${b.variety_id===v.id?'selected':''}>${v.name}${v.variety_name?' ('+v.variety_name+')':''}</option>`
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Plant Variety *</label>
|
||||
<select id="bf-variety" class="form-select">${varOpts}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Batch Label</label>
|
||||
<input type="text" id="bf-label" class="form-input" value="${esc(b.label||'')}" placeholder="e.g. Main Crop" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Quantity (seeds/plants)</label>
|
||||
<input type="number" id="bf-qty" class="form-input" value="${b.quantity||1}" min="1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status</label>
|
||||
<select id="bf-status" class="form-select">
|
||||
${['planned','germinating','seedling','potted_up','hardening','garden','harvested','failed']
|
||||
.map(s => `<option value="${s}" ${b.status===s?'selected':''}>${statusLabel(s)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sow Date</label>
|
||||
<input type="date" id="bf-sow" class="form-input" value="${b.sow_date||''}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Germination Date</label>
|
||||
<input type="date" id="bf-germ" class="form-input" value="${b.germination_date||''}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Greenhouse / Pot Up Date</label>
|
||||
<input type="date" id="bf-gh" class="form-input" value="${b.greenhouse_date||''}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Garden Transplant Date</label>
|
||||
<input type="date" id="bf-garden" class="form-input" value="${b.garden_date||''}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea id="bf-notes" class="form-textarea">${esc(b.notes||'')}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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()}
|
||||
<div class="btn-row" style="margin-top:1rem">
|
||||
<button class="btn btn-primary" onclick="App.submitAddBatch()">Log Batch</button>
|
||||
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
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)}
|
||||
<div class="btn-row" style="margin-top:1rem">
|
||||
<button class="btn btn-primary" onclick="App.submitEditBatch(${id})">Save Changes</button>
|
||||
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
|
||||
</div>
|
||||
`);
|
||||
} 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, sendTestNotification, sendDailySummary,
|
||||
closeModal: (e) => closeModal(e),
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
Reference in New Issue
Block a user