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:
@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user