Projette les cibles en temps réel et corrige le fuseau horaire

Calcule les heures travaillées et cibles d'entrée/sortie même quand une
demi-journée est en cours (pas encore de pointage de sortie), au lieu
d'attendre la fin de journée. Ajoute un endpoint /refresh pour
rafraîchir l'affichage sans repointer. Désambiguïse les libellés de
semaine sur plusieurs années et force TZ=Europe/Paris dans le conteneur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Antoine
2026-07-16 14:12:03 +02:00
parent b6e0fcd925
commit 761a86986a
8 changed files with 400 additions and 24 deletions

View File

@ -34,7 +34,7 @@
<canvas class="cp-donut" id="cpd-{{ loop.index }}" width="90" height="90"
data-pct="{{ pct if pct is not none else 0 }}"
data-has-data="{{ 'true' if pct is not none else 'false' }}"
aria-label="{{ pct }}%"></canvas>
aria-label="{{ (pct ~ '%') if pct is not none else 'pas de données' }}"></canvas>
</div>
<div class="cp-n {{ cls if pct is not none }}">
{% if pct is not none %}{{ comp.n_ok }}/{{ comp.n }} jours conformes
@ -107,6 +107,7 @@
<canvas id="c-weekly" class="zoomable" height="180"
data-chart-type="weekly" data-chart-title="Soldes hebdomadaires"
aria-label="Soldes hebdomadaires"></canvas>
<div class="chart-tooltip" id="wkly-tooltip"></div>
</div>
{% set has_partial = stats.weekly_balances | selectattr("partial") | list %}
{% if has_partial %}
@ -127,6 +128,7 @@
</div>
<div class="chart-modal-body">
<canvas id="chart-modal-canvas"></canvas>
<div class="chart-tooltip" id="modal-tooltip"></div>
</div>
</div>
</div>
@ -191,6 +193,34 @@
return Math.round(100 * ok / vals.length);
}
/* ─── Rounded bar helper: flat at the baseline, rounded at the growing end ─── */
function drawBarFromBaseline(ctx, x, w, baseline, far, r) {
const grows_up = far < baseline;
const top = grows_up ? far : baseline;
const h = Math.abs(baseline - far);
r = Math.max(0, Math.min(r, w / 2, h));
if (r <= 0.5 || h <= 0) { ctx.fillRect(x, top, w, h); return; }
ctx.beginPath();
if (grows_up) {
ctx.moveTo(x, top + h);
ctx.lineTo(x, top + r);
ctx.arcTo(x, top, x + r, top, r);
ctx.lineTo(x + w - r, top);
ctx.arcTo(x + w, top, x + w, top + r, r);
ctx.lineTo(x + w, top + h);
} else {
ctx.moveTo(x, top);
ctx.lineTo(x + w, top);
ctx.lineTo(x + w, top + h - r);
ctx.arcTo(x + w, top + h, x + w - r, top + h, r);
ctx.lineTo(x + r, top + h);
ctx.arcTo(x, top + h, x, top + h - r, r);
ctx.lineTo(x, top);
}
ctx.closePath();
ctx.fill();
}
/* ─── Donut ─── */
function drawDonut(canvas, pct, op, progress) {
const W = canvas.width, H = canvas.height;
@ -209,7 +239,7 @@
}
const color = pct >= 95 ? C.pos : (pct >= 80 ? C.warn : C.neg);
ctx.beginPath(); ctx.arc(cx, cy, r, -Math.PI/2, -Math.PI/2 + 2*Math.PI*(pct/100)*progress);
ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineCap = 'butt'; ctx.stroke();
ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineCap = pct >= 100 ? 'butt' : 'round'; ctx.stroke();
if (progress >= 1) {
ctx.fillStyle = color;
ctx.font = `500 ${Math.round(W*0.19)}px ${MONO}`;
@ -262,7 +292,7 @@
if (!count) return;
const x = (i/nBins)*W, bh = (count/maxBin)*chartH;
ctx.fillStyle = color + 'BB';
ctx.fillRect(x, PAD_T+chartH-bh, bw, bh);
drawBarFromBaseline(ctx, x, bw, PAD_T + chartH, PAD_T + chartH - bh, 2);
});
// Baseline
@ -337,7 +367,7 @@
}
/* ─── Weekly bars ─── */
function _drawWeekly(canvas, balances) {
function _drawWeekly(canvas, balances, hoverIndex) {
if (!balances || !balances.length) return;
const W = canvas.width, H = canvas.height;
const ctx = canvas.getContext('2d');
@ -371,26 +401,36 @@
}
ctx.fillText('0', PAD_L-5, midY+3);
const layout = [];
balances.forEach((b, i) => {
const x = PAD_L+i*barSlot+(barSlot-barW)/2;
const hPx = (Math.abs(b.delta_min)/axisMax)*(chartH/2);
const far = b.delta_min >= 0 ? midY-hPx : midY+hPx;
layout.push({ x, w: barW, top: Math.min(midY, far), bottom: Math.max(midY, far), b });
const hovered = i === hoverIndex;
const baseColor = b.delta_min >= 0 ? C.pos : C.neg;
const alpha = b.partial ? '55' : 'CC';
const alpha = hovered ? 'FF' : (b.partial ? '55' : 'CC');
if (b.delta_min >= 0) {
const g = ctx.createLinearGradient(x, midY-hPx, x, midY);
g.addColorStop(0, baseColor+alpha); g.addColorStop(1, baseColor+(b.partial?'33':'88'));
ctx.fillStyle = g; ctx.fillRect(x, midY-hPx, barW, hPx);
g.addColorStop(0, baseColor+alpha); g.addColorStop(1, baseColor+(hovered ? 'AA' : (b.partial?'33':'88')));
ctx.fillStyle = g; drawBarFromBaseline(ctx, x, barW, midY, midY-hPx, 4);
} else {
const g = ctx.createLinearGradient(x, midY, x, midY+hPx);
g.addColorStop(0, baseColor+(b.partial?'33':'88')); g.addColorStop(1, baseColor+alpha);
ctx.fillStyle = g; ctx.fillRect(x, midY, barW, hPx);
g.addColorStop(0, baseColor+(hovered ? 'AA' : (b.partial?'33':'88'))); g.addColorStop(1, baseColor+alpha);
ctx.fillStyle = g; drawBarFromBaseline(ctx, x, barW, midY, midY+hPx, 4);
}
if (b.partial && hPx > 2) {
const yEdge = b.delta_min >= 0 ? midY-hPx : midY+hPx;
ctx.save(); ctx.strokeStyle = baseColor+'88'; ctx.lineWidth=1; ctx.setLineDash([2,2]);
ctx.beginPath(); ctx.moveTo(x,yEdge); ctx.lineTo(x+barW,yEdge); ctx.stroke(); ctx.restore();
}
if (hovered) {
ctx.save(); ctx.strokeStyle = baseColor; ctx.lineWidth = 1.5;
ctx.strokeRect(x+0.75, Math.min(midY,far)+0.75, Math.max(0,barW-1.5), Math.max(0,hPx-1.5));
ctx.restore();
}
});
canvas.__wklyLayout = { layout, PAD_L, PAD_R, chartW };
ctx.font = `9px ${MONO}`; ctx.fillStyle = C.muted;
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
@ -408,6 +448,7 @@
if (!c) return;
c.width = c.parentElement.clientWidth || 800;
const balances = filterWeekly(currentPeriod);
c.__wklyBalances = balances;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
_drawWeekly(c, balances); return;
}
@ -424,6 +465,52 @@
requestAnimationFrame(step);
}
/* ─── Weekly hover: tooltip + bar lift, hit target = the bar's whole column ─── */
function attachWeeklyHover(canvas, tooltipEl) {
if (!canvas || !tooltipEl || canvas.__hoverAttached) return;
canvas.__hoverAttached = true;
let hoverIndex = -1;
function clear() {
const balances = canvas.__wklyBalances;
if (balances && hoverIndex !== -1) _drawWeekly(canvas, balances, -1);
hoverIndex = -1;
tooltipEl.classList.remove('visible');
}
canvas.addEventListener('mousemove', evt => {
const info = canvas.__wklyLayout, balances = canvas.__wklyBalances;
if (!info || !balances || !balances.length) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width, scaleY = canvas.height / rect.height;
const xCanvas = (evt.clientX - rect.left) * scaleX;
const { PAD_L, chartW } = info;
if (xCanvas < PAD_L || xCanvas > PAD_L + chartW) { clear(); return; }
const barSlot = chartW / balances.length;
const idx = Math.min(balances.length - 1, Math.max(0, Math.floor((xCanvas - PAD_L) / barSlot)));
if (idx !== hoverIndex) { hoverIndex = idx; _drawWeekly(canvas, balances, idx); }
const b = balances[idx];
const sign = b.delta_min > 0 ? '+' : (b.delta_min < 0 ? '' : '');
const abs = Math.abs(b.delta_min);
tooltipEl.replaceChildren();
const valSpan = document.createElement('span');
valSpan.className = 'ct-val';
valSpan.textContent = `${sign}${Math.floor(abs/60)}h${String(abs%60).padStart(2,'0')}`;
const labelSpan = document.createElement('span');
labelSpan.className = 'ct-label';
labelSpan.textContent = b.label + (b.partial ? ' · incomplète' : '');
tooltipEl.append(valSpan, labelSpan);
tooltipEl.classList.add('visible');
const entry = canvas.__wklyLayout.layout[idx];
tooltipEl.style.left = ((entry.x + entry.w/2) / scaleX) + 'px';
tooltipEl.style.top = (entry.top / scaleY - 6) + 'px';
});
canvas.addEventListener('mouseleave', clear);
}
/* ─── Update everything for a period ─── */
function updateForPeriod(period) {
currentPeriod = period;
@ -485,12 +572,17 @@
document.getElementById('chart-modal-title').textContent = title;
document.getElementById('modal-tooltip').classList.remove('visible');
if (type === 'dist') {
mc.width = maxW; mc.height = 180;
mc.__wklyBalances = null;
_drawDist(mc, slot, true);
} else if (type === 'weekly') {
mc.width = maxW; mc.height = 300;
_drawWeekly(mc, filterWeekly(currentPeriod));
const balances = filterWeekly(currentPeriod);
mc.__wklyBalances = balances;
_drawWeekly(mc, balances);
attachWeeklyHover(mc, document.getElementById('modal-tooltip'));
}
document.getElementById('chart-modal').removeAttribute('hidden');
@ -527,6 +619,7 @@
if (el) animateDonut(el, parseInt(el.dataset.pct), el.dataset.op);
});
drawWeekly();
attachWeeklyHover(document.getElementById('c-weekly'), document.getElementById('wkly-tooltip'));
}
if (document.readyState === 'loading') {