diff --git a/public/js/render.js b/public/js/render.js new file mode 100644 index 0000000..717f06c --- /dev/null +++ b/public/js/render.js @@ -0,0 +1,286 @@ +import { state, savePrefs } from './state.js'; +import { tr } from './i18n.js'; +import { api } from './api.js'; +import { tOff, o2d, dTot, fd, fs, editable, now, heuteColor, isCollapsed, toggleCollapse, calc, dcls, dlbl } from './goals.js'; +import { tpl, showToast } from './ui.js'; + +function saveGoal(g) { + api('PATCH', 'goals/' + g.id, { + name: g.name, unit: g.unit, daily: g.daily, days: g.days, start: g.start, sets: g.sets, + }).catch(e => { + if (e.status !== 401) showToast(tr('errSave')); + }); +} + +function addSet(gid, off) { + const g = state.goals.find(x => x.id === gid); + if (!g || !editable(g, off)) return; + const k = gid + '_' + off, amt = parseInt(state.addAmt[k] || '0', 10); + if (amt <= 0) return; + if (!g.sets[String(off)]) g.sets[String(off)] = []; + g.sets[String(off)].push({ amount: amt, time: off === tOff(g) ? now() : 'โ€”' }); + state.addAmt[k] = ''; saveGoal(g); render(); +} + +function remSet(gid, off, idx) { + const g = state.goals.find(x => x.id === gid); + if (!g || !editable(g, off)) return; + g.sets[String(off)].splice(idx, 1); saveGoal(g); render(); +} + +function delGoal(id) { + if (!confirm(tr('confirmDelete'))) return; + state.goals = state.goals.filter(g => g.id !== id); + render(); + api('DELETE', 'goals/' + id).catch(() => showToast(tr('errDelete'))); +} + +function selD(gid, off) { + const g = state.goals.find(x => x.id === gid); + if (!g || !editable(g, off)) return; + state.selDay[gid] = state.selDay[gid] === off ? null : off; + render(); +} + +function startRen(id) { + const g = state.goals.find(x => x.id === id); if (!g) return; + state.renamingId = id; state.renameVal = g.name; render(); + setTimeout(() => { const el = document.getElementById('ri' + id); if (el) { el.focus(); el.select(); } }, 50); +} + +function commitRen(id) { + const g = state.goals.find(x => x.id === id); + if (g && state.renameVal.trim()) { g.name = state.renameVal.trim(); saveGoal(g); } + state.renamingId = null; render(); +} + +function cancelRen() { state.renamingId = null; render(); } + +function buildNameWrap(g) { + if (state.renamingId === g.id) { + const el = tpl('tpl-name-edit'); + const inp = el.querySelector('.ren-input'); + inp.id = 'ri' + g.id; inp.value = g.name; inp.dataset.g = g.id; + return el; + } + const el = tpl('tpl-name-view'); + el.querySelector('.goal-name').textContent = g.name; + el.querySelector('.btn-ren').dataset.g = g.id; + return el; +} + +function buildPanel(g, off) { + const t = tOff(g), sets = g.sets[String(off)] || [], tot = dTot(g, off); + const lbl = off === t ? tr('heute') : tr('gestern'), k = g.id + '_' + off; + const el = tpl('tpl-panel'); + el.querySelector('.dpanel-title').textContent = lbl + ' โ€” ' + fd(o2d(g, off)); + el.querySelector('.dpanel-sub').textContent = tot + ' / ' + g.daily + ' ' + g.unit; + const body = el.querySelector('.dpanel-body'); + if (sets.length) { + for (let i = 0; i < sets.length; i++) { + const s = sets[i], row = tpl('tpl-set-row'), span = row.querySelector('span'); + if (s.time !== 'โ€”') { + const st = document.createElement('span'); st.className = 'stime'; st.textContent = s.time + ' ยท'; + span.appendChild(st); span.appendChild(document.createTextNode(' ')); + } + const strong = document.createElement('strong'); strong.textContent = s.amount; + span.appendChild(strong); span.appendChild(document.createTextNode(' ' + g.unit)); + const btn = row.querySelector('.sdel'); + btn.dataset.g = g.id; btn.dataset.o = off; btn.dataset.i = i; + body.appendChild(row); + } + } else { + body.appendChild(tpl('tpl-nosets')); + } + const addRow = tpl('tpl-add-row'); + const inp = addRow.querySelector('.num-in'); + inp.placeholder = g.daily; inp.value = state.addAmt[k] || ''; inp.dataset.k = k; inp.dataset.g = g.id; inp.dataset.o = off; + const abtn = addRow.querySelector('.btn-as'); + abtn.dataset.g = g.id; abtn.dataset.o = off; + addRow.querySelector('.ulbl').textContent = g.unit; + body.appendChild(addRow); + return el; +} + +function buildCard(g) { + const c = calc(g), t = c.tOff; + const fc = c.surplus > 0 ? 'var(--blue)' : c.st === 0 ? 'var(--green)' : c.dailyDelta <= 0 ? 'var(--green)' : c.dailyDelta <= g.daily * .2 ? 'var(--amber)' : 'var(--red)'; + let bc, bt; + const bufStr = (c.buf > 0 ? '+' : '') + c.buf; + if (c.ok && c.surplus > 0) { bc = 'b-buf'; bt = bufStr; } + else if (c.ok) { bc = 'b-done'; bt = bufStr; } + else if (c.dailyDelta <= 0) { bc = 'b-ok'; bt = bufStr; } + else if (c.dailyDelta <= g.daily * .2) { bc = 'b-warn'; bt = bufStr; } + else { bc = 'b-danger'; bt = bufStr; } + + let el; + if (isCollapsed(g.id)) { + el = tpl('tpl-card-collapsed'); + if (c.ok) el.classList.add('done'); + el.querySelector('.card-hdr').dataset.g = g.id; + const bd = el.querySelector('.card-bd'); + bd.insertBefore(buildNameWrap(g), bd.firstElementChild); + const hc = heuteColor(c.tdone, g.daily); + el.querySelector('.m-dr').textContent = c.dr; + el.querySelector('.m-end').textContent = fs(c.end); + const mH = el.querySelector('.m-heute'); mH.textContent = c.tdone + '/' + g.daily; mH.style.color = hc; + el.querySelector('.m-total').textContent = c.done + '/' + c.tot; + const badge = el.querySelector('.badge'); badge.className = 'badge ' + bc; badge.textContent = bt; + const fill = el.querySelector('.prog-fill'); fill.style.width = c.pct + '%'; fill.style.background = fc; + return el; + } + + el = tpl('tpl-card-expanded'); + if (c.ok) el.classList.add('done'); + el.querySelector('.card-hdr').dataset.g = g.id; + const bd = el.querySelector('.card-bd'); + bd.insertBefore(buildNameWrap(g), bd.firstElementChild); + el.querySelector('.m-dr').textContent = c.dr; + el.querySelector('.m-end').textContent = fs(c.end); + const badge = el.querySelector('.badge'); badge.className = 'badge ' + bc; badge.textContent = bt; + const fill = el.querySelector('.prog-fill'); fill.style.width = c.pct + '%'; fill.style.background = fc; + el.querySelector('.pr-done').textContent = c.done + ' ' + g.unit + ' ' + tr('doneLabel'); + el.querySelector('.pr-pct').textContent = c.pct + '% ' + tr('ofLabel') + ' ' + c.tot; + el.querySelector('.sv-tdone').textContent = c.tdone; + el.querySelector('.sv-daily').textContent = g.daily; + el.querySelector('.sv-st').textContent = c.st; + el.querySelector('.sv-noch').style.color = heuteColor(c.tdone, g.daily); + el.querySelectorAll('.sunit').forEach(u => { u.textContent = g.unit; }); + + const sel = state.selDay[g.id] != null ? state.selDay[g.id] : t; + const dotsWrap = el.querySelector('.dots-wrap'); + for (let i = 0; i < g.days; i++) { + const it = i === t, iy = i === t - 1, is = sel === i, ed = editable(g, i); + const dot = tpl('tpl-dot'); + dot.className = dcls(g, i) + (is ? ' rs' : it ? ' rt' : iy && t > 0 ? ' ry' : ''); + if (ed) { dot.dataset.g = g.id; dot.dataset.d = i; } + dot.textContent = dlbl(g, i); + dotsWrap.appendChild(dot); + } + + if (sel != null) el.insertBefore(buildPanel(g, sel), el.querySelector('.card-foot')); + el.querySelector('.btn-del').dataset.g = g.id; + return el; +} + +function buildQuickBook() { + const active = state.goals.filter(g => { + const c = calc(g); + return tOff(g) < g.days && (c.buf < 0 || (c.tdone < g.daily && c.buf < g.daily)); + }); + if (!active.length) return null; + const frag = document.createDocumentFragment(); + const lbl = document.createElement('div'); lbl.className = 'sec-lbl'; lbl.textContent = tr('qbLabel'); + frag.appendChild(lbl); + const card = document.createElement('div'); card.className = 'card qb-card'; + for (const g of active) { + const c = calc(g), k = g.id + '_' + c.tOff; + const row = tpl('tpl-qb-row'); + row.querySelector('.qb-name').textContent = g.name; + const stat = row.querySelector('.qb-stat'); stat.textContent = c.tdone + '/' + g.daily; stat.style.color = heuteColor(c.tdone, g.daily); + const inp = row.querySelector('.num-in'); + inp.placeholder = g.daily; inp.value = state.addAmt[k] || ''; inp.dataset.k = k; inp.dataset.g = g.id; inp.dataset.o = c.tOff; + const btn = row.querySelector('.btn-as'); btn.dataset.g = g.id; btn.dataset.o = c.tOff; + card.appendChild(row); + } + frag.appendChild(card); + return frag; +} + +function calcAwards() { + let units = 0; + for (const g of state.goals) { + if (tOff(g) >= g.days) units += Math.floor(g.days / 30); + } + const gold = Math.floor(units / 25); units %= 25; + const silver = Math.floor(units / 5); const bronze = units % 5; + return { gold, silver, bronze }; +} + +export function render() { + const m = document.getElementById('main'); + const frag = document.createDocumentFragment(); + + if (!state.prefs.hd) { + const hint = tpl('tpl-hint'); + hint.querySelector('.hclose').onclick = () => { state.prefs.hd = 1; savePrefs(); hint.remove(); }; + frag.appendChild(hint); + } + + const aw = calcAwards(); + if (aw.gold || aw.silver || aw.bronze) { + const awards = document.createElement('div'); awards.className = 'awards'; + for (const [emoji, count] of [['๐Ÿฅ‡', aw.gold], ['๐Ÿฅˆ', aw.silver], ['๐Ÿฅ‰', aw.bronze]]) { + for (let i = 0; i < count; i++) { + const sp = document.createElement('span'); sp.className = 'aw'; sp.textContent = emoji; + awards.appendChild(sp); + } + } + frag.appendChild(awards); + } + + if (!state.goals.length) { + frag.appendChild(tpl('tpl-empty')); + m.innerHTML = ''; m.appendChild(frag); wire(); return; + } + + if (state.userName) { + const gr = document.createElement('div'); gr.className = 'greeting'; + gr.textContent = tr('hello').replace('{n}', state.userName); + frag.appendChild(gr); + } + + const qb = buildQuickBook(); if (qb) frag.appendChild(qb); + + const open = [], done = []; + for (const g of state.goals) { + if (calc(g).ok) done.push(g); else open.push(g); + } + if (open.length) { + const sl = document.createElement('div'); sl.className = 'sec-lbl'; sl.textContent = tr('openLabel'); + frag.appendChild(sl); + for (const g of open) frag.appendChild(buildCard(g)); + } + if (done.length) { + const sl2 = document.createElement('div'); sl2.className = 'sec-lbl'; sl2.textContent = tr('doneToday'); + frag.appendChild(sl2); + for (const g of done) frag.appendChild(buildCard(g)); + } + + m.innerHTML = ''; m.appendChild(frag); wire(); +} + +function wire() { + document.querySelectorAll('.card-hdr[data-g]').forEach(el => { + el.onclick = function(e) { + if (e.target.classList.contains('btn-ren') || e.target.classList.contains('ren-input')) return; + toggleCollapse(this.dataset.g); render(); + }; + }); + document.querySelectorAll('.btn-ren').forEach(b => { + b.onclick = function(e) { e.stopPropagation(); startRen(this.dataset.g); }; + }); + document.querySelectorAll('.ren-input').forEach(inp => { + const gid = inp.dataset.g; + inp.oninput = function() { state.renameVal = this.value; }; + inp.onkeydown = function(e) { if (e.key === 'Enter') commitRen(gid); if (e.key === 'Escape') cancelRen(); }; + inp.onblur = function() { commitRen(gid); }; + }); + document.querySelectorAll('.de').forEach(d => { + d.onclick = function(e) { e.stopPropagation(); selD(this.dataset.g, parseInt(this.dataset.d, 10)); }; + }); + document.querySelectorAll('.btn-as').forEach(b => { + b.onclick = function() { addSet(this.dataset.g, parseInt(this.dataset.o, 10)); }; + }); + document.querySelectorAll('.num-in').forEach(inp => { + const k = inp.dataset.k, g = inp.dataset.g, o = parseInt(inp.dataset.o, 10); + inp.oninput = function() { state.addAmt[k] = this.value; }; + inp.onkeydown = function(e) { if (e.key === 'Enter') addSet(g, o); }; + }); + document.querySelectorAll('.sdel').forEach(b => { + b.onclick = function() { remSet(this.dataset.g, parseInt(this.dataset.o, 10), parseInt(this.dataset.i, 10)); }; + }); + document.querySelectorAll('.btn-del').forEach(b => { + b.onclick = function() { delGoal(this.dataset.g); }; + }); +}