Initial commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 22:27:27 -08:00
parent 4387f6df92
commit 492e1fd68f
32 changed files with 2608 additions and 0 deletions

35
nginx/html/404.html Normal file
View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 Not Found — Eggtracker</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.error-center { text-align: center; padding: 5rem 1rem; }
.error-center .code { font-size: 5rem; font-weight: 700; color: var(--border); line-height: 1; }
.error-center p { color: var(--muted); margin: 1rem 0 2rem; }
</style>
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 Eggtracker</a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
<li><a href="/history">History</a></li>
<li><a href="/flock">Flock</a></li>
<li><a href="/budget">Budget</a></li>
</ul>
</nav>
<main class="container">
<div class="error-center">
<div class="code">404</div>
<h1>Page not found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</main>
<script src="/js/api.js"></script>
</body>
</html>

27
nginx/html/50x.html Normal file
View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Error — Eggtracker</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.error-center { text-align: center; padding: 5rem 1rem; }
.error-center .code { font-size: 5rem; font-weight: 700; color: var(--border); line-height: 1; }
.error-center p { color: var(--muted); margin: 1rem 0 2rem; }
</style>
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 Eggtracker</a>
</nav>
<main class="container">
<div class="error-center">
<div class="code">5xx</div>
<h1>Something went wrong</h1>
<p>The server ran into a problem. This usually means the API container is still starting up — wait a moment and try again.</p>
<a href="/" class="btn btn-primary">Try Again</a>
</div>
</main>
</body>
</html>

103
nginx/html/budget.html Normal file
View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Budget — Eggtracker</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
<li><a href="/history">History</a></li>
<li><a href="/flock">Flock</a></li>
<li><a href="/budget">Budget</a></li>
<li><a href="/summary">Summary</a></li>
</ul>
</nav>
<main class="container">
<h1>Budget &amp; Feed Costs</h1>
<div id="msg" class="message"></div>
<!-- Stats: all-time -->
<h2>All-Time</h2>
<div class="stats-grid" style="margin-bottom: 1rem;">
<div class="stat-card"><div class="label">Total Feed Cost</div><div class="value" id="b-cost-total"></div></div>
<div class="stat-card"><div class="label">Total Eggs</div><div class="value" id="b-eggs-total"></div></div>
<div class="stat-card accent"><div class="label">Cost / Egg</div><div class="value" id="b-cpe"></div></div>
<div class="stat-card accent"><div class="label">Cost / Dozen</div><div class="value" id="b-cpd"></div></div>
</div>
<!-- Stats: last 30 days -->
<h2>Last 30 Days</h2>
<div class="stats-grid" style="margin-bottom: 2rem;">
<div class="stat-card"><div class="label">Feed Cost (30d)</div><div class="value" id="b-cost-30d"></div></div>
<div class="stat-card"><div class="label">Eggs (30d)</div><div class="value" id="b-eggs-30d"></div></div>
<div class="stat-card accent"><div class="label">Cost / Egg (30d)</div><div class="value" id="b-cpe-30d"></div></div>
<div class="stat-card accent"><div class="label">Cost / Dozen (30d)</div><div class="value" id="b-cpd-30d"></div></div>
</div>
<!-- Log a feed purchase -->
<div class="card">
<h2>Log Feed Purchase</h2>
<form id="feed-form">
<div class="form-grid">
<div class="form-group">
<label for="date">Date</label>
<input type="date" id="date" required>
</div>
<div class="form-group">
<label for="bags">Number of Bags</label>
<input type="number" id="bags" min="0.01" step="0.01" required placeholder="1">
</div>
<div class="form-group">
<label for="price">Price per Bag ($)</label>
<input type="number" id="price" min="0.01" step="0.01" required placeholder="0.00">
</div>
<div class="form-group">
<label>Total</label>
<input type="text" id="total-display" readonly placeholder="$0.00" style="background:#f7f4ef; color: var(--green); font-weight:600;">
</div>
<div class="form-group span-full">
<label for="notes">Notes (optional)</label>
<textarea id="notes" placeholder="e.g. Brand, store, sale price…"></textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save Purchase</button>
</div>
</div>
</form>
</div>
<!-- Purchase history -->
<h2>Purchase History</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Bags</th>
<th>Price / Bag</th>
<th>Total</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody id="feed-body">
<tr class="empty-row"><td colspan="6">Loading…</td></tr>
</tbody>
<tfoot id="feed-foot"></tfoot>
</table>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/budget.js"></script>
</body>
</html>

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

@@ -0,0 +1,262 @@
/* ── Variables ───────────────────────────────────────────────────────────── */
:root {
--green: #3d6b4f;
--green-dark: #2e5240;
--amber: #d4850a;
--bg: #f7f4ef;
--card-bg: #ffffff;
--border: #ddd6cc;
--text: #2c2c2c;
--muted: #6b6b6b;
--danger: #b83232;
--danger-dark: #962828;
--radius: 8px;
--shadow: 0 2px 8px rgba(0,0,0,0.08);
--nav-h: 56px;
}
/* ── Reset ───────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a { color: var(--green); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── Nav ─────────────────────────────────────────────────────────────────── */
.nav {
height: var(--nav-h);
background: var(--green);
display: flex;
align-items: center;
padding: 0 1.5rem;
gap: 2rem;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.nav-brand {
font-size: 1.2rem;
font-weight: 700;
color: #fff;
white-space: nowrap;
}
.nav-brand:hover { text-decoration: none; }
.nav-links {
display: flex;
gap: 0.25rem;
list-style: none;
}
.nav-links a {
color: rgba(255,255,255,0.8);
padding: 0.4rem 0.75rem;
border-radius: 4px;
font-size: 0.95rem;
transition: background 0.15s, color 0.15s;
}
.nav-links a:hover,
.nav-links a.active {
background: rgba(255,255,255,0.18);
color: #fff;
text-decoration: none;
}
/* ── Layout ──────────────────────────────────────────────────────────────── */
.container {
max-width: 980px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
h1 { font-size: 1.6rem; margin-bottom: 1.5rem; }
h2 { font-size: 1.15rem; margin-bottom: 1rem; color: var(--green-dark); }
/* ── Stat cards ──────────────────────────────────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1rem;
text-align: center;
box-shadow: var(--shadow);
}
.stat-card .label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
margin-bottom: 0.4rem;
}
.stat-card .value {
font-size: 2rem;
font-weight: 700;
color: var(--green);
line-height: 1.1;
}
.stat-card .unit {
font-size: 0.78rem;
color: var(--muted);
margin-top: 0.2rem;
}
.stat-card.accent .value { color: var(--amber); }
/* ── Cards / sections ────────────────────────────────────────────────────── */
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
/* ── Tables ──────────────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
font-size: 0.93rem;
}
thead { background: var(--green); color: #fff; }
th, td { padding: 0.7rem 1rem; text-align: left; }
th { font-weight: 600; font-size: 0.82rem; letter-spacing: 0.04em; }
tbody tr:nth-child(even) { background: #f9f7f4; }
tbody tr:hover { background: #f0ebe3; }
td.notes { color: var(--muted); font-style: italic; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions { white-space: nowrap; }
.empty-row td { text-align: center; color: var(--muted); padding: 2rem; font-style: italic; }
.total-row td { font-weight: 600; background: #ede8e0 !important; }
/* ── Forms ───────────────────────────────────────────────────────────────── */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(175px, 1fr));
gap: 1rem;
align-items: end;
}
.form-group { display: flex; flex-direction: column; gap: 0.35rem; }
.form-group.span-full { grid-column: 1 / -1; }
label { font-size: 0.875rem; font-weight: 500; }
input[type="text"],
input[type="number"],
input[type="date"],
textarea,
select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.93rem;
font-family: inherit;
background: #fff;
color: var(--text);
transition: border-color 0.15s, box-shadow 0.15s;
width: 100%;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--green);
box-shadow: 0 0 0 3px rgba(61,107,79,0.12);
}
textarea { resize: vertical; min-height: 68px; }
/* Inline edit inputs inside table cells */
td input[type="text"],
td input[type="number"],
td input[type="date"] {
padding: 0.3rem 0.5rem;
font-size: 0.88rem;
min-width: 80px;
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.btn {
display: inline-block;
padding: 0.5rem 1.1rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
line-height: 1.4;
}
.btn-primary { background: var(--green); color: #fff; }
.btn-primary:hover { background: var(--green-dark); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: var(--danger-dark); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { background: #ede8e0; }
.btn-sm { padding: 0.28rem 0.65rem; font-size: 0.8rem; }
/* ── Messages ────────────────────────────────────────────────────────────── */
.message {
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.9rem;
display: none;
}
.message.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.message.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.message.visible { display: block; }
/* ── Filter bar ──────────────────────────────────────────────────────────── */
.filter-bar {
display: flex;
gap: 0.75rem;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.filter-bar .form-group { min-width: 140px; }
/* ── Misc ────────────────────────────────────────────────────────────────── */
.text-muted { color: var(--muted); font-size: 0.9rem; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.gap-1 { gap: 0.5rem; }
.flex { display: flex; }
/* ── Mobile nav ──────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.nav { gap: 0.5rem; padding: 0 0.75rem; }
.nav-brand span { display: none; } /* hide text, keep emoji */
.nav-links { overflow-x: auto; scrollbar-width: none; }
.nav-links::-webkit-scrollbar { display: none; }
.nav-links a { padding: 0.4rem 0.55rem; font-size: 0.82rem; white-space: nowrap; }
}

3
nginx/html/favicon.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y=".9em" font-size="90">🥚</text>
</svg>

After

Width:  |  Height:  |  Size: 114 B

86
nginx/html/flock.html Normal file
View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flock — Eggtracker</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
<li><a href="/history">History</a></li>
<li><a href="/flock">Flock</a></li>
<li><a href="/budget">Budget</a></li>
<li><a href="/summary">Summary</a></li>
</ul>
</nav>
<main class="container">
<h1>Flock Management</h1>
<!-- Current flock callout -->
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 240px));">
<div class="stat-card">
<div class="label">Current Flock Size</div>
<div class="value" id="current-count"></div>
<div class="unit">chickens</div>
</div>
<div class="stat-card">
<div class="label">As of</div>
<div class="value" style="font-size:1.1rem;" id="current-date"></div>
</div>
</div>
<!-- Log a change -->
<div class="card">
<h2>Log a Flock Change</h2>
<div id="msg" class="message"></div>
<form id="flock-form">
<div class="form-grid">
<div class="form-group">
<label for="date">Date of Change</label>
<input type="date" id="date" required>
</div>
<div class="form-group">
<label for="count">New Total Count</label>
<input type="number" id="count" min="0" required placeholder="0">
</div>
<div class="form-group span-full">
<label for="notes">Notes (optional)</label>
<textarea id="notes" placeholder="e.g. Added 3 new hens, lost 1 to predator…"></textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save Change</button>
</div>
</div>
</form>
</div>
<!-- Flock history table -->
<h2>Flock History</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Count</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody id="flock-body">
<tr class="empty-row"><td colspan="4">Loading…</td></tr>
</tbody>
</table>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/flock.js"></script>
</body>
</html>

69
nginx/html/history.html Normal file
View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>History — Eggtracker</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
<li><a href="/history">History</a></li>
<li><a href="/flock">Flock</a></li>
<li><a href="/budget">Budget</a></li>
<li><a href="/summary">Summary</a></li>
</ul>
</nav>
<main class="container">
<h1>Egg Collection History</h1>
<div id="msg" class="message"></div>
<!-- Date filter -->
<div class="card">
<div class="filter-bar">
<div class="form-group">
<label for="filter-start">From</label>
<input type="date" id="filter-start">
</div>
<div class="form-group">
<label for="filter-end">To</label>
<input type="date" id="filter-end">
</div>
<button class="btn btn-primary" onclick="loadHistory()">Filter</button>
<button class="btn btn-ghost" onclick="clearFilter()">Clear</button>
</div>
</div>
<div class="section-header">
<span class="text-muted" id="result-count"></span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Eggs</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody id="history-body">
<tr class="empty-row"><td colspan="4">Loading…</td></tr>
</tbody>
<tfoot id="history-foot"></tfoot>
</table>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/history.js"></script>
</body>
</html>

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

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard — Eggtracker</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
<li><a href="/history">History</a></li>
<li><a href="/flock">Flock</a></li>
<li><a href="/budget">Budget</a></li>
<li><a href="/summary">Summary</a></li>
</ul>
</nav>
<main class="container">
<h1>Dashboard</h1>
<div id="msg" class="message"></div>
<!-- Stat cards -->
<div class="stats-grid" id="stats-grid">
<div class="stat-card"><div class="label">Flock Size</div><div class="value" id="s-flock"></div><div class="unit">chickens</div></div>
<div class="stat-card"><div class="label">Last 7 Days</div><div class="value" id="s-7d"></div><div class="unit">eggs</div></div>
<div class="stat-card"><div class="label">Last 30 Days</div><div class="value" id="s-30d"></div><div class="unit">eggs</div></div>
<div class="stat-card"><div class="label">All-Time Eggs</div><div class="value" id="s-total"></div><div class="unit">eggs</div></div>
<div class="stat-card accent"><div class="label">Avg / Day (30d)</div><div class="value" id="s-avg-day"></div><div class="unit">eggs/day</div></div>
<div class="stat-card accent"><div class="label">Avg / Hen / Day (30d)</div><div class="value" id="s-avg-hen"></div><div class="unit">eggs/hen</div></div>
<div class="stat-card accent"><div class="label">Cost / Egg</div><div class="value" id="s-cpe"></div></div>
<div class="stat-card accent"><div class="label">Cost / Egg (30d)</div><div class="value" id="s-cpe-30d"></div></div>
<div class="stat-card accent"><div class="label">Cost / Dozen</div><div class="value" id="s-cpd"></div></div>
<div class="stat-card accent"><div class="label">Cost / Dozen (30d)</div><div class="value" id="s-cpd-30d"></div></div>
</div>
<!-- Trend chart -->
<div class="card">
<h2>Eggs — Last 30 Days</h2>
<p id="chart-no-data" class="text-muted" style="display:none; padding: 1.5rem 0; text-align:center;">No egg data yet — log some collections to see the chart.</p>
<div id="chart-wrap">
<canvas id="eggs-chart" height="90"></canvas>
</div>
</div>
<!-- Recent collections -->
<div class="section-header">
<h2>Recent Collections</h2>
<a href="/log" class="btn btn-primary btn-sm">+ Log Eggs</a>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Eggs</th>
<th>Notes</th>
</tr>
</thead>
<tbody id="recent-body">
<tr class="empty-row"><td colspan="3">Loading…</td></tr>
</tbody>
</table>
</div>
<p class="text-muted mt-1"><a href="/history">View full history →</a></p>
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/dashboard.js"></script>
</body>
</html>

67
nginx/html/js/api.js Normal file
View File

@@ -0,0 +1,67 @@
// api.js — shared fetch helpers and utilities used by every page
const API = {
async _fetch(url, options = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `Request failed (${res.status})`);
}
if (res.status === 204) return null; // DELETE returns No Content
return res.json();
},
get: (url) => API._fetch(url),
post: (url, data) => API._fetch(url, { method: 'POST', body: JSON.stringify(data) }),
put: (url, data) => API._fetch(url, { method: 'PUT', body: JSON.stringify(data) }),
del: (url) => API._fetch(url, { method: 'DELETE' }),
};
// Show a timed success or error message inside a .message element
function showMessage(el, text, type = 'success') {
el.textContent = text;
el.className = `message ${type} visible`;
setTimeout(() => { el.className = 'message'; }, 4000);
}
// Set an input[type=date] to today's date (using local time, not UTC)
function setToday(inputEl) {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
inputEl.value = `${y}-${m}-${d}`;
}
// Format YYYY-MM-DD → MM/DD/YYYY for display
function fmtDate(str) {
if (!str) return '—';
const [y, m, d] = str.split('-');
return `${m}/${d}/${y}`;
}
// Format a number as a dollar amount
function fmtMoney(val) {
if (val == null || val === '' || isNaN(Number(val))) return '—';
return '$' + Number(val).toFixed(2);
}
// Format a small decimal (cost per egg) with 4 decimal places
function fmtMoneyFull(val) {
if (val == null || val === '' || isNaN(Number(val))) return '—';
return '$' + Number(val).toFixed(4);
}
// Highlight the nav link that matches the current page
function highlightNav() {
const path = window.location.pathname.replace(/\.html$/, '').replace(/\/$/, '') || '/';
document.querySelectorAll('.nav-links a').forEach(a => {
const href = a.getAttribute('href').replace(/\/$/, '') || '/';
if (href === path) a.classList.add('active');
});
}
document.addEventListener('DOMContentLoaded', highlightNav);

163
nginx/html/js/budget.js Normal file
View File

@@ -0,0 +1,163 @@
let feedData = [];
async function loadBudget() {
const msg = document.getElementById('msg');
try {
const [stats, purchases] = await Promise.all([
API.get('/api/stats/budget'),
API.get('/api/feed'),
]);
// All-time stats
document.getElementById('b-cost-total').textContent = fmtMoney(stats.total_feed_cost);
document.getElementById('b-eggs-total').textContent = stats.total_eggs_alltime;
document.getElementById('b-cpe').textContent = fmtMoneyFull(stats.cost_per_egg);
document.getElementById('b-cpd').textContent = fmtMoney(stats.cost_per_dozen);
// Last 30 days
document.getElementById('b-cost-30d').textContent = fmtMoney(stats.total_feed_cost_30d);
document.getElementById('b-eggs-30d').textContent = stats.total_eggs_30d;
document.getElementById('b-cpe-30d').textContent = fmtMoneyFull(stats.cost_per_egg_30d);
document.getElementById('b-cpd-30d').textContent = fmtMoney(stats.cost_per_dozen_30d);
feedData = purchases;
renderTable();
} catch (err) {
showMessage(msg, `Failed to load budget data: ${err.message}`, 'error');
}
}
function renderTable() {
const tbody = document.getElementById('feed-body');
const tfoot = document.getElementById('feed-foot');
if (feedData.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">No feed purchases logged yet.</td></tr>';
tfoot.innerHTML = '';
return;
}
tbody.innerHTML = feedData.map(e => {
const total = (parseFloat(e.bags) * parseFloat(e.price_per_bag)).toFixed(2);
return `
<tr data-id="${e.id}">
<td>${fmtDate(e.date)}</td>
<td>${parseFloat(e.bags)}</td>
<td>${fmtMoney(e.price_per_bag)}</td>
<td>${fmtMoney(total)}</td>
<td class="notes">${e.notes || ''}</td>
<td class="actions">
<button class="btn btn-ghost btn-sm" onclick="startEdit(${e.id})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})">Delete</button>
</td>
</tr>
`;
}).join('');
// Total row
const grandTotal = feedData.reduce((sum, e) => sum + parseFloat(e.bags) * parseFloat(e.price_per_bag), 0);
tfoot.innerHTML = `
<tr class="total-row">
<td colspan="3">Total</td>
<td>${fmtMoney(grandTotal)}</td>
<td colspan="2">${feedData.length} purchases</td>
</tr>
`;
}
function startEdit(id) {
const entry = feedData.find(e => e.id === id);
const row = document.querySelector(`tr[data-id="${id}"]`);
row.innerHTML = `
<td><input type="date" value="${entry.date}"></td>
<td><input type="number" min="0.01" step="0.01" value="${parseFloat(entry.bags)}" style="width:80px;"></td>
<td><input type="number" min="0.01" step="0.01" value="${parseFloat(entry.price_per_bag)}" style="width:90px;"></td>
<td>—</td>
<td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td>
<td class="actions">
<button class="btn btn-primary btn-sm" onclick="saveEdit(${id})">Save</button>
<button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button>
</td>
`;
}
async function saveEdit(id) {
const msg = document.getElementById('msg');
const row = document.querySelector(`tr[data-id="${id}"]`);
const inputs = row.querySelectorAll('input');
const [dateInput, bagsInput, priceInput, notesInput] = inputs;
try {
const updated = await API.put(`/api/feed/${id}`, {
date: dateInput.value,
bags: parseFloat(bagsInput.value),
price_per_bag: parseFloat(priceInput.value),
notes: notesInput.value.trim() || null,
});
const idx = feedData.findIndex(e => e.id === id);
feedData[idx] = updated;
renderTable();
loadBudget();
showMessage(msg, 'Purchase updated.');
} catch (err) {
showMessage(msg, `Error: ${err.message}`, 'error');
}
}
async function deleteEntry(id) {
if (!confirm('Delete this purchase?')) return;
const msg = document.getElementById('msg');
try {
await API.del(`/api/feed/${id}`);
feedData = feedData.filter(e => e.id !== id);
renderTable();
loadBudget();
showMessage(msg, 'Purchase deleted.');
} catch (err) {
showMessage(msg, `Error: ${err.message}`, 'error');
}
}
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('feed-form');
const msg = document.getElementById('msg');
const bagsInput = document.getElementById('bags');
const priceInput = document.getElementById('price');
const totalDisplay = document.getElementById('total-display');
setToday(document.getElementById('date'));
// Live total calculation
function updateTotal() {
const bags = parseFloat(bagsInput.value) || 0;
const price = parseFloat(priceInput.value) || 0;
totalDisplay.value = bags && price ? fmtMoney(bags * price) : '';
}
bagsInput.addEventListener('input', updateTotal);
priceInput.addEventListener('input', updateTotal);
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
date: document.getElementById('date').value,
bags: parseFloat(bagsInput.value),
price_per_bag: parseFloat(priceInput.value),
notes: document.getElementById('notes').value.trim() || null,
};
try {
await API.post('/api/feed', data);
showMessage(msg, 'Purchase saved!');
form.reset();
totalDisplay.value = '';
setToday(document.getElementById('date'));
loadBudget();
} catch (err) {
showMessage(msg, `Error: ${err.message}`, 'error');
}
});
loadBudget();
});

127
nginx/html/js/dashboard.js Normal file
View File

@@ -0,0 +1,127 @@
let eggChart = null;
function buildChart(eggs) {
const today = new Date();
const labels = [];
const data = [];
// Build a lookup map from date string → egg count
const eggMap = {};
eggs.forEach(e => { eggMap[e.date] = e.eggs; });
// Generate the last 30 days in chronological order
for (let i = 29; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const dy = String(d.getDate()).padStart(2, '0');
const dateStr = `${y}-${mo}-${dy}`;
labels.push(`${mo}/${dy}`);
data.push(eggMap[dateStr] ?? 0);
}
const ctx = document.getElementById('eggs-chart').getContext('2d');
const chartWrap = document.getElementById('chart-wrap');
const noDataMsg = document.getElementById('chart-no-data');
const hasData = data.some(v => v > 0);
if (!hasData) {
if (chartWrap) chartWrap.style.display = 'none';
if (noDataMsg) noDataMsg.style.display = 'block';
return;
}
if (chartWrap) chartWrap.style.display = 'block';
if (noDataMsg) noDataMsg.style.display = 'none';
if (eggChart) eggChart.destroy(); // prevent duplicate charts on re-render
eggChart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
data,
borderColor: '#3d6b4f',
backgroundColor: 'rgba(61,107,79,0.08)',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
fill: true,
tension: 0.3,
}],
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.parsed.y} eggs`,
},
},
},
scales: {
y: {
beginAtZero: true,
suggestedMax: 5,
ticks: { stepSize: 1, precision: 0 },
},
x: {
ticks: { maxTicksLimit: 10 },
},
},
},
});
}
async function loadDashboard() {
const msg = document.getElementById('msg');
try {
// Fetch stats and recent eggs in parallel
const [stats, budget, eggs] = await Promise.all([
API.get('/api/stats/dashboard'),
API.get('/api/stats/budget'),
API.get('/api/eggs'),
]);
// Populate stat cards
document.getElementById('s-flock').textContent = stats.current_flock ?? '—';
document.getElementById('s-total').textContent = stats.total_eggs_alltime;
document.getElementById('s-7d').textContent = stats.total_eggs_7d;
document.getElementById('s-30d').textContent = stats.total_eggs_30d;
document.getElementById('s-avg-day').textContent = stats.avg_eggs_per_day_30d ?? '—';
document.getElementById('s-avg-hen').textContent = stats.avg_eggs_per_hen_day_30d ?? '—';
document.getElementById('s-cpe').textContent = fmtMoneyFull(budget.cost_per_egg);
document.getElementById('s-cpe-30d').textContent = fmtMoneyFull(budget.cost_per_egg_30d);
document.getElementById('s-cpd').textContent = fmtMoney(budget.cost_per_dozen);
document.getElementById('s-cpd-30d').textContent = fmtMoney(budget.cost_per_dozen_30d);
// Trend chart — uses all fetched eggs, filtered to last 30 days inside buildChart
buildChart(eggs);
// Recent 10 collections
const tbody = document.getElementById('recent-body');
const recent = eggs.slice(0, 10);
if (recent.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="3">No eggs logged yet. <a href="/log">Log your first collection →</a></td></tr>';
return;
}
tbody.innerHTML = recent.map(e => `
<tr>
<td>${fmtDate(e.date)}</td>
<td>${e.eggs}</td>
<td class="notes">${e.notes || ''}</td>
</tr>
`).join('');
} catch (err) {
showMessage(msg, `Failed to load dashboard: ${err.message}`, 'error');
}
}
document.addEventListener('DOMContentLoaded', loadDashboard);

119
nginx/html/js/flock.js Normal file
View File

@@ -0,0 +1,119 @@
let flockData = [];
async function loadFlock() {
const msg = document.getElementById('msg');
try {
const [current, history] = await Promise.all([
API.get('/api/flock/current'),
API.get('/api/flock'),
]);
document.getElementById('current-count').textContent = current?.chicken_count ?? '—';
document.getElementById('current-date').textContent = current ? fmtDate(current.date) : 'No data';
flockData = history;
renderTable();
} catch (err) {
showMessage(msg, `Failed to load flock data: ${err.message}`, 'error');
}
}
function renderTable() {
const tbody = document.getElementById('flock-body');
if (flockData.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="4">No flock history yet.</td></tr>';
return;
}
tbody.innerHTML = flockData.map(e => `
<tr data-id="${e.id}">
<td>${fmtDate(e.date)}</td>
<td>${e.chicken_count}</td>
<td class="notes">${e.notes || ''}</td>
<td class="actions">
<button class="btn btn-ghost btn-sm" onclick="startEdit(${e.id})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})">Delete</button>
</td>
</tr>
`).join('');
}
function startEdit(id) {
const entry = flockData.find(e => e.id === id);
const row = document.querySelector(`tr[data-id="${id}"]`);
row.innerHTML = `
<td><input type="date" value="${entry.date}"></td>
<td><input type="number" min="0" value="${entry.chicken_count}" style="width:80px;"></td>
<td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td>
<td class="actions">
<button class="btn btn-primary btn-sm" onclick="saveEdit(${id})">Save</button>
<button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button>
</td>
`;
}
async function saveEdit(id) {
const msg = document.getElementById('msg');
const row = document.querySelector(`tr[data-id="${id}"]`);
const [dateInput, countInput, notesInput] = row.querySelectorAll('input');
try {
const updated = await API.put(`/api/flock/${id}`, {
date: dateInput.value,
chicken_count: parseInt(countInput.value, 10),
notes: notesInput.value.trim() || null,
});
const idx = flockData.findIndex(e => e.id === id);
flockData[idx] = updated;
renderTable();
loadFlock(); // refresh current-flock callout
showMessage(msg, 'Entry updated.');
} catch (err) {
showMessage(msg, `Error: ${err.message}`, 'error');
}
}
async function deleteEntry(id) {
if (!confirm('Delete this flock entry?')) return;
const msg = document.getElementById('msg');
try {
await API.del(`/api/flock/${id}`);
flockData = flockData.filter(e => e.id !== id);
renderTable();
loadFlock();
showMessage(msg, 'Entry deleted.');
} catch (err) {
showMessage(msg, `Error: ${err.message}`, 'error');
}
}
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('flock-form');
const msg = document.getElementById('msg');
setToday(document.getElementById('date'));
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
date: document.getElementById('date').value,
chicken_count: parseInt(document.getElementById('count').value, 10),
notes: document.getElementById('notes').value.trim() || null,
};
try {
await API.post('/api/flock', data);
showMessage(msg, 'Flock change saved!');
form.reset();
setToday(document.getElementById('date'));
loadFlock();
} catch (err) {
showMessage(msg, `Error: ${err.message}`, 'error');
}
});
loadFlock();
});

118
nginx/html/js/history.js Normal file
View File

@@ -0,0 +1,118 @@
let currentData = [];
async function loadHistory() {
const tbody = document.getElementById('history-body');
const tfoot = document.getElementById('history-foot');
const msg = document.getElementById('msg');
const start = document.getElementById('filter-start').value;
const end = document.getElementById('filter-end').value;
let url = '/api/eggs';
const params = new URLSearchParams();
if (start) params.set('start', start);
if (end) params.set('end', end);
if ([...params].length) url += '?' + params.toString();
try {
currentData = await API.get(url);
if (currentData.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="4">No entries found.</td></tr>';
tfoot.innerHTML = '';
return;
}
renderTable();
} catch (err) {
showMessage(msg, `Failed to load history: ${err.message}`, 'error');
}
}
function renderTable() {
const tbody = document.getElementById('history-body');
const tfoot = document.getElementById('history-foot');
// Update result count label
const total = currentData.reduce((sum, e) => sum + e.eggs, 0);
const countEl = document.getElementById('result-count');
if (countEl) countEl.textContent = `${currentData.length} entries · ${total} eggs`;
tbody.innerHTML = currentData.map(e => `
<tr data-id="${e.id}">
<td>${fmtDate(e.date)}</td>
<td>${e.eggs}</td>
<td class="notes">${e.notes || ''}</td>
<td class="actions">
<button class="btn btn-ghost btn-sm" onclick="startEdit(${e.id})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})">Delete</button>
</td>
</tr>
`).join('');
// Total row in footer
tfoot.innerHTML = `
<tr class="total-row">
<td colspan="1">Total</td>
<td>${total}</td>
<td colspan="2">${currentData.length} entries</td>
</tr>
`;
}
function startEdit(id) {
const entry = currentData.find(e => e.id === id);
const row = document.querySelector(`tr[data-id="${id}"]`);
row.innerHTML = `
<td><input type="date" value="${entry.date}"></td>
<td><input type="number" min="0" value="${entry.eggs}" style="width:80px;"></td>
<td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td>
<td class="actions">
<button class="btn btn-primary btn-sm" onclick="saveEdit(${id})">Save</button>
<button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button>
</td>
`;
}
async function saveEdit(id) {
const msg = document.getElementById('msg');
const row = document.querySelector(`tr[data-id="${id}"]`);
const [dateInput, eggsInput, notesInput] = row.querySelectorAll('input');
try {
const updated = await API.put(`/api/eggs/${id}`, {
date: dateInput.value,
eggs: parseInt(eggsInput.value, 10),
notes: notesInput.value.trim() || null,
});
// Update local data and re-render
const idx = currentData.findIndex(e => e.id === id);
currentData[idx] = updated;
renderTable();
showMessage(msg, 'Entry updated.');
} catch (err) {
showMessage(msg, `Error: ${err.message}`, 'error');
}
}
async function deleteEntry(id) {
if (!confirm('Delete this entry?')) return;
const msg = document.getElementById('msg');
try {
await API.del(`/api/eggs/${id}`);
currentData = currentData.filter(e => e.id !== id);
renderTable();
showMessage(msg, 'Entry deleted.');
} catch (err) {
showMessage(msg, `Error: ${err.message}`, 'error');
}
}
function clearFilter() {
document.getElementById('filter-start').value = '';
document.getElementById('filter-end').value = '';
loadHistory();
}
document.addEventListener('DOMContentLoaded', loadHistory);

52
nginx/html/js/log.js Normal file
View File

@@ -0,0 +1,52 @@
async function loadRecent() {
const tbody = document.getElementById('recent-body');
try {
const eggs = await API.get('/api/eggs');
const recent = eggs.slice(0, 7);
if (recent.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="3">No entries yet.</td></tr>';
return;
}
tbody.innerHTML = recent.map(e => `
<tr>
<td>${fmtDate(e.date)}</td>
<td>${e.eggs}</td>
<td class="notes">${e.notes || ''}</td>
</tr>
`).join('');
} catch (err) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="3">Could not load recent entries.</td></tr>';
}
}
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('log-form');
const msg = document.getElementById('msg');
// Default date to today
setToday(document.getElementById('date'));
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
date: document.getElementById('date').value,
eggs: parseInt(document.getElementById('eggs').value, 10),
notes: document.getElementById('notes').value.trim() || null,
};
try {
await API.post('/api/eggs', data);
showMessage(msg, 'Entry saved!');
form.reset();
setToday(document.getElementById('date'));
loadRecent();
} catch (err) {
showMessage(msg, `Error: ${err.message}`, 'error');
}
});
loadRecent();
});

366
nginx/html/js/summary.js Normal file
View File

@@ -0,0 +1,366 @@
let monthlyChart = null;
function buildChart(rows) {
const chartWrap = document.getElementById('chart-wrap');
const noDataMsg = document.getElementById('chart-no-data');
if (rows.length === 0) {
chartWrap.style.display = 'none';
noDataMsg.style.display = 'block';
return;
}
chartWrap.style.display = 'block';
noDataMsg.style.display = 'none';
// Show up to last 24 months, oldest → newest for the chart
const display = [...rows].reverse().slice(-24);
const labels = display.map(r => r.month_label);
const data = display.map(r => r.total_eggs);
const ctx = document.getElementById('monthly-chart').getContext('2d');
if (monthlyChart) monthlyChart.destroy();
monthlyChart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
data,
backgroundColor: 'rgba(61,107,79,0.75)',
borderColor: '#3d6b4f',
borderWidth: 1,
borderRadius: 4,
}],
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.parsed.y} eggs`,
},
},
},
scales: {
y: {
beginAtZero: true,
suggestedMax: 10,
ticks: { stepSize: 1, precision: 0 },
},
},
},
});
}
async function loadSummary() {
const msg = document.getElementById('msg');
const tbody = document.getElementById('summary-body');
try {
const rows = await API.get('/api/stats/monthly');
buildChart(rows);
if (rows.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="9">No data yet.</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => `
<tr>
<td><strong>${r.month_label}</strong></td>
<td>${r.total_eggs}</td>
<td>${r.days_logged}</td>
<td>${r.avg_eggs_per_day ?? '—'}</td>
<td>${r.flock_at_month_end ?? '—'}</td>
<td>${r.avg_eggs_per_hen_per_day ?? '—'}</td>
<td>${fmtMoney(r.feed_cost)}</td>
<td>${fmtMoneyFull(r.cost_per_egg)}</td>
<td>${fmtMoney(r.cost_per_dozen)}</td>
</tr>
`).join('');
} catch (err) {
showMessage(msg, `Failed to load summary: ${err.message}`, 'error');
}
}
document.addEventListener('DOMContentLoaded', loadSummary);
// ── CSV Export ────────────────────────────────────────────────────────────────
function csvEscape(str) {
return `"${String(str == null ? '' : str).replace(/"/g, '""')}"`;
}
async function exportCSV() {
const msg = document.getElementById('msg');
try {
const [eggsData, flockAll, feedData] = await Promise.all([
API.get('/api/eggs'),
API.get('/api/flock'),
API.get('/api/feed'),
]);
if (eggsData.length === 0 && flockAll.length === 0 && feedData.length === 0) {
showMessage(msg, 'No data to export.', 'error');
return;
}
// Sort flock ascending for effective-count lookup
const flockSorted = [...flockAll].sort((a, b) => a.date.localeCompare(b.date));
function effectiveFlockAt(dateStr) {
let result = null;
for (const f of flockSorted) {
if (f.date <= dateStr) result = f;
else break;
}
return result;
}
// Date-keyed lookups
const eggsByDate = Object.fromEntries(eggsData.map(e => [e.date, e]));
const flockByDate = Object.fromEntries(flockAll.map(f => [f.date, f]));
const feedByDate = {};
for (const f of feedData) {
if (!feedByDate[f.date]) feedByDate[f.date] = [];
feedByDate[f.date].push(f);
}
// Union of all dates
const allDates = [...new Set([
...Object.keys(eggsByDate),
...Object.keys(flockByDate),
...Object.keys(feedByDate),
])].sort((a, b) => b.localeCompare(a));
const csvRows = [[
'Date', 'Eggs Collected', 'Egg Notes',
'Flock Size', 'Flock Notes',
'Feed Bags', 'Feed Price/Bag', 'Feed Total', 'Feed Notes',
]];
for (const dateStr of allDates) {
const egg = eggsByDate[dateStr];
const feeds = feedByDate[dateStr] || [];
const flock = effectiveFlockAt(dateStr);
const flockChg = flockByDate[dateStr];
const flockCount = flock ? flock.chicken_count : '';
const flockNotes = flockChg ? csvEscape(flockChg.notes) : '';
if (feeds.length === 0) {
csvRows.push([
dateStr,
egg ? egg.eggs : '',
egg ? csvEscape(egg.notes) : '',
flockCount,
flockNotes,
'', '', '', '',
]);
} else {
feeds.forEach((feed, i) => {
const total = (parseFloat(feed.bags) * parseFloat(feed.price_per_bag)).toFixed(2);
csvRows.push([
dateStr,
i === 0 && egg ? egg.eggs : '',
i === 0 && egg ? csvEscape(egg.notes) : '',
i === 0 ? flockCount : '',
i === 0 ? flockNotes : '',
parseFloat(feed.bags),
parseFloat(feed.price_per_bag),
total,
csvEscape(feed.notes),
]);
});
}
}
const csv = csvRows.map(r => r.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const now = new Date();
const fileDate = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
a.download = `egg-tracker-${fileDate}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
showMessage(msg, `Export failed: ${err.message}`, 'error');
}
}
// ── CSV Import ────────────────────────────────────────────────────────────────
function parseCSVText(text) {
const rows = [];
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
const fields = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
field += '"';
i++;
} else if (ch === '"') {
inQuotes = false;
} else {
field += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
fields.push(field);
field = '';
} else {
field += ch;
}
}
fields.push(field);
rows.push(fields);
}
return rows;
}
async function handleImportFile(input) {
const file = input.files[0];
if (!file) return;
input.value = ''; // allow re-selecting the same file
const msg = document.getElementById('msg');
let text;
try {
text = await file.text();
} catch (err) {
showMessage(msg, `Could not read file: ${err.message}`, 'error');
return;
}
const rows = parseCSVText(text);
if (rows.length < 2) {
showMessage(msg, 'CSV has no data rows.', 'error');
return;
}
const headers = rows[0].map(h => h.trim());
const expected = [
'Date', 'Eggs Collected', 'Egg Notes',
'Flock Size', 'Flock Notes',
'Feed Bags', 'Feed Price/Bag', 'Feed Total', 'Feed Notes',
];
const missing = expected.filter(h => !headers.includes(h));
if (missing.length > 0) {
showMessage(msg, `CSV is missing columns: ${missing.join(', ')}`, 'error');
return;
}
const idx = Object.fromEntries(headers.map((h, i) => [h, i]));
// Sort ascending so flock-change detection is chronologically correct
const dataRows = rows.slice(1)
.filter(r => r.length > 1)
.sort((a, b) => (a[idx['Date']] || '').localeCompare(b[idx['Date']] || ''));
let eggsCreated = 0, eggsSkipped = 0;
let flockCreated = 0;
let feedCreated = 0;
let errors = 0;
let lastFlockSize = null;
let lastFlockDate = null;
for (const row of dataRows) {
const get = col => (row[idx[col]] ?? '').trim();
const date = get('Date');
const eggsStr = get('Eggs Collected');
const eggNotes = get('Egg Notes');
const flockStr = get('Flock Size');
const flockNotes = get('Flock Notes');
const bagsStr = get('Feed Bags');
const priceStr = get('Feed Price/Bag');
const feedNotes = get('Feed Notes');
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
// ── Egg entry ────────────────────────────────────────────────────────
if (eggsStr !== '' && !isNaN(parseInt(eggsStr, 10))) {
try {
await API.post('/api/eggs', {
date,
eggs: parseInt(eggsStr, 10),
notes: eggNotes || null,
});
eggsCreated++;
} catch (err) {
if (err.message.toLowerCase().includes('already exists')) {
eggsSkipped++;
} else {
errors++;
}
}
}
// ── Flock entry ──────────────────────────────────────────────────────
if (flockStr !== '' && !isNaN(parseInt(flockStr, 10))) {
const flockSize = parseInt(flockStr, 10);
const sizeChanged = flockSize !== lastFlockSize;
const hasNotes = flockNotes !== '';
if (date !== lastFlockDate && (sizeChanged || hasNotes)) {
try {
await API.post('/api/flock', {
date,
chicken_count: flockSize,
notes: flockNotes || null,
});
flockCreated++;
lastFlockSize = flockSize;
lastFlockDate = date;
} catch (err) {
errors++;
}
}
}
// ── Feed entry ───────────────────────────────────────────────────────
if (bagsStr !== '' && priceStr !== '' &&
!isNaN(parseFloat(bagsStr)) && !isNaN(parseFloat(priceStr))) {
try {
await API.post('/api/feed', {
date,
bags: parseFloat(bagsStr),
price_per_bag: parseFloat(priceStr),
notes: feedNotes || null,
});
feedCreated++;
} catch (err) {
errors++;
}
}
}
const parts = [];
if (eggsCreated) parts.push(`${eggsCreated} egg ${eggsCreated === 1 ? 'entry' : 'entries'}`);
if (eggsSkipped) parts.push(`${eggsSkipped} skipped (duplicate date)`);
if (flockCreated) parts.push(`${flockCreated} flock ${flockCreated === 1 ? 'change' : 'changes'}`);
if (feedCreated) parts.push(`${feedCreated} feed ${feedCreated === 1 ? 'purchase' : 'purchases'}`);
const summary = parts.length ? `Imported: ${parts.join(', ')}.` : 'Nothing new to import.';
const errNote = errors > 0 ? ` (${errors} row${errors === 1 ? '' : 's'} failed)` : '';
showMessage(msg, summary + errNote, errors > 0 ? 'error' : 'success');
loadSummary();
}

72
nginx/html/log.html Normal file
View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log Eggs — Eggtracker</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
<li><a href="/history">History</a></li>
<li><a href="/flock">Flock</a></li>
<li><a href="/budget">Budget</a></li>
<li><a href="/summary">Summary</a></li>
</ul>
</nav>
<main class="container">
<h1>Log Eggs</h1>
<div class="card">
<div id="msg" class="message"></div>
<form id="log-form">
<div class="form-grid">
<div class="form-group">
<label for="date">Date</label>
<input type="date" id="date" required>
</div>
<div class="form-group">
<label for="eggs">Eggs Collected</label>
<input type="number" id="eggs" min="0" required placeholder="0">
</div>
<div class="form-group span-full">
<label for="notes">Notes (optional)</label>
<textarea id="notes" placeholder="Anything worth noting…"></textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save Entry</button>
</div>
</div>
</form>
</div>
<div class="section-header">
<h2>Recent Entries</h2>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Eggs</th>
<th>Notes</th>
</tr>
</thead>
<tbody id="recent-body">
<tr class="empty-row"><td colspan="3">Loading…</td></tr>
</tbody>
</table>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/log.js"></script>
</body>
</html>

74
nginx/html/summary.html Normal file
View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Summary — Eggtracker</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
<li><a href="/history">History</a></li>
<li><a href="/flock">Flock</a></li>
<li><a href="/budget">Budget</a></li>
<li><a href="/summary">Summary</a></li>
</ul>
</nav>
<main class="container">
<h1>Monthly Summary</h1>
<div id="msg" class="message"></div>
<div class="section-header" style="margin-bottom:1rem;">
<span></span>
<div style="display:flex;gap:0.5rem;">
<button class="btn btn-ghost btn-sm" onclick="exportCSV()">Download CSV</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('csv-import-input').click()">Import CSV</button>
<input type="file" id="csv-import-input" accept=".csv" style="display:none"
onchange="handleImportFile(this)">
</div>
</div>
<!-- Bar chart -->
<div class="card">
<h2>Monthly Egg Totals</h2>
<p id="chart-no-data" class="text-muted" style="display:none; padding:1.5rem 0; text-align:center;">No egg data yet.</p>
<div id="chart-wrap">
<canvas id="monthly-chart" height="90"></canvas>
</div>
</div>
<!-- Monthly breakdown table -->
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Month</th>
<th>Total Eggs</th>
<th>Days Logged</th>
<th>Avg / Day</th>
<th>Flock</th>
<th>Avg / Hen / Day</th>
<th>Feed Cost</th>
<th>Cost / Egg</th>
<th>Cost / Dozen</th>
</tr>
</thead>
<tbody id="summary-body">
<tr class="empty-row"><td colspan="9">Loading…</td></tr>
</tbody>
</table>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/summary.js"></script>
</body>
</html>