momi-tracker/index.html
2025-06-05 12:02:56 +02:00

528 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="fav_icon.ico" type="image/x-icon">
<title>Milk Production Tracker</title>
<style>
body,
html {
margin: 0;
padding: 0;
height: 100%;
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
background: #f9f9f9;
}
/* Menu bar container */
#menu-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 40px;
background: #007bff;
display: flex;
align-items: center;
padding: 0 10px;
z-index: 1000;
}
#menu-btn {
background: transparent;
border: none;
color: white;
font-size: 16px;
cursor: pointer;
padding: 6px 12px;
border-radius: 4px;
}
#menu-btn:hover,
#menu-btn:focus {
background: rgba(255, 255, 255, 0.2);
outline: none;
}
#floating-menu {
position: fixed;
top: 40px;
/* below menu bar */
left: 0;
background: white;
border: 1px solid #ddd;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
border-radius: 4px;
display: none;
z-index: 1000;
min-width: 140px;
}
#floating-menu button {
width: 100%;
background: none;
border: none;
padding: 10px 14px;
text-align: left;
font-size: 14px;
cursor: pointer;
}
#floating-menu button:hover {
background: #f0f0f0;
}
#page-heading {
font-weight: bold;
font-size: 18px;
margin: 12px 0 12px 10px;
color: #333;
}
#list {
flex: 1 1 auto;
overflow-y: auto;
padding: 0 10px 10px 10px;
margin-top: 28px;
/* 40px menu bar + 36px heading + 12px margin */
}
.item {
background: white;
margin-bottom: 8px;
padding: 10px 12px;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
}
.item>div {
display: flex;
flex-direction: column;
}
.timestamp {
font-weight: bold;
margin-bottom: 4px;
}
.amount {
color: #555;
}
button.delete-btn,
button.restore-btn {
background: transparent;
border: none;
cursor: pointer;
width: 24px;
height: 24px;
fill: #c00;
}
button.restore-btn {
fill: #080;
}
#input-area {
flex-shrink: 0;
background: white;
border-top: 1px solid #ddd;
padding: 8px;
display: flex;
gap: 8px;
align-items: center;
position: fixed;
bottom: 0;
left: 0;
right: 0;
box-sizing: border-box;
flex-wrap: wrap;
justify-content: flex-start;
}
input[type="datetime-local"],
input[type="number"] {
padding: 6px 8px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 4px;
flex: 1 1 auto;
}
input[type="number"] {
max-width: 100px;
}
button#submit-btn {
background: #007bff;
border: none;
color: white;
padding: 8px 14px;
font-size: 14px;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
flex-basis: 100%;
margin-top: 6px;
max-width: 100%;
}
button#submit-btn:disabled {
background: #a0c8ff;
cursor: default;
}
@media (min-width: 400px) {
button#submit-btn {
flex-basis: auto;
margin-top: 0;
}
}
@media (min-width: 600px) {
#input-area {
position: static;
padding: 12px 16px;
justify-content: flex-start;
}
input[type="datetime-local"] {
max-width: 250px;
}
}
.hidden {
visibility: hidden;
height: 0;
}
</style>
</head>
<body>
<div id="menu-bar">
<button id="menu-btn" aria-haspopup="true" aria-expanded="false" aria-controls="floating-menu">Menu</button>
</div>
<div id="floating-menu" role="menu" aria-label="Navigation menu">
<button id="nav-entries" role="menuitem">Trash</button>
<button id="nav-import" role="menuitem">Import</button>
<button id="nav-export" role="menuitem">Export</button>
</div>
<div id="page-heading" aria-live="polite"></div>
<div id="list" aria-live="polite" aria-label="Milk production entries"></div>
<div id="input-area">
<input type="datetime-local" id="timestamp" aria-label="Timestamp" />
<input type="number" id="amount" min="0" step="1" aria-label="Amount in milliliters" placeholder="ml" />
<button id="submit-btn" disabled>Add</button>
</div>
<script>
const STORAGE_KEY = 'milkProductionEntries';
const REMOVED_KEY = 'milkProductionRemoved';
let entries = JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
let removed = JSON.parse(localStorage.getItem(REMOVED_KEY)) || [];
const listEl = document.getElementById('list');
const timestampInput = document.getElementById('timestamp');
timestampInput.value = new Date().toISOString().slice(0, 16);
const amountInput = document.getElementById('amount');
const submitBtn = document.getElementById('submit-btn');
const menuBtn = document.getElementById('menu-btn');
const floatingMenu = document.getElementById('floating-menu');
const navEntriesBtn = document.getElementById('nav-entries');
const navExportBtn = document.getElementById('nav-export');
const navImportBtn = document.getElementById('nav-import');
const pageHeading = document.getElementById('page-heading');
const inputArea = document.getElementById('input-area');
let currentView = 'entries';
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
localStorage.setItem(REMOVED_KEY, JSON.stringify(removed));
}
function formatTimestamp(ts) {
const d = new Date(ts);
if (isNaN(d)) return ts;
return d.toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
function renderEntries() {
pageHeading.textContent = '';
inputArea.style.display = 'flex';
listEl.innerHTML = '';
if (entries.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.textContent = 'No milk production entries yet.';
emptyMsg.style.color = '#666';
emptyMsg.style.textAlign = 'center';
emptyMsg.style.marginTop = '20px';
listEl.appendChild(emptyMsg);
return;
}
entries.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)).forEach((entry, i) => {
const item = document.createElement('div');
item.className = 'item';
item.setAttribute('data-index', i);
const info = document.createElement('div');
const ts = document.createElement('div');
ts.className = 'timestamp';
ts.textContent = formatTimestamp(entry.timestamp);
const amt = document.createElement('div');
amt.className = 'amount';
amt.textContent = `${entry.amount} ml`;
info.appendChild(ts);
info.appendChild(amt);
const delBtn = document.createElement('button');
delBtn.className = 'delete-btn';
delBtn.setAttribute('aria-label', 'Delete entry');
delBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#c00"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>
`;
delBtn.addEventListener('click', () => {
removed.push(entries[i]);
entries.splice(i, 1);
save();
render();
});
item.appendChild(info);
item.appendChild(delBtn);
listEl.appendChild(item);
});
}
function renderTrash() {
pageHeading.textContent = 'Trash Bin';
inputArea.style.display = 'none'; // hide input area in trash view
listEl.innerHTML = '';
if (removed.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.textContent = 'Trash bin is empty.';
emptyMsg.style.color = '#666';
emptyMsg.style.textAlign = 'center';
emptyMsg.style.marginTop = '20px';
listEl.appendChild(emptyMsg);
return;
}
removed.sort(((a, b) => new Date(b.timestamp) - new Date(a.timestamp))).forEach((entry, i) => {
const item = document.createElement('div');
item.className = 'item';
item.setAttribute('data-index', i);
const info = document.createElement('div');
const ts = document.createElement('div');
ts.className = 'timestamp';
ts.textContent = formatTimestamp(entry.timestamp);
const amt = document.createElement('div');
amt.className = 'amount';
amt.textContent = `${entry.amount} ml`;
info.appendChild(ts);
info.appendChild(amt);
const restoreBtn = document.createElement('button');
restoreBtn.className = 'restore-btn';
restoreBtn.setAttribute('aria-label', 'Restore entry');
restoreBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#080"><path d="M13 3a9 9 0 1 0 8.95 9.95h-2.02a7 7 0 1 1-6.93-7v4l5-5-5-5v4z"/></svg>
`;
restoreBtn.addEventListener('click', () => {
entries.push(removed[i]);
removed.splice(i, 1);
save();
render();
});
item.appendChild(info);
item.appendChild(restoreBtn);
listEl.appendChild(item);
});
}
function render() {
if (currentView === 'entries') {
renderEntries();
menuBtn.textContent = 'Menu';
} else if (currentView === 'trash') {
renderTrash();
menuBtn.textContent = 'Menu';
}
}
function validateInputs() {
const tsVal = timestampInput.value;
const amtVal = amountInput.value;
if (!tsVal) return false;
if (!amtVal) return false;
const amtNum = Number(amtVal);
if (!Number.isInteger(amtNum) || amtNum < 0) return false;
return true;
}
function updateSubmitState() {
submitBtn.disabled = !validateInputs();
}
timestampInput.addEventListener('input', updateSubmitState);
amountInput.addEventListener('input', updateSubmitState);
submitBtn.addEventListener('click', () => {
if (!validateInputs()) return;
const newEntry = {
timestamp: timestampInput.value,
amount: Number(amountInput.value)
};
entries.push(newEntry);
save();
render();
timestampInput.value = new Date().toISOString().slice(0, 16);
amountInput.value = '';
updateSubmitState();
});
menuBtn.addEventListener('click', () => {
const expanded = menuBtn.getAttribute('aria-expanded') === 'true';
if (expanded) {
floatingMenu.style.display = 'none';
menuBtn.setAttribute('aria-expanded', 'false');
} else {
floatingMenu.style.display = 'block';
menuBtn.setAttribute('aria-expanded', 'true');
}
});
document.addEventListener('click', (e) => {
if (!floatingMenu.contains(e.target) && e.target !== menuBtn) {
floatingMenu.style.display = 'none';
menuBtn.setAttribute('aria-expanded', 'false');
}
});
function uploadJSONFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = () => {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const uploadedData = JSON.parse(reader.result);
if (!Array.isArray(uploadedData)) {
console.error('Uploaded JSON is not an array');
return;
}
const existingDataString = localStorage.getItem(STORAGE_KEY);
let existingData = [];
if (existingDataString) {
try {
existingData = JSON.parse(existingDataString);
if (!Array.isArray(existingData)) {
console.error('Existing localStorage data is not an array');
return;
}
} catch {
console.error('Failed to parse existing localStorage data');
return;
}
}
const existingSet = new Set(existingData.map(item => JSON.stringify(item)));
const filteredUploaded = uploadedData.filter(item => {
const str = JSON.stringify(item);
if (existingSet.has(str)) {
return false;
}
existingSet.add(str);
return true;
});
const mergedData = existingData.concat(filteredUploaded);
entries = mergedData;
localStorage.setItem(STORAGE_KEY, JSON.stringify(mergedData));
render();
updateSubmitState();
console.log('Data merged successfully, duplicates ignored');
} catch (e) {
console.error('Invalid JSON file', e);
}
};
reader.readAsText(file);
};
input.click();
}
function downloadJSONFile(filename = 'data.json') {
const blob = new Blob([JSON.stringify(entries)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
navEntriesBtn.addEventListener('click', () => {
currentView = currentView === 'entries' ? 'trash' : 'entries';
navEntriesBtn.innerHTML = currentView === 'entries' ? 'Trash' : 'Entries';
floatingMenu.style.display = 'none';
menuBtn.setAttribute('aria-expanded', 'false');
render();
});
navExportBtn.addEventListener('click', () => {
downloadJSONFile();
floatingMenu.style.display = 'none';
menuBtn.setAttribute('aria-expanded', 'false');
});
navImportBtn.addEventListener('click', () => {
uploadJSONFile();
floatingMenu.style.display = 'none';
menuBtn.setAttribute('aria-expanded', 'false');
});
render();
updateSubmitState();
</script>
</body>
</html>