Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
67
nginx/html/js/api.js
Normal file
67
nginx/html/js/api.js
Normal 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
163
nginx/html/js/budget.js
Normal 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
127
nginx/html/js/dashboard.js
Normal 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
119
nginx/html/js/flock.js
Normal 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
118
nginx/html/js/history.js
Normal 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
52
nginx/html/js/log.js
Normal 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
366
nginx/html/js/summary.js
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user