Allow viewing past days + enforce edit cutoff server-side

- Clicking any past day dot now opens a stats panel (read-only for days older than yesterday)
- Entry form and delete buttons hidden for non-editable days
- Backend silently restores locked offsets (< yesterday) on PATCH, preventing backdated edits
- Negative buffer no longer shows green: badge and progress bar are amber/red when buf < 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kühn 2026-05-11 19:04:36 +02:00
parent 526d851eef
commit da6eed8803
2 changed files with 32 additions and 17 deletions

View file

@ -37,7 +37,7 @@ function delGoal(id) {
function selD(gid, off) { function selD(gid, off) {
const g = state.goals.find(x => x.id === gid); const g = state.goals.find(x => x.id === gid);
if (!g || !editable(g, off)) return; if (!g) return;
state.selDay[gid] = state.selDay[gid] === off ? null : off; state.selDay[gid] = state.selDay[gid] === off ? null : off;
render(); render();
} }
@ -70,10 +70,11 @@ function buildNameWrap(g) {
} }
function buildPanel(g, off) { function buildPanel(g, off) {
const t = tOff(g), sets = g.sets[String(off)] || [], tot = dTot(g, off); const t = tOff(g), sets = g.sets[String(off)] || [], tot = dTot(g, off), ed = editable(g, off);
const lbl = off === t ? tr('heute') : tr('gestern'), k = g.id + '_' + off; const lbl = off === t ? tr('heute') : off === t - 1 ? tr('gestern') : null;
const k = g.id + '_' + off;
const el = tpl('tpl-panel'); const el = tpl('tpl-panel');
el.querySelector('.dpanel-title').textContent = lbl + ' — ' + fd(o2d(g, off)); el.querySelector('.dpanel-title').textContent = (lbl ? lbl + ' — ' : '') + fd(o2d(g, off));
el.querySelector('.dpanel-sub').textContent = tot + ' / ' + g.daily + ' ' + g.unit; el.querySelector('.dpanel-sub').textContent = tot + ' / ' + g.daily + ' ' + g.unit;
const body = el.querySelector('.dpanel-body'); const body = el.querySelector('.dpanel-body');
if (sets.length) { if (sets.length) {
@ -86,12 +87,13 @@ function buildPanel(g, off) {
const strong = document.createElement('strong'); strong.textContent = s.amount; const strong = document.createElement('strong'); strong.textContent = s.amount;
span.appendChild(strong); span.appendChild(document.createTextNode(' ' + g.unit)); span.appendChild(strong); span.appendChild(document.createTextNode(' ' + g.unit));
const btn = row.querySelector('.sdel'); const btn = row.querySelector('.sdel');
btn.dataset.g = g.id; btn.dataset.o = off; btn.dataset.i = i; if (ed) { btn.dataset.g = g.id; btn.dataset.o = off; btn.dataset.i = i; } else { btn.remove(); }
body.appendChild(row); body.appendChild(row);
} }
} else { } else {
body.appendChild(tpl('tpl-nosets')); body.appendChild(tpl('tpl-nosets'));
} }
if (ed) {
const addRow = tpl('tpl-add-row'); const addRow = tpl('tpl-add-row');
const inp = addRow.querySelector('.num-in'); 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; inp.placeholder = g.daily; inp.value = state.addAmt[k] || ''; inp.dataset.k = k; inp.dataset.g = g.id; inp.dataset.o = off;
@ -99,16 +101,17 @@ function buildPanel(g, off) {
abtn.dataset.g = g.id; abtn.dataset.o = off; abtn.dataset.g = g.id; abtn.dataset.o = off;
addRow.querySelector('.ulbl').textContent = g.unit; addRow.querySelector('.ulbl').textContent = g.unit;
body.appendChild(addRow); body.appendChild(addRow);
}
return el; return el;
} }
function buildCard(g) { function buildCard(g) {
const c = calc(g), t = c.tOff; 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)'; const fc = c.surplus > 0 ? 'var(--blue)' : c.st === 0 && c.buf >= 0 ? 'var(--green)' : c.dailyDelta <= 0 ? 'var(--green)' : c.dailyDelta <= g.daily * .2 ? 'var(--amber)' : 'var(--red)';
let bc, bt; let bc, bt;
const bufStr = (c.buf > 0 ? '+' : '') + c.buf; const bufStr = (c.buf > 0 ? '+' : '') + c.buf;
if (c.ok && c.surplus > 0) { bc = 'b-buf'; bt = bufStr; } if (c.ok && c.surplus > 0) { bc = 'b-buf'; bt = bufStr; }
else if (c.ok) { bc = 'b-done'; bt = bufStr; } else if (c.ok && c.buf >= 0) { bc = 'b-done'; bt = bufStr; }
else if (c.dailyDelta <= 0) { bc = 'b-ok'; 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 if (c.dailyDelta <= g.daily * .2) { bc = 'b-warn'; bt = bufStr; }
else { bc = 'b-danger'; bt = bufStr; } else { bc = 'b-danger'; bt = bufStr; }
@ -153,7 +156,7 @@ function buildCard(g) {
const it = i === t, iy = i === t - 1, is = sel === i, ed = editable(g, i); const it = i === t, iy = i === t - 1, is = sel === i, ed = editable(g, i);
const dot = tpl('tpl-dot'); const dot = tpl('tpl-dot');
dot.className = dcls(g, i) + (is ? ' rs' : it ? ' rt' : iy && t > 0 ? ' ry' : ''); dot.className = dcls(g, i) + (is ? ' rs' : it ? ' rt' : iy && t > 0 ? ' ry' : '');
if (ed) { dot.dataset.g = g.id; dot.dataset.d = i; } if (i <= t) { dot.dataset.g = g.id; dot.dataset.d = i; }
dot.textContent = dlbl(g, i); dot.textContent = dlbl(g, i);
dotsWrap.appendChild(dot); dotsWrap.appendChild(dot);
} }
@ -266,7 +269,7 @@ function wire() {
inp.onkeydown = function(e) { if (e.key === 'Enter') commitRen(gid); if (e.key === 'Escape') cancelRen(); }; inp.onkeydown = function(e) { if (e.key === 'Enter') commitRen(gid); if (e.key === 'Escape') cancelRen(); };
inp.onblur = function() { commitRen(gid); }; inp.onblur = function() { commitRen(gid); };
}); });
document.querySelectorAll('.de').forEach(d => { document.querySelectorAll('.de, .dl').forEach(d => {
d.onclick = function(e) { e.stopPropagation(); selD(this.dataset.g, parseInt(this.dataset.d, 10)); }; d.onclick = function(e) { e.stopPropagation(); selD(this.dataset.g, parseInt(this.dataset.d, 10)); };
}); });
document.querySelectorAll('.btn-as').forEach(b => { document.querySelectorAll('.btn-as').forEach(b => {

View file

@ -84,7 +84,19 @@ class GoalController extends AbstractController
if (isset($data['unit'])) $goal->setUnit((string)$data['unit']); if (isset($data['unit'])) $goal->setUnit((string)$data['unit']);
if (isset($data['daily'])) $goal->setDaily((float)$data['daily']); if (isset($data['daily'])) $goal->setDaily((float)$data['daily']);
if (isset($data['days'])) $goal->setDays((int)$data['days']); if (isset($data['days'])) $goal->setDays((int)$data['days']);
if (isset($data['sets'])) $goal->setSets((array)$data['sets']); if (isset($data['sets'])) {
$newSets = (array)$data['sets'];
$existing = $goal->getSets();
$startTs = (clone $goal->getStart())->setTime(0, 0, 0)->getTimestamp();
$todayOffset = (int)round((mktime(0, 0, 0) - $startTs) / 86400);
foreach ($existing as $offset => $entries) {
if ((int)$offset < $todayOffset - 1) $newSets[(string)$offset] = $entries;
}
foreach (array_keys($newSets) as $offset) {
if ((int)$offset < $todayOffset - 1 && !array_key_exists((string)$offset, $existing)) unset($newSets[$offset]);
}
$goal->setSets($newSets);
}
$this->em->flush(); $this->em->flush();
return new JsonResponse(['ok' => true]); return new JsonResponse(['ok' => true]);