First commit

This commit is contained in:
2026-04-12 18:55:00 +03:00
commit 8f1bf14191
12 changed files with 1451 additions and 0 deletions

390
templates/index.html Normal file
View File

@@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Daily Check-in</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/theme.js"></script>
</head>
<body>
<nav class="topbar">
<span class="topbar-title">Daily Check-in</span>
<div class="topbar-nav">
<a href="/" class="active">Form</a>
<a href="/history">History</a>
</div>
<div id="status" title="Save status"></div>
<button class="theme-toggle" id="theme-toggle" title="Toggle theme"></button>
</nav>
<div class="page">
<!-- Period banner -->
<div class="period-banner" id="period-banner">
<span class="period-icon">🕐</span>
<div class="period-text" id="period-text">Loading period…</div>
<div class="period-progress">
<div class="period-pct" id="period-pct"></div>
<div class="period-bar-wrap"><div class="period-bar" id="period-bar" style="width:0%"></div></div>
</div>
</div>
<!-- Questions -->
<div id="form"></div>
<!-- Actions row -->
<div style="display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;">
<a class="download-btn" href="#" onclick="downloadCurrentMd(event)">⬇ Download as .md</a>
<button class="early-submit-btn" onclick="earlySubmit()">⏭ Start new day</button>
</div>
<!-- Confirmation dialog -->
<div id="confirm-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:300;backdrop-filter:blur(4px);">
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:28px 24px;max-width:340px;width:90%;box-shadow:var(--shadow);">
<div style="font-size:17px;font-weight:600;margin-bottom:8px;">Start new day?</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:24px;line-height:1.5;">This will snapshot current answers and reset the form — same as the automatic 10:00 submission, just now.</div>
<div style="display:flex;gap:10px;">
<button onclick="confirmEarlySubmit()" style="flex:1;padding:10px;background:var(--accent);border:none;border-radius:var(--radius);color:white;font-size:14px;font-weight:600;cursor:pointer;">Submit now</button>
<button onclick="closeConfirm()" style="flex:1;padding:10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px;cursor:pointer;">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Done checkbox -->
<div class="done-card" id="done-card" onclick="toggleDone()">
<div class="done-checkbox" id="done-checkbox"></div>
<div>
<div class="done-label">Mark as fully filled</div>
<div class="done-sub" id="done-sub">Not marked yet</div>
</div>
</div>
</div>
<script>
const QUESTIONS = {{ questions | tojson }};
const PERIOD = {{ period | tojson }};
let answers = {};
let saveTimer = null;
// ── Status ──
function setStatus(s) {
const el = document.getElementById('status');
el.className = s;
if (s === 'saving') el.textContent = 'saving';
else if (s === 'saved') el.textContent = 'saved';
else if (s === 'error') el.textContent = 'error';
else el.textContent = '';
}
// ── Period banner ──
function renderPeriod() {
if (!PERIOD.start || !PERIOD.end) {
document.getElementById('period-text').innerHTML = '<strong>No active period configured</strong>';
return;
}
const start = new Date(PERIOD.start);
const end = new Date(PERIOD.end);
const now = new Date();
const fmt = d => d.toLocaleString('en-GB', {day:'numeric', month:'short', hour:'2-digit', minute:'2-digit', hour12:false});
document.getElementById('period-text').innerHTML =
`<strong>${fmt(start)}</strong> → <strong>${fmt(end)}</strong>`;
const total = end - start;
const elapsed = Math.max(0, Math.min(now - start, total));
const pct = total > 0 ? Math.round(elapsed / total * 100) : 0;
document.getElementById('period-pct').textContent = pct + '%';
document.getElementById('period-bar').style.width = pct + '%';
}
renderPeriod();
// ── Load & save ──
async function loadAnswers() {
const res = await fetch('/api/answers');
answers = await res.json();
renderAll();
renderDone();
}
function scheduleSave() {
clearTimeout(saveTimer);
setStatus('saving');
saveTimer = setTimeout(async () => {
try {
await fetch('/api/answers', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(answers)
});
setStatus('saved');
setTimeout(() => setStatus('idle'), 2000);
} catch(e) { setStatus('error'); }
}, 600);
}
function set(id, val) { answers[id] = val; scheduleSave(); }
// ── Done checkbox ──
async function toggleDone() {
const current = answers.__done__ || false;
const next = !current;
answers.__done__ = next;
answers.__done_at__ = next ? new Date().toISOString() : null;
scheduleSave();
renderDone();
}
function renderDone() {
const card = document.getElementById('done-card');
const box = document.getElementById('done-checkbox');
const sub = document.getElementById('done-sub');
const done = answers.__done__;
card.classList.toggle('checked', !!done);
box.textContent = done ? '✓' : '';
if (done && answers.__done_at__) {
const d = new Date(answers.__done_at__);
sub.textContent = 'Marked at ' + d.toLocaleTimeString('en-GB', {hour:'2-digit', minute:'2-digit'});
} else {
sub.textContent = 'Not marked yet';
}
}
// ── Render questions ──
function renderAll() {
const form = document.getElementById('form');
form.innerHTML = '';
QUESTIONS.forEach(q => {
const wrap = document.createElement('div');
wrap.className = 'question';
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `<div class="question-label">${escHtml(q.label)}</div>` + renderField(q);
wrap.appendChild(card);
form.appendChild(wrap);
attachListeners(q, card);
});
}
function renderField(q) {
const val = answers[q.id];
switch(q.type) {
case 'yesno': {
const y = val === true, n = val === false;
return `<div class="yesno">
<button data-id="${q.id}" data-val="yes" class="${y?'active-yes':''}">Yes</button>
<button data-id="${q.id}" data-val="no" class="${n?'active-no':''}">No</button>
</div>`;
}
case 'scale': {
const v = val ?? Math.round(((q.min??1)+(q.max??10))/2);
return `<div class="scale-wrap">
<div class="scale-top">
<span class="scale-value" id="sv-${q.id}">${v}</span>
<span class="scale-range">${q.min??1} ${q.max??10}</span>
</div>
<input type="range" data-id="${q.id}" min="${q.min??1}" max="${q.max??10}" step="${q.step??1}" value="${v}">
</div>`;
}
case 'number': {
return `<input type="number" data-id="${q.id}" min="${q.min??''}" max="${q.max??''}" value="${val??''}" placeholder="—">`;
}
case 'duration': {
const h = val != null ? Math.floor(val/60) : '';
const m = val != null ? String(val%60).padStart(2,'0') : '';
return `<div class="duration-wrap">
<input type="number" data-id="${q.id}" data-part="h" min="0" max="23" value="${h}" placeholder="h">
<span class="time-sep">:</span>
<input type="number" data-id="${q.id}" data-part="m" min="0" max="59" value="${m}" placeholder="mm">
</div>`;
}
case 'time': {
const h = val != null ? String(Math.floor(val/60)).padStart(2,'0') : '';
const m = val != null ? String(val%60).padStart(2,'0') : '';
return `<div class="time-wrap">
<input type="number" data-id="${q.id}" data-part="h" min="0" max="23" value="${h}" placeholder="HH">
<span class="time-sep">:</span>
<input type="number" data-id="${q.id}" data-part="m" min="0" max="59" value="${m}" placeholder="MM">
</div>`;
}
case 'counter': {
const v = val ?? 0;
return `<div class="counter-wrap">
<button class="counter-btn" data-id="${q.id}" data-action="dec"></button>
<div class="counter-val" id="cv-${q.id}">${v}</div>
<button class="counter-btn" data-id="${q.id}" data-action="inc">+</button>
</div>`;
}
case 'text':
return `<input type="text" data-id="${q.id}" value="${escHtml(val??'')}" placeholder="…">`;
case 'textarea':
return `<textarea data-id="${q.id}" placeholder="…">${escHtml(val??'')}</textarea>`;
case 'choice': {
return `<div class="choice-wrap">${q.options.map(o =>
`<button class="choice-btn${val===o?' active':''}" data-id="${q.id}" data-val="${escHtml(o)}">${escHtml(o)}</button>`
).join('')}</div>`;
}
case 'multichoice': {
const sel = Array.isArray(val) ? val : [];
return `<div class="multichoice-wrap">${q.options.map(o =>
`<button class="choice-btn${sel.includes(o)?' active':''}" data-id="${q.id}" data-val="${escHtml(o)}">${escHtml(o)}</button>`
).join('')}</div>`;
}
default: return `<span style="color:var(--text3);font-size:12px">unknown type: ${q.type}</span>`;
}
}
function attachListeners(q, div) {
switch(q.type) {
case 'yesno':
div.querySelectorAll('button[data-id]').forEach(btn => btn.addEventListener('click', () => {
const v = btn.dataset.val === 'yes';
set(q.id, v);
div.querySelectorAll('button[data-id]').forEach(b => b.className = '');
btn.className = v ? 'active-yes' : 'active-no';
}));
break;
case 'scale': {
const inp = div.querySelector('input[type=range]');
const display = div.querySelector(`#sv-${q.id}`);
inp.addEventListener('input', () => { display.textContent = inp.value; set(q.id, Number(inp.value)); });
break;
}
case 'number':
div.querySelector('input[type=number]').addEventListener('input', e => {
set(q.id, e.target.value === '' ? null : Number(e.target.value));
});
break;
case 'duration':
case 'time':
div.querySelectorAll('input[type=number]').forEach(inp => {
inp.addEventListener('input', () => {
const h = parseInt(div.querySelector('[data-part=h]').value) || 0;
const m = parseInt(div.querySelector('[data-part=m]').value) || 0;
set(q.id, h * 60 + m);
});
});
break;
case 'counter': {
const display = div.querySelector(`#cv-${q.id}`);
const cfg = QUESTIONS.find(x => x.id === q.id);
div.querySelectorAll('.counter-btn').forEach(btn => btn.addEventListener('click', () => {
let v = answers[q.id] ?? 0;
if (btn.dataset.action === 'inc') v = Math.min(v + (cfg.step||1), cfg.max ?? Infinity);
else v = Math.max(v - (cfg.step||1), cfg.min ?? -Infinity);
display.textContent = v;
set(q.id, v);
}));
break;
}
case 'text':
div.querySelector('input[type=text]').addEventListener('input', e => set(q.id, e.target.value));
break;
case 'textarea':
div.querySelector('textarea').addEventListener('input', e => set(q.id, e.target.value));
break;
case 'choice':
div.querySelectorAll('.choice-btn').forEach(btn => btn.addEventListener('click', () => {
set(q.id, btn.dataset.val);
div.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}));
break;
case 'multichoice':
div.querySelectorAll('.choice-btn').forEach(btn => btn.addEventListener('click', () => {
let sel = Array.isArray(answers[q.id]) ? [...answers[q.id]] : [];
const v = btn.dataset.val;
sel = sel.includes(v) ? sel.filter(x => x !== v) : [...sel, v];
set(q.id, sel);
btn.classList.toggle('active');
}));
break;
}
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function formatValMd(q, val) {
if (val == null) return '_(no answer)_';
switch(q.type) {
case 'yesno': return val ? 'Yes' : 'No';
case 'duration':
case 'time': { const h = Math.floor(val/60), m = val%60; return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; }
case 'multichoice': return Array.isArray(val) ? val.join(', ') : String(val);
default: return String(val);
}
}
function downloadCurrentMd(e) {
e.preventDefault();
const periodStart = PERIOD.start ? new Date(PERIOD.start) : new Date();
const dateStr = periodStart.toISOString().slice(0, 10);
const now = new Date();
const lines = [
`# Daily Check-in — ${dateStr}`,
'',
`_Downloaded at: ${now.toLocaleString('en-GB')}_`,
'',
'---',
'',
];
QUESTIONS.forEach(q => {
lines.push(`## ${q.label}`, '', formatValMd(q, answers[q.id]), '');
});
const blob = new Blob([lines.join('\n')], {type: 'text/markdown'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `checkin-${dateStr}-draft.md`;
a.click();
}
function earlySubmit() {
document.getElementById('confirm-overlay').style.display = 'block';
}
function closeConfirm() {
document.getElementById('confirm-overlay').style.display = 'none';
}
async function confirmEarlySubmit() {
closeConfirm();
setStatus('saving');
try {
const res = await fetch('/api/snapshot/now', {method: 'POST'});
if (!res.ok) throw new Error();
answers = {};
renderAll();
renderDone();
setStatus('saved');
setTimeout(() => { setStatus('idle'); window.location.reload(); }, 1000);
} catch(e) { setStatus('error'); }
}
loadAnswers();
initThemeToggle();
</script>
</body>
</html>