diff --git a/server/state_server.py b/server/state_server.py
index 8c8d8e3..da0eae2 100644
--- a/server/state_server.py
+++ b/server/state_server.py
@@ -216,6 +216,20 @@ def do_POST(self):
except (KeyError, ValueError, TypeError):
pass
self.send_error(400, "Bad Request")
+ elif self.path == "/schedule/delete":
+ try:
+ chip = int(data["chip"])
+ pin = int(data["pin"])
+ for s in list(SCHEDULES):
+ if s["chip"] == chip and s["pin"] == pin:
+ SCHEDULES.remove(s)
+ save_schedules()
+ self._set_json_headers()
+ self.wfile.write(b"{}")
+ return
+ except (KeyError, ValueError, TypeError):
+ pass
+ self.send_error(400, "Bad Request")
else:
self.send_error(404, "Not Found")
diff --git a/server/web/app.js b/server/web/app.js
index 9dc3940..0927ac6 100644
--- a/server/web/app.js
+++ b/server/web/app.js
@@ -1,11 +1,58 @@
const API_BASE = `${window.location.protocol}//${window.location.hostname}:5000`;
-function fillSelect(select, max) {
- for (let i = 0; i < max; i++) {
- const opt = document.createElement('option');
- opt.value = i;
- opt.textContent = i;
- select.appendChild(opt);
+const PIN_LAYOUT = [
+ [ [0,1], [0,0], [1,1], [1,0], [2,1], [2,0] ],
+ [ [0,3], [0,2], [1,3], [1,2], [2,3], [2,2] ],
+ [ [0,5], [0,4], [1,5], [1,4], [2,5], [2,4] ],
+];
+
+const PIN_LABELS = [
+ '1','2','3','4','5','6',
+ '7','8','9','10','11','12',
+ '13','14','15','16','17','18'
+];
+
+const scheduleMap = {};
+
+function initTheme() {
+ const stored = localStorage.getItem('theme');
+ const preferDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ const useDark = stored ? stored === 'dark' : preferDark;
+ document.body.classList.toggle('dark', useDark);
+}
+
+function buildGrid() {
+ const grid = document.getElementById('pin-grid');
+ PIN_LAYOUT.flat().forEach(([chip, pin], idx) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.textContent = PIN_LABELS[idx];
+ btn.dataset.chip = chip;
+ btn.dataset.pin = pin;
+ btn.addEventListener('click', () => handlePinClick(chip, pin));
+ grid.appendChild(btn);
+ });
+}
+
+function selectPin(chip, pin) {
+ document.getElementById('chip').value = chip;
+ document.getElementById('pin').value = pin;
+ document.querySelectorAll('#pin-grid button').forEach(b => b.classList.remove('selected'));
+ const btn = document.querySelector(`#pin-grid button[data-chip="${chip}"][data-pin="${pin}"]`);
+ if (btn) btn.classList.add('selected');
+}
+
+function handlePinClick(chip, pin) {
+ selectPin(chip, pin);
+ const sched = scheduleMap[`${chip}-${pin}`];
+ if (sched) {
+ populateForm(sched);
+ } else {
+ document.getElementById('form').reset();
+ document.querySelectorAll('.repeat-presets button, .overdue-presets button').forEach(b => b.classList.remove('active'));
+ document.getElementById('repeat-advanced').classList.add('hidden');
+ selectPin(chip, pin);
+ document.getElementById('delete').classList.add('hidden');
}
}
@@ -14,33 +61,122 @@ async function fetchSchedules() {
const data = await res.json();
const tbody = document.querySelector('#schedule-list tbody');
tbody.innerHTML = '';
+ Object.keys(scheduleMap).forEach(k => delete scheduleMap[k]);
+ document.querySelectorAll('#pin-grid button').forEach(b => b.classList.remove('scheduled'));
const schedules = data.schedules.slice().reverse();
schedules.forEach(s => {
+ scheduleMap[`${s.chip}-${s.pin}`] = s;
const tr = document.createElement('tr');
const due = new Date(s.due).toLocaleString();
- tr.innerHTML = `
${s.name} | ${s.chip} | ${s.pin} | ` +
+ tr.innerHTML = `${s.name} | ${labelFor(s.chip, s.pin)} | ` +
`${due} | ` +
`${formatInterval(s.repeat)} | ${formatInterval(s.overdue)} | `;
tr.addEventListener('click', () => populateForm(s));
tbody.appendChild(tr);
+ const btn = document.querySelector(`#pin-grid button[data-chip="${s.chip}"][data-pin="${s.pin}"]`);
+ if (btn) btn.classList.add('scheduled');
});
}
+function labelFor(chip, pin) {
+ const idx = PIN_LAYOUT.flat().findIndex(([c,p]) => c === chip && p === pin);
+ return PIN_LABELS[idx] || `${chip}/${pin}`;
+}
+
function formatInterval(obj) {
return Object.entries(obj || {}).map(([k, v]) => `${v} ${k}`).join(' ');
}
function populateForm(s) {
- document.getElementById('chip').value = s.chip;
- document.getElementById('pin').value = s.pin;
+ selectPin(s.chip, s.pin);
document.getElementById('name').value = s.name || '';
const dueDate = new Date(s.due);
dueDate.setMinutes(dueDate.getMinutes() - dueDate.getTimezoneOffset());
document.getElementById('due').value = dueDate.toISOString().slice(0,16);
+ const adv = document.getElementById('repeat-advanced');
+ adv.classList.add('hidden');
document.getElementById('repeat-days').value = s.repeat?.days || '';
document.getElementById('repeat-hours').value = s.repeat?.hours || '';
+ document.querySelectorAll('.repeat-presets button').forEach(b => b.classList.remove('active'));
+ if (s.repeat) {
+ if ((s.repeat.days === 1 || s.repeat.days === 7) && !s.repeat.hours) {
+ const type = s.repeat.days === 1 ? 'daily' : 'weekly';
+ const btn = document.querySelector(`.repeat-presets button[data-repeat="${type}"]`);
+ if (btn) btn.classList.add('active');
+ } else {
+ adv.classList.remove('hidden');
+ }
+ }
document.getElementById('overdue-days').value = s.overdue?.days || '';
document.getElementById('overdue-hours').value = s.overdue?.hours || '';
+ document.querySelectorAll('.overdue-presets button').forEach(b => b.classList.remove('active'));
+ if (s.overdue) {
+ if (s.overdue.days === 1 && !s.overdue.hours) {
+ const btn = document.querySelector('.overdue-presets button[data-overdue="1d"]');
+ if (btn) btn.classList.add('active');
+ } else if (s.overdue.days === 3 && !s.overdue.hours) {
+ const btn = document.querySelector('.overdue-presets button[data-overdue="3d"]');
+ if (btn) btn.classList.add('active');
+ }
+ }
+ document.getElementById('delete').classList.remove('hidden');
+}
+
+function applyDuePreset(type) {
+ const input = document.getElementById('due');
+ const now = new Date();
+ let target = new Date(now);
+ switch(type) {
+ case 'morning':
+ target.setHours(8,0,0,0);
+ if (target <= now) target.setDate(target.getDate()+1);
+ break;
+ case 'evening':
+ target.setHours(18,0,0,0);
+ if (target <= now) target.setDate(target.getDate()+1);
+ break;
+ case 'plus1d':
+ target = new Date(now.getTime() + 24*60*60*1000);
+ break;
+ case 'plus3d':
+ target = new Date(now.getTime() + 3*24*60*60*1000);
+ break;
+ case 'nextweek':
+ target = new Date(now.getTime() + 7*24*60*60*1000);
+ break;
+ default:
+ return;
+ }
+ target.setMinutes(target.getMinutes() - target.getTimezoneOffset());
+ input.value = target.toISOString().slice(0,16);
+}
+
+function toggleRepeatPreset(btn) {
+ const type = btn.dataset.repeat;
+ const active = btn.classList.contains('active');
+ document.querySelectorAll('.repeat-presets button[data-repeat]').forEach(b => b.classList.remove('active'));
+ const adv = document.getElementById('repeat-advanced');
+ adv.classList.add('hidden');
+ document.getElementById('repeat-days').value = '';
+ document.getElementById('repeat-hours').value = '';
+ if (!active) {
+ btn.classList.add('active');
+ if (type === 'daily') document.getElementById('repeat-days').value = 1;
+ if (type === 'weekly') document.getElementById('repeat-days').value = 7;
+ }
+}
+
+function toggleOverduePreset(btn) {
+ const type = btn.dataset.overdue;
+ const active = btn.classList.contains('active');
+ document.querySelectorAll('.overdue-presets button').forEach(b => b.classList.remove('active'));
+ document.getElementById('overdue-days').value = '';
+ document.getElementById('overdue-hours').value = '';
+ if (!active) {
+ btn.classList.add('active');
+ if (type === '1d') document.getElementById('overdue-days').value = 1;
+ if (type === '3d') document.getElementById('overdue-days').value = 3;
+ }
}
async function submitForm(e) {
@@ -69,10 +205,43 @@ async function submitForm(e) {
body: JSON.stringify(payload)
});
e.target.reset();
+ document.querySelectorAll('.repeat-presets button, .overdue-presets button').forEach(b => b.classList.remove('active'));
+ document.getElementById('repeat-advanced').classList.add('hidden');
+ fetchSchedules();
+}
+
+async function deleteSchedule() {
+ const chip = parseInt(document.getElementById('chip').value, 10);
+ const pin = parseInt(document.getElementById('pin').value, 10);
+ await fetch(`${API_BASE}/schedule/delete`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ chip, pin })
+ });
+ document.getElementById('form').reset();
+ document.querySelectorAll('.repeat-presets button, .overdue-presets button').forEach(b => b.classList.remove('active'));
+ document.getElementById('repeat-advanced').classList.add('hidden');
fetchSchedules();
}
-fillSelect(document.getElementById('chip'), 3);
-fillSelect(document.getElementById('pin'), 6);
+initTheme();
+buildGrid();
fetchSchedules();
document.getElementById('form').addEventListener('submit', submitForm);
+document.getElementById('delete').addEventListener('click', deleteSchedule);
+document.getElementById('show-advanced').addEventListener('click', () => {
+ document.getElementById('repeat-advanced').classList.remove('hidden');
+});
+document.getElementById('theme-toggle').addEventListener('click', () => {
+ document.body.classList.toggle('dark');
+ localStorage.setItem('theme', document.body.classList.contains('dark') ? 'dark' : 'light');
+});
+document.querySelectorAll('.quick-due button').forEach(btn => {
+ btn.addEventListener('click', () => applyDuePreset(btn.dataset.due));
+});
+document.querySelectorAll('.repeat-presets button[data-repeat]').forEach(btn => {
+ btn.addEventListener('click', () => toggleRepeatPreset(btn));
+});
+document.querySelectorAll('.overdue-presets button').forEach(btn => {
+ btn.addEventListener('click', () => toggleOverduePreset(btn));
+});
diff --git a/server/web/index.html b/server/web/index.html
index 5cf0a33..c1ad074 100644
--- a/server/web/index.html
+++ b/server/web/index.html
@@ -2,16 +2,18 @@
+
Chore Schedules
diff --git a/server/web/style.css b/server/web/style.css
index c345651..69158df 100644
--- a/server/web/style.css
+++ b/server/web/style.css
@@ -1,17 +1,31 @@
+:root {
+ --bg: #f5f5f5;
+ --text: #333;
+ --card-bg: #fff;
+ --border: #ddd;
+ --primary: #007bff;
+ --primary-hover: #0056b3;
+ --danger: #dc3545;
+ --danger-hover: #a71d2a;
+ --scheduled-bg: #28a745;
+ --scheduled-color: #fff;
+}
+
body {
font-family: system-ui, sans-serif;
- margin: 2rem;
- background: #f5f5f5;
- color: #333;
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
}
.container {
max-width: 800px;
margin: auto;
- background: #fff;
- padding: 2rem;
+ background: var(--card-bg);
+ padding: 1rem 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ position: relative;
}
table {
@@ -22,7 +36,7 @@ table {
table th, table td {
padding: 0.5rem 0.75rem;
- border-bottom: 1px solid #ddd;
+ border-bottom: 1px solid var(--border);
text-align: left;
}
@@ -33,21 +47,109 @@ form label {
}
fieldset {
- border: 1px solid #ddd;
+ border: 1px solid var(--border);
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
}
+.pin-grid {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 4px;
+ margin-bottom: 1rem;
+}
+
+.pin-grid button {
+ padding: 0.5rem;
+ font-size: 0.8rem;
+}
+
+.pin-grid button.scheduled {
+ background: var(--scheduled-bg);
+ color: var(--scheduled-color);
+}
+
+.pin-grid button.selected {
+ outline: 3px solid var(--primary-hover);
+}
+
+.quick-due, .repeat-presets, .overdue-presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 1rem;
+}
+
+.hidden {
+ display: none;
+}
+
+.danger {
+ background: var(--danger);
+}
+
+.danger:hover {
+ background: var(--danger-hover);
+}
+
button {
padding: 0.5rem 1rem;
border: none;
- background: #007bff;
+ background: var(--primary);
color: #fff;
border-radius: 4px;
cursor: pointer;
}
button:hover {
- background: #0056b3;
+ background: var(--primary-hover);
+}
+
+.repeat-presets button.active,
+.overdue-presets button.active {
+ background: var(--primary-hover);
+}
+
+.theme-toggle {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
}
+
+body.dark {
+ --bg: #111;
+ --text: #eee;
+ --card-bg: #222;
+ --border: #444;
+ --primary: #3399ff;
+ --primary-hover: #66b3ff;
+ --danger: #ff5c5c;
+ --danger-hover: #cc0000;
+ --scheduled-bg: #2e8b57;
+}
+
+@media (max-width: 600px) {
+ .container {
+ max-width: 100%;
+ margin: 0;
+ border-radius: 0;
+ box-shadow: none;
+ padding: 1rem;
+ }
+
+ .pin-grid {
+ gap: 2px;
+ }
+
+ .pin-grid button {
+ font-size: 0.9rem;
+ }
+
+ .quick-due button,
+ .repeat-presets button,
+ .overdue-presets button {
+ flex: 1;
+ }
+}
+