Files
pointeuse-optimisator/app/templates/stats.html
Antoine 761a86986a 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>
2026-07-16 14:12:03 +02:00

652 lines
26 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block content %}
<div class="stats-head">
<div>
<div class="stats-title">Statistiques</div>
<div class="stats-meta">
{{ stats.n_jours }} jours complets analysés
<span class="stats-sep">·</span>
{{ stats.total_weeks }} semaines
{% if stats.avg_delta_min != 0 %}
<span class="stats-sep">·</span>
solde moyen
<span class="{{ 'stat-pos' if stats.avg_delta_min > 0 else 'stat-neg' }}">{{ stats.avg_delta }}/sem</span>
{% endif %}
</div>
</div>
</div>
{# ── 3 période-conformité cards ── #}
<div class="comp-periods">
{% set periods = [
(stats.comp_globale, "Conformité globale", "Toutes périodes"),
(stats.comp_mois, "Ce mois", stats.mois_label),
(stats.comp_sem_n1, "Semaine précédente", stats.prev_week_label),
] %}
{% for comp, title, sub in periods %}
{% set pct = comp.pct %}
{% set cls = 'comp-ok' if pct is not none and pct >= 95 else ('comp-warn' if pct is not none and pct >= 80 else 'comp-bad') %}
<div class="comp-period-card">
<div class="cp-eyebrow">{{ title }}</div>
<div class="cp-sub">{{ sub }}</div>
<div class="cp-donut-wrap">
<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 ~ '%') 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
{% else %}<span class="cp-nodata">pas de données</span>{% endif %}
</div>
</div>
{% endfor %}
</div>
{# ── Onglets de période ── #}
<div class="period-tabs-wrap">
<span class="period-tabs-label">Période d'analyse</span>
<div class="period-tabs" role="tablist">
<button class="ptab active" data-period="all" role="tab" aria-selected="true">Tout</button>
<button class="ptab" data-period="1y" role="tab">1&thinsp;an</button>
<button class="ptab" data-period="3m" role="tab">3&thinsp;mois</button>
<button class="ptab" data-period="1m" role="tab">1&thinsp;mois</button>
</div>
</div>
{# ── 4 time-slot cards ── #}
<div class="ts-grid">
{% set slot_meta = [
("matin_entree", "Entrée matin", "≤ 09:00", "le", "matin"),
("matin_sortie", "Sortie matin", "≥ 12:00", "ge", "matin"),
("aprem_entree", "Entrée après-midi","≤ 14:00", "le", "aprem"),
("aprem_sortie", "Sortie après-midi","≥ 17:00", "ge", "aprem"),
] %}
{% for slot, label, constraint, op, half in slot_meta %}
{% set ts = stats.time_stats[slot] %}
{% set comp = stats.compliance[slot] %}
<div class="ts-card ts-{{ half }}">
<div class="ts-eyebrow">{{ label }}</div>
{% if ts %}
<div class="ts-mean-row">
<div class="ts-mean" id="ts-mean-{{ slot }}">{{ ts.mean_hhmm }}</div>
{% if comp is not none %}
<div class="donut-wrap">
<canvas id="donut-{{ slot }}" class="donut-canvas" width="60" height="60"
data-pct="{{ comp }}" data-op="{{ op }}"
aria-label="{{ comp }}% {{ constraint }}"></canvas>
<div class="donut-label" id="donut-label-{{ slot }}">{{ constraint }}</div>
</div>
{% endif %}
</div>
<div class="ts-sigma" id="ts-sigma-{{ slot }}">σ&thinsp;=&thinsp;{{ ts.sigma_min }}&nbsp;min&ensp;·&ensp;n&thinsp;=&thinsp;{{ ts.n }}</div>
<canvas class="dist-canvas zoomable" id="dist-{{ slot }}" width="1" height="76"
data-chart-type="dist" data-chart-slot="{{ slot }}"
data-chart-title="{{ label }}"
aria-label="Distribution {{ label }}"></canvas>
<div class="dist-legend">
<span class="dl-item"><span class="dl-mean-line"></span>μ&thinsp;=&thinsp;<span id="dl-mean-{{ slot }}">{{ ts.mean_hhmm }}</span></span>
<span class="dl-item"><span class="dl-target-line {{ 'dl-neg' if op == 'le' else 'dl-pos' }}"></span>{{ constraint }}</span>
<span class="dl-item"><span class="dl-band ts-{{ half }}"></span>±1σ&thinsp;(<span id="dl-sigma-{{ slot }}">{{ ts.sigma_min }}</span>&thinsp;min)</span>
<span class="dl-item dl-n">n&thinsp;=&thinsp;<span id="dl-n-{{ slot }}">{{ ts.n }}</span>&thinsp;jours</span>
</div>
{% else %}
<div class="ts-mean ts-empty"></div>
<div class="ts-sigma">pas de données</div>
{% endif %}
</div>
{% endfor %}
</div>
{# ── Weekly balance chart ── #}
{% if stats.weekly_balances %}
<div class="wkly-section">
<div class="wkly-eyebrow">Soldes hebdomadaires <span class="wkly-note">(jours incomplets exclus)</span></div>
<div class="wkly-wrap">
<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 %}
<div class="wkly-legend">
<span class="legend-dot partial-dot"></span> semaine avec jours non renseignés
</div>
{% endif %}
</div>
{% endif %}
{# ── Zoom modal ── #}
<div id="chart-modal" class="chart-modal" hidden aria-modal="true" role="dialog" aria-labelledby="chart-modal-title">
<div class="chart-modal-bd" id="chart-modal-bd"></div>
<div class="chart-modal-inner">
<div class="chart-modal-header">
<span id="chart-modal-title" class="chart-modal-title"></span>
<button class="chart-modal-close" id="chart-modal-close" aria-label="Fermer"></button>
</div>
<div class="chart-modal-body">
<canvas id="chart-modal-canvas"></canvas>
<div class="chart-tooltip" id="modal-tooltip"></div>
</div>
</div>
</div>
<script>
(function () {
const DATA = {{ data_json | safe }};
const C = {
pos: '#2A5E42', neg: '#9B3528',
accent: '#2F5FA0', accent2: '#B86A08', warn: '#B86A08',
rule: '#D8D0C0', ruleL: '#EAE5DA',
text: '#1C1712', muted: '#6B6152',
ground: '#F4F0E8', surface: '#FDFAF4',
};
const MONO = "'DM Mono', 'Cascadia Code', ui-monospace, monospace";
const SLOTS = ['matin_entree', 'matin_sortie', 'aprem_entree', 'aprem_sortie'];
const SLOT_COLOR = { matin_entree: C.accent, matin_sortie: C.accent, aprem_entree: C.accent2, aprem_sortie: C.accent2 };
const SLOT_TARGET = { matin_entree: [9*60,'le'], matin_sortie: [12*60,'ge'], aprem_entree: [14*60,'le'], aprem_sortie: [17*60,'ge'] };
let currentPeriod = 'all';
/* ─── Period filtering ─── */
function getCutoff(period) {
if (period === 'all') return null;
const d = new Date(DATA.today);
if (period === '1m') d.setMonth(d.getMonth() - 1);
else if (period === '3m') d.setMonth(d.getMonth() - 3);
else if (period === '1y') d.setFullYear(d.getFullYear() - 1);
return d;
}
function filterVals(slot, period) {
const dv = DATA.time_stats[slot]?.dated_values;
if (!dv) return [];
const cutoff = getCutoff(period);
return cutoff
? dv.filter(x => new Date(x.d) >= cutoff).map(x => x.v)
: dv.map(x => x.v);
}
function filterWeekly(period) {
const cutoff = getCutoff(period);
if (!cutoff) return DATA.weekly_balances;
return DATA.weekly_balances.filter(b => new Date(b.date) >= cutoff);
}
/* ─── Stats helpers ─── */
function _mean(arr) { return arr.length ? arr.reduce((a,b) => a+b,0) / arr.length : 0; }
function _sigma(arr) {
if (arr.length < 2) return 0;
const m = _mean(arr);
return Math.sqrt(arr.reduce((a,b) => a+(b-m)**2, 0) / (arr.length-1));
}
function _fmtMin(m) {
const h = Math.floor(m/60), mm = m%60;
return `${h.toString().padStart(2,'0')}:${mm.toString().padStart(2,'0')}`;
}
function _compPct(vals, target, op) {
if (!vals.length) return null;
const ok = vals.filter(v => op === 'le' ? v <= target : v >= target).length;
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;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
const cx = W/2, cy = H/2;
const outerR = Math.min(W,H)/2 - 2;
const lw = outerR * 0.38;
const r = outerR - lw/2;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, 2*Math.PI);
ctx.strokeStyle = C.ruleL; ctx.lineWidth = lw; ctx.stroke();
if (pct === null) {
ctx.fillStyle = C.muted; ctx.font = `400 ${Math.round(W*0.19)}px ${MONO}`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('—', cx, cy);
return;
}
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 = pct >= 100 ? 'butt' : 'round'; ctx.stroke();
if (progress >= 1) {
ctx.fillStyle = color;
ctx.font = `500 ${Math.round(W*0.19)}px ${MONO}`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(pct + '%', cx, cy);
}
}
function animateDonut(canvas, pct, op) {
if (pct === null) { drawDonut(canvas, null, op, 1); return; }
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { drawDonut(canvas, pct, op, 1); return; }
const DUR = 600; let t0 = null;
function step(ts) {
if (!t0) t0 = ts;
const p = Math.min((ts-t0)/DUR, 1);
drawDonut(canvas, pct, op, 1-Math.pow(1-p,3));
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
/* ─── Distribution core ─── */
function _drawDistVals(canvas, vals, mean, sigma, target, op, color, enlarged) {
if (!vals || !vals.length) return;
const W = canvas.width, H = canvas.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
const lo = Math.min(...vals, target) - 15;
const hi = Math.max(...vals, target) + 15;
const range = hi - lo || 1;
const toX = v => ((v - lo) / range) * W;
const PAD_T = 4, PAD_B = H > 60 ? 20 : 4, chartH = H - PAD_T - PAD_B;
const targetColor = op === 'le' ? C.neg : C.pos;
const binSize = 5;
const nBins = Math.ceil((hi-lo)/binSize) + 1;
const bins = new Array(nBins).fill(0);
vals.forEach(v => { const b = Math.min(Math.floor((v-lo)/binSize), nBins-1); bins[b]++; });
const maxBin = Math.max(...bins, 1);
// σ band
ctx.fillStyle = color === C.accent ? 'rgba(47,95,160,.09)' : 'rgba(184,106,8,.09)';
const sLo = Math.max(0, toX(mean-sigma)), sHi = Math.min(W, toX(mean+sigma));
ctx.fillRect(sLo, 0, sHi-sLo, PAD_T+chartH);
// Bars
const bw = Math.max(1, (W/nBins) - 0.5);
bins.forEach((count, i) => {
if (!count) return;
const x = (i/nBins)*W, bh = (count/maxBin)*chartH;
ctx.fillStyle = color + 'BB';
drawBarFromBaseline(ctx, x, bw, PAD_T + chartH, PAD_T + chartH - bh, 2);
});
// Baseline
ctx.strokeStyle = C.rule; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(0, PAD_T+chartH); ctx.lineTo(W, PAD_T+chartH); ctx.stroke();
// Target line
const tx = toX(target);
if (tx >= 0 && tx <= W) {
ctx.save(); ctx.strokeStyle = targetColor; ctx.lineWidth = 1.5; ctx.setLineDash([3,3]);
ctx.beginPath(); ctx.moveTo(tx, PAD_T); ctx.lineTo(tx, PAD_T+chartH); ctx.stroke(); ctx.restore();
}
// Mean line
ctx.strokeStyle = C.text; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(toX(mean), PAD_T); ctx.lineTo(toX(mean), PAD_T+chartH); ctx.stroke();
// X axis labels
if (PAD_B > 10) {
const tickStep = enlarged ? 15 : 30;
const firstTick = Math.ceil(lo/tickStep)*tickStep;
const labelY = PAD_T+chartH+5;
const fs = Math.max(7, Math.min(9, Math.floor(W/28)));
ctx.textBaseline = 'top';
let ticks;
if (enlarged) {
ticks = new Set();
for (let t = firstTick; t <= hi; t += tickStep) {
if (toX(t) >= 2 && toX(t) <= W-2) ticks.add(t);
}
} else {
ticks = new Set([Math.round(target/tickStep)*tickStep]);
let lastX = -999;
for (let t = firstTick; t <= hi; t += tickStep) {
const x = toX(t);
if (x >= 4 && x <= W-4 && x-lastX >= 30) { ticks.add(t); lastX = x; }
}
}
ticks.forEach(t => {
const x = toX(t);
if (x < 0 || x > W) return;
const isTarget = (t === target);
const isMean = !isTarget && Math.abs(t - Math.round(mean/tickStep)*tickStep) < 2;
ctx.textAlign = x < 16 ? 'left' : (x > W-16 ? 'right' : 'center');
ctx.fillStyle = isTarget ? targetColor : (isMean ? C.text : C.muted);
ctx.font = `${isTarget||isMean?'500':'400'} ${fs}px ${MONO}`;
ctx.strokeStyle = isTarget ? targetColor : C.rule;
ctx.lineWidth = isTarget ? 1 : 0.5;
ctx.beginPath(); ctx.moveTo(x, PAD_T+chartH); ctx.lineTo(x, PAD_T+chartH+3); ctx.stroke();
ctx.fillText(_fmtMin(t), x, labelY);
});
}
}
function _drawDist(canvas, slot, enlarged) {
const stat = DATA.time_stats[slot];
if (!stat) return;
const vals = filterVals(slot, currentPeriod);
if (!vals.length) return;
const mean = _mean(vals), sigma = _sigma(vals);
const [target, op] = SLOT_TARGET[slot];
_drawDistVals(canvas, vals, mean, sigma, target, op, SLOT_COLOR[slot], enlarged);
}
function drawDist(slot) {
const c = document.getElementById('dist-'+slot);
if (!c) return;
c.width = c.parentElement.clientWidth || 240;
_drawDist(c, slot, false);
}
/* ─── Weekly bars ─── */
function _drawWeekly(canvas, balances, hoverIndex) {
if (!balances || !balances.length) return;
const W = canvas.width, H = canvas.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
const PAD_L = 42, PAD_R = 12, PAD_T = 12, PAD_B = 42;
const chartW = W-PAD_L-PAD_R, chartH = H-PAD_T-PAD_B;
const midY = PAD_T+chartH/2;
const maxAbs = Math.max(...balances.map(b => Math.abs(b.delta_min)), 1);
const axisMax = Math.ceil(maxAbs/30)*30;
const toY = v => midY - (v/axisMax)*(chartH/2);
const barSlot = chartW/balances.length;
const barW = Math.max(4, Math.min(32, barSlot-3));
ctx.fillStyle = C.surface; ctx.fillRect(PAD_L, PAD_T, chartW, chartH);
ctx.strokeStyle = C.ruleL; ctx.lineWidth = 0.5;
for (let v = 30; v <= axisMax; v += 30) {
[toY(v), toY(-v)].forEach(y => {
ctx.beginPath(); ctx.moveTo(PAD_L,y); ctx.lineTo(PAD_L+chartW,y); ctx.stroke();
});
}
ctx.strokeStyle = C.rule; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PAD_L,midY); ctx.lineTo(PAD_L+chartW,midY); ctx.stroke();
ctx.fillStyle = C.muted; ctx.font = `9px ${MONO}`; ctx.textAlign = 'right';
for (let v = 60; v <= axisMax; v += 60) {
const h = Math.floor(v/60);
ctx.fillText(`+${h}h`, PAD_L-5, toY(v)+3);
ctx.fillText(`-${h}h`, PAD_L-5, toY(-v)+3);
}
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 = 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+(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+(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';
balances.forEach((b, i) => {
const x = PAD_L+i*barSlot+barSlot/2, y = PAD_T+chartH+8;
ctx.save(); ctx.translate(x,y); ctx.rotate(-Math.PI/4); ctx.fillText(b.label,0,0); ctx.restore();
});
ctx.strokeStyle = C.rule; ctx.lineWidth = 1;
ctx.strokeRect(PAD_L, PAD_T, chartW, chartH);
}
function drawWeekly() {
const c = document.getElementById('c-weekly');
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;
}
const DUR = 500; let t0 = null;
function step(ts) {
if (!t0) t0 = ts;
const p = Math.min((ts-t0)/DUR, 1);
const ease = 1-Math.pow(1-p,3);
// Animate bar heights by scaling delta_min
const scaled = balances.map(b => ({...b, delta_min: b.delta_min * ease}));
_drawWeekly(c, scaled);
if (p < 1) requestAnimationFrame(step);
}
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;
SLOTS.forEach(slot => {
if (!DATA.time_stats[slot]) return;
const vals = filterVals(slot, period);
const [target, op] = SLOT_TARGET[slot];
if (!vals.length) {
const meanEl = document.getElementById('ts-mean-'+slot);
if (meanEl) meanEl.textContent = '—';
const sigmaEl = document.getElementById('ts-sigma-'+slot);
if (sigmaEl) sigmaEl.textContent = 'pas de données pour cette période';
const donutEl = document.getElementById('donut-'+slot);
if (donutEl) drawDonut(donutEl, null, op, 1);
return;
}
const m = _mean(vals), s = _sigma(vals);
const pct = _compPct(vals, target, op);
// Update text
const meanEl = document.getElementById('ts-mean-'+slot);
if (meanEl) meanEl.textContent = _fmtMin(Math.round(m));
const sigmaEl = document.getElementById('ts-sigma-'+slot);
if (sigmaEl) sigmaEl.textContent = `σ=${Math.round(s)} min·n=${vals.length}`;
// Update legend
const dlMean = document.getElementById('dl-mean-'+slot);
if (dlMean) dlMean.textContent = _fmtMin(Math.round(m));
const dlSigma = document.getElementById('dl-sigma-'+slot);
if (dlSigma) dlSigma.textContent = Math.round(s);
const dlN = document.getElementById('dl-n-'+slot);
if (dlN) dlN.textContent = vals.length;
// Redraw distribution
const c = document.getElementById('dist-'+slot);
if (c) {
c.width = c.parentElement.clientWidth || 240;
_drawDistVals(c, vals, m, s, target, op, SLOT_COLOR[slot], false);
}
// Redraw donut
const donutEl = document.getElementById('donut-'+slot);
if (donutEl) drawDonut(donutEl, pct, op, 1);
});
drawWeekly();
}
/* ─── Zoom modal ─── */
function openModal(sourceCanvas) {
const type = sourceCanvas.dataset.chartType;
const slot = sourceCanvas.dataset.chartSlot;
const title = sourceCanvas.dataset.chartTitle || '';
const mc = document.getElementById('chart-modal-canvas');
const maxW = Math.floor(Math.min(window.innerWidth*0.88, 1050));
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;
const balances = filterWeekly(currentPeriod);
mc.__wklyBalances = balances;
_drawWeekly(mc, balances);
attachWeeklyHover(mc, document.getElementById('modal-tooltip'));
}
document.getElementById('chart-modal').removeAttribute('hidden');
document.body.style.overflow = 'hidden';
document.getElementById('chart-modal-close').focus();
}
function closeModal() {
document.getElementById('chart-modal').setAttribute('hidden', '');
document.body.style.overflow = '';
}
/* ─── Init ─── */
function init() {
[1,2,3].forEach(i => {
const el = document.getElementById('cpd-'+i);
if (!el) return;
el.dataset.hasData === 'true'
? animateDonut(el, parseInt(el.dataset.pct), 'day')
: (function(){
const ctx = el.getContext('2d');
const W=el.width,H=el.height,cx=W/2,cy=H/2;
const r=(Math.min(W,H)/2-2)*0.69;
ctx.beginPath(); ctx.arc(cx,cy,r,0,2*Math.PI);
ctx.strokeStyle=C.ruleL; ctx.lineWidth=(Math.min(W,H)/2-2)*0.38; ctx.stroke();
ctx.fillStyle=C.muted; ctx.font=`400 13px ${MONO}`;
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('—',cx,cy);
})();
});
SLOTS.forEach(slot => {
drawDist(slot);
const el = document.getElementById('donut-'+slot);
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') {
document.addEventListener('DOMContentLoaded', init);
} else { init(); }
// Tabs
document.querySelectorAll('.ptab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.ptab').forEach(b => {
b.classList.remove('active'); b.setAttribute('aria-selected','false');
});
btn.classList.add('active'); btn.setAttribute('aria-selected','true');
updateForPeriod(btn.dataset.period);
});
});
// Zoom
document.addEventListener('click', e => { const c = e.target.closest('canvas.zoomable'); if (c) openModal(c); });
document.getElementById('chart-modal-close').addEventListener('click', closeModal);
document.getElementById('chart-modal-bd').addEventListener('click', closeModal);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
let resizeTimer;
window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(init, 120); });
})();
</script>
{% endblock %}