Initial commit: Sproutly plant tracking app

This commit is contained in:
2026-03-08 23:27:16 -07:00
commit 4f66102bbb
20 changed files with 2643 additions and 0 deletions

630
nginx/html/css/style.css Normal file
View File

@@ -0,0 +1,630 @@
/* ===== CSS Custom Properties ===== */
:root {
--green-darkest: #1b4332;
--green-dark: #2d6a4f;
--green-mid: #40916c;
--green-light: #52b788;
--green-pale: #95d5b2;
--green-ghost: #d8f3dc;
--green-bg: #f0f9f4;
--amber: #f4a261;
--amber-dark: #e76f51;
--yellow: #e9c46a;
--surface: #ffffff;
--surface-alt: #f8fafb;
--border: #e2ece7;
--text: #1b4332;
--text-muted: #5f7a6e;
--text-light: #94b5a5;
--danger: #e63946;
--blue: #2d9cdb;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 2px 8px rgba(27,67,50,0.08);
--shadow-lg: 0 8px 24px rgba(27,67,50,0.12);
--sidebar-w: 220px;
}
/* ===== Reset ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 15px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--green-bg);
color: var(--text);
display: flex;
min-height: 100vh;
line-height: 1.5;
}
a { color: var(--green-mid); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ===== Sidebar ===== */
.sidebar {
width: var(--sidebar-w);
background: var(--green-darkest);
color: #d8f3dc;
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 1.4rem 1.2rem 1rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.brand-icon { font-size: 1.6rem; }
.brand-name {
font-size: 1.25rem;
font-weight: 700;
color: #fff;
letter-spacing: 0.02em;
}
.sidebar-nav {
flex: 1;
padding: 0.8rem 0.6rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.nav-link {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.8rem;
border-radius: var(--radius-sm);
color: var(--green-pale);
font-size: 0.9rem;
font-weight: 500;
transition: background 0.15s, color 0.15s;
text-decoration: none;
}
.nav-link:hover { background: rgba(255,255,255,0.07); color: #fff; text-decoration: none; }
.nav-link.active { background: var(--green-dark); color: #fff; }
.nav-icon { font-size: 1rem; width: 1.2rem; text-align: center; }
.sidebar-footer {
padding: 1rem 1.2rem;
font-size: 0.75rem;
color: var(--text-light);
border-top: 1px solid rgba(255,255,255,0.06);
}
/* ===== Main Content ===== */
.main-content {
margin-left: var(--sidebar-w);
flex: 1;
padding: 2rem;
max-width: 1200px;
}
.page { display: none; }
.page.active { display: block; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.page-title {
font-size: 1.6rem;
font-weight: 700;
color: var(--green-darkest);
}
.page-subtitle {
color: var(--text-muted);
font-size: 0.9rem;
margin-top: 0.2rem;
}
/* ===== Stats Row ===== */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--surface);
border-radius: var(--radius);
padding: 1.2rem 1.4rem;
box-shadow: var(--shadow);
border-left: 4px solid var(--green-light);
}
.stat-card.accent { border-left-color: var(--amber); }
.stat-card.green { border-left-color: var(--green-mid); }
.stat-card.warn { border-left-color: var(--amber-dark); }
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--green-darkest);
line-height: 1;
}
.stat-label {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ===== Section Header ===== */
.section-header {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 0.8rem;
}
.section-header h2 {
font-size: 1.05rem;
font-weight: 600;
color: var(--green-dark);
}
.hint-text { font-size: 0.8rem; color: var(--text-light); }
/* ===== Tasks ===== */
.tasks-group { margin-bottom: 1rem; }
.tasks-group-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.4rem;
padding: 0.2rem 0.6rem;
border-radius: 20px;
display: inline-block;
}
.tasks-group-label.overdue { background: #fde8e8; color: var(--danger); }
.tasks-group-label.today { background: #fff3e0; color: #c85000; }
.tasks-group-label.week { background: #e8f5e9; color: var(--green-dark); }
.tasks-group-label.month { background: var(--green-ghost); color: var(--green-dark); }
.task-list { display: flex; flex-direction: column; gap: 0.5rem; }
.task-card {
background: var(--surface);
border-radius: var(--radius);
padding: 0.75rem 1rem;
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 1rem;
border-left: 4px solid var(--green-light);
transition: transform 0.1s;
}
.task-card:hover { transform: translateX(2px); }
.task-card.overdue { border-left-color: var(--danger); }
.task-card.today { border-left-color: var(--amber); }
.task-card.week { border-left-color: var(--green-light); }
.task-card.month { border-left-color: var(--green-ghost); }
.task-dot {
width: 12px; height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.task-info { flex: 1; }
.task-title { font-weight: 600; font-size: 0.92rem; }
.task-detail { font-size: 0.8rem; color: var(--text-muted); }
.task-date {
font-size: 0.78rem;
color: var(--text-light);
white-space: nowrap;
text-align: right;
}
.task-date.overdue { color: var(--danger); font-weight: 600; }
/* ===== Frost Badge ===== */
.badge-frost {
background: #e3f2fd;
color: #1565c0;
font-size: 0.78rem;
font-weight: 600;
padding: 0.25rem 0.7rem;
border-radius: 20px;
}
/* ===== Timeline ===== */
.timeline-wrapper {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1rem 1.2rem;
overflow-x: auto;
}
.timeline-months {
display: grid;
grid-template-columns: 120px repeat(12, 1fr);
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border);
padding-bottom: 0.4rem;
margin-bottom: 0.4rem;
min-width: 700px;
}
.timeline-months > span:first-child { color: transparent; }
.timeline-row {
display: grid;
grid-template-columns: 120px repeat(365, 1fr);
align-items: center;
height: 26px;
position: relative;
min-width: 700px;
}
.timeline-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
grid-column: 1;
padding-right: 0.5rem;
}
.timeline-bar-area {
grid-column: 2 / -1;
position: relative;
height: 18px;
}
.timeline-segment {
position: absolute;
height: 100%;
border-radius: 3px;
opacity: 0.85;
}
.timeline-today-line {
position: absolute;
top: 0; bottom: 0;
width: 2px;
background: var(--amber-dark);
z-index: 5;
pointer-events: none;
}
.timeline-today-line::after {
content: 'Today';
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 0.65rem;
color: var(--amber-dark);
white-space: nowrap;
font-weight: 700;
}
/* ===== Batch Cards ===== */
.batch-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.batch-card {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
transition: box-shadow 0.2s;
}
.batch-card:hover { box-shadow: var(--shadow-lg); }
.batch-card-top {
height: 6px;
}
.batch-card-body { padding: 1rem; }
.batch-card-name {
font-weight: 600;
font-size: 1rem;
margin-bottom: 0.2rem;
}
.batch-card-variety {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.6rem;
}
.batch-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.8rem;
}
.batch-meta-item {
font-size: 0.75rem;
color: var(--text-muted);
background: var(--green-bg);
padding: 0.15rem 0.5rem;
border-radius: 20px;
}
.batch-card-actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.badge-status {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.2rem 0.6rem;
border-radius: 20px;
margin-bottom: 0.5rem;
}
.status-planned { background: #f1f3f5; color: #6c757d; }
.status-germinating{ background: #fff3cd; color: #856404; }
.status-seedling { background: #d1fae5; color: #065f46; }
.status-potted_up { background: #bbf7d0; color: #14532d; }
.status-hardening { background: #dbeafe; color: #1e3a8a; }
.status-garden { background: #dcfce7; color: #166534; }
.status-harvested { background: #ede9fe; color: #4c1d95; }
.status-failed { background: #fee2e2; color: #991b1b; }
/* ===== Variety Table ===== */
.variety-table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.variety-table th {
background: var(--green-darkest);
color: var(--green-ghost);
text-align: left;
padding: 0.7rem 1rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.variety-table td {
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.88rem;
vertical-align: middle;
}
.variety-table tr:last-child td { border-bottom: none; }
.variety-table tr:hover td { background: var(--green-bg); }
.variety-color-dot {
display: inline-block;
width: 10px; height: 10px;
border-radius: 50%;
margin-right: 0.4rem;
vertical-align: middle;
}
.weeks-chip {
font-size: 0.75rem;
background: var(--green-ghost);
color: var(--green-dark);
padding: 0.1rem 0.5rem;
border-radius: 20px;
white-space: nowrap;
}
/* ===== Buttons ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.5rem 1.1rem;
border-radius: var(--radius-sm);
border: none;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
white-space: nowrap;
}
.btn:hover { opacity: 0.88; transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn-primary { background: var(--green-dark); color: #fff; }
.btn-secondary { background: var(--green-ghost); color: var(--green-dark); }
.btn-danger { background: #fee2e2; color: var(--danger); }
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.78rem; }
.btn-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
/* ===== Filter Bar ===== */
.filter-bar {
display: flex;
gap: 0.8rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-input, .select-input {
padding: 0.5rem 0.8rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.88rem;
background: var(--surface);
color: var(--text);
outline: none;
transition: border-color 0.15s;
}
.search-input { min-width: 220px; flex: 1; }
.search-input:focus, .select-input:focus { border-color: var(--green-light); }
/* ===== Settings ===== */
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.settings-card {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.4rem;
}
.settings-section-title {
font-size: 1rem;
font-weight: 700;
color: var(--green-dark);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.settings-desc {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.settings-status {
font-size: 0.85rem;
color: var(--green-mid);
margin-left: 1rem;
}
/* ===== Forms ===== */
.form-group { margin-bottom: 1rem; }
.form-label {
display: block;
font-size: 0.83rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.3rem;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 0.55rem 0.8rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.88rem;
background: var(--surface);
color: var(--text);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
font-family: inherit;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: var(--green-light);
box-shadow: 0 0 0 3px rgba(82,183,136,0.15);
}
.form-textarea { resize: vertical; min-height: 80px; }
.form-hint { font-size: 0.76rem; color: var(--text-light); margin-top: 0.25rem; display: block; }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-check {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.88rem;
cursor: pointer;
}
/* ===== Modal ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(27,67,50,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-overlay.hidden { display: none; }
.modal {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 580px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.2rem 1.4rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 { font-size: 1.05rem; font-weight: 700; color: var(--green-darkest); }
.modal-close {
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
color: var(--text-muted);
line-height: 1;
padding: 0 0.3rem;
}
.modal-close:hover { color: var(--danger); }
#modal-body { padding: 1.4rem; }
/* ===== Empty State ===== */
.empty-state {
padding: 2.5rem 1rem;
text-align: center;
color: var(--text-light);
font-size: 0.9rem;
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* ===== Toast ===== */
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--green-darkest);
color: #fff;
padding: 0.7rem 1.2rem;
border-radius: var(--radius-sm);
font-size: 0.88rem;
z-index: 2000;
box-shadow: var(--shadow-lg);
animation: fadeUp 0.3s ease;
}
.toast.hidden { display: none; }
.toast.error { background: var(--danger); }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===== Category Badges ===== */
.cat-badge {
font-size: 0.72rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: 20px;
text-transform: capitalize;
}
.cat-vegetable { background: #dcfce7; color: #166534; }
.cat-herb { background: #d1fae5; color: #065f46; }
.cat-flower { background: #fce7f3; color: #9d174d; }
.cat-fruit { background: #fee2e2; color: #991b1b; }
/* ===== Responsive ===== */
@media (max-width: 900px) {
.stats-row { grid-template-columns: 1fr 1fr; }
.settings-grid { grid-template-columns: 1fr; }
.form-row { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
:root { --sidebar-w: 0px; }
.sidebar { display: none; }
.main-content { margin-left: 0; padding: 1rem; }
.stats-row { grid-template-columns: 1fr 1fr; }
}

216
nginx/html/index.html Normal file
View File

@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sproutly</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<aside class="sidebar">
<div class="sidebar-brand">
<span class="brand-icon">&#127807;</span>
<span class="brand-name">Sproutly</span>
</div>
<nav class="sidebar-nav">
<a href="#dashboard" class="nav-link active" data-page="dashboard">
<span class="nav-icon">&#128200;</span> Dashboard
</a>
<a href="#varieties" class="nav-link" data-page="varieties">
<span class="nav-icon">&#127793;</span> Seed Library
</a>
<a href="#garden" class="nav-link" data-page="garden">
<span class="nav-icon">&#127807;</span> My Garden
</a>
<a href="#settings" class="nav-link" data-page="settings">
<span class="nav-icon">&#9881;</span> Settings
</a>
</nav>
<div class="sidebar-footer">
<span id="sidebar-date"></span>
</div>
</aside>
<main class="main-content">
<!-- DASHBOARD -->
<section id="page-dashboard" class="page active">
<div class="page-header">
<div>
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle" id="dash-subtitle">Loading your garden...</p>
</div>
<button class="btn btn-primary" onclick="App.showAddBatchModal()">+ Log Batch</button>
</div>
<div class="stats-row" id="stats-row">
<div class="stat-card">
<div class="stat-value" id="stat-varieties"></div>
<div class="stat-label">Seed Varieties</div>
</div>
<div class="stat-card accent">
<div class="stat-value" id="stat-active"></div>
<div class="stat-label">Active Batches</div>
</div>
<div class="stat-card green">
<div class="stat-value" id="stat-garden"></div>
<div class="stat-label">In Garden</div>
</div>
<div class="stat-card warn">
<div class="stat-value" id="stat-tasks"></div>
<div class="stat-label">Tasks This Month</div>
</div>
</div>
<div class="section-header">
<h2>Action Needed</h2>
<span class="badge badge-frost" id="frost-badge"></span>
</div>
<div id="tasks-container">
<div class="empty-state">Loading tasks...</div>
</div>
<div class="section-header" style="margin-top:2rem">
<h2>Year Planting Timeline</h2>
<span class="hint-text">Based on your last frost date</span>
</div>
<div class="timeline-wrapper" id="timeline-container">
<div class="empty-state">Configure your last frost date in Settings to see the timeline.</div>
</div>
<div class="section-header" style="margin-top:2rem">
<h2>Active Batches</h2>
</div>
<div class="batch-grid" id="active-batches-container">
<div class="empty-state">No active batches. Start tracking by logging a batch!</div>
</div>
</section>
<!-- SEED LIBRARY -->
<section id="page-varieties" class="page">
<div class="page-header">
<div>
<h1 class="page-title">Seed Library</h1>
<p class="page-subtitle">Manage your plant varieties and growing schedules</p>
</div>
<button class="btn btn-primary" onclick="App.showAddVarietyModal()">+ Add Variety</button>
</div>
<div class="filter-bar">
<input type="text" id="variety-search" class="search-input" placeholder="Search varieties..." oninput="App.filterVarieties()" />
<select id="variety-cat-filter" class="select-input" onchange="App.filterVarieties()">
<option value="">All Categories</option>
<option value="vegetable">Vegetables</option>
<option value="herb">Herbs</option>
<option value="flower">Flowers</option>
<option value="fruit">Fruit</option>
</select>
</div>
<div id="varieties-container">
<div class="empty-state">Loading varieties...</div>
</div>
</section>
<!-- MY GARDEN -->
<section id="page-garden" class="page">
<div class="page-header">
<div>
<h1 class="page-title">My Garden</h1>
<p class="page-subtitle">Track your growing batches from seed to harvest</p>
</div>
<button class="btn btn-primary" onclick="App.showAddBatchModal()">+ Log Batch</button>
</div>
<div class="filter-bar">
<select id="garden-status-filter" class="select-input" onchange="App.filterBatches()">
<option value="">All Statuses</option>
<option value="planned">Planned</option>
<option value="germinating">Germinating</option>
<option value="seedling">Seedling</option>
<option value="potted_up">Potted Up</option>
<option value="hardening">Hardening Off</option>
<option value="garden">In Garden</option>
<option value="harvested">Harvested</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="batch-grid" id="garden-container">
<div class="empty-state">Loading batches...</div>
</div>
</section>
<!-- SETTINGS -->
<section id="page-settings" class="page">
<div class="page-header">
<div>
<h1 class="page-title">Settings</h1>
<p class="page-subtitle">Configure your growing zone and notifications</p>
</div>
</div>
<div class="settings-grid">
<div class="settings-card">
<h3 class="settings-section-title">Growing Zone</h3>
<div class="form-group">
<label class="form-label">Location Name</label>
<input type="text" id="s-location" class="form-input" placeholder="e.g. Backyard Garden" />
</div>
<div class="form-group">
<label class="form-label">Last Spring Frost Date</label>
<input type="date" id="s-last-frost" class="form-input" />
<span class="form-hint">Used to calculate all seed starting dates</span>
</div>
<div class="form-group">
<label class="form-label">First Fall Frost Date</label>
<input type="date" id="s-first-frost" class="form-input" />
<span class="form-hint">Used for fall planting planning</span>
</div>
<div class="form-group">
<label class="form-label">Timezone</label>
<input type="text" id="s-timezone" class="form-input" placeholder="e.g. America/New_York" />
</div>
</div>
<div class="settings-card">
<h3 class="settings-section-title">Ntfy Notifications</h3>
<p class="settings-desc">
Get daily summaries on your phone via <a href="https://ntfy.sh" target="_blank">ntfy.sh</a> (free, open source push notifications).
</p>
<div class="form-group">
<label class="form-label">Ntfy Server</label>
<input type="text" id="s-ntfy-server" class="form-input" placeholder="https://ntfy.sh" />
</div>
<div class="form-group">
<label class="form-label">Ntfy Topic</label>
<input type="text" id="s-ntfy-topic" class="form-input" placeholder="my-garden-alerts" />
<span class="form-hint">Subscribe to this topic in the ntfy app</span>
</div>
<div class="form-group">
<label class="form-label">Daily Summary Time</label>
<input type="time" id="s-notif-time" class="form-input" />
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="App.sendTestNotification()">Send Test</button>
<button class="btn btn-secondary" onclick="App.sendDailySummary()">Send Summary Now</button>
</div>
</div>
</div>
<div style="margin-top:1rem">
<button class="btn btn-primary" onclick="App.saveSettings()">Save Settings</button>
<span id="settings-status" class="settings-status"></span>
</div>
</section>
</main>
<!-- MODALS -->
<div id="modal-overlay" class="modal-overlay hidden" onclick="App.closeModal(event)">
<div class="modal" id="modal-box">
<div class="modal-header">
<h3 id="modal-title">Modal</h3>
<button class="modal-close" onclick="App.closeModal()">&times;</button>
</div>
<div id="modal-body"></div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="/js/app.js"></script>
</body>
</html>

786
nginx/html/js/app.js Normal file
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ===== 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: '&#127793;', pot_up: '&#129716;', transplant: '&#127807;', check_batch: '&#128270;'
}[t.type] || '&#128203;';
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);