367 lines
13 KiB
JavaScript
367 lines
13 KiB
JavaScript
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>${fmtMoney(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();
|
|
}
|