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

@ -1,6 +1,8 @@
"""Business logic: compute worked hours and expected hours per day/week."""
from datetime import datetime, timedelta
APREM_DEBUT_MIN = 13 * 60 + 30 # 13:30 — pause déjeuner de base 12h00-13h30
def hhmm_to_minutes(val: str | None) -> int:
if not val:
@ -16,14 +18,26 @@ def minutes_to_hhmm(minutes: int) -> str:
def heures_travaillees(p: dict) -> int:
"""Return worked minutes for a pointage row."""
matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - hhmm_to_minutes(p["matin_entree"]))
aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - hhmm_to_minutes(p["aprem_entree"]))
# only count if entry AND exit are present
if not (p["matin_entree"] and p["matin_sortie"]):
"""Return worked minutes for a pointage row.
If a half-day has started but has no exit time yet, count elapsed time up to now
(today only — past dates with missing exits are treated as 0).
"""
today = datetime.today().strftime("%Y-%m-%d")
now_min = datetime.today().hour * 60 + datetime.today().minute
matin = 0
if not (p["aprem_entree"] and p["aprem_sortie"]):
if p["matin_entree"] and p["matin_sortie"]:
matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - hhmm_to_minutes(p["matin_entree"]))
elif p["matin_entree"] and p.get("date") == today:
matin = max(0, now_min - hhmm_to_minutes(p["matin_entree"]))
aprem = 0
if p["aprem_entree"] and p["aprem_sortie"]:
aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - hhmm_to_minutes(p["aprem_entree"]))
elif p["aprem_entree"] and p.get("date") == today:
aprem = max(0, now_min - hhmm_to_minutes(p["aprem_entree"]))
return matin + aprem
@ -91,11 +105,29 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok
# Sortie cible: optimal aprem exit so the week totals exactly the target hours
today = datetime.today().strftime("%Y-%m-%d")
now_min = datetime.today().hour * 60 + datetime.today().minute
worked_before = 0
for i, jour in enumerate(jours):
cg = jour["conge"]
# Jour futur pas encore entamé : pas de cible, le rattrapage n'est pas figé sur ce jour précis
is_future_untouched = jour["date"] > today and not jour.get("matin_entree") and not jour.get("aprem_entree")
if is_future_untouched:
jour["sortie_cible"] = None
jour["entree_cible"] = None
worked_before += jour["travaille_min"]
continue
if jour["du_min"] == 0 or "aprem" in cg or "jour" in cg:
jour["sortie_cible"] = None
jour["entree_cible"] = None
if "aprem" in cg and "matin" not in cg and jour["du_min"] > 0:
du_remaining_mat = sum(j["du_min"] for j in jours[i + 1:])
if du_remaining_mat == 0 and not jour.get("matin_entree"):
needed_today_mat = max(0, total_du - worked_before - du_remaining_mat)
if needed_today_mat > 3 * 60: # plus que le créneau 9h-12h obligatoire
entree_min = 12 * 60 - needed_today_mat
if entree_min >= 6 * 60: # avant 9h obligatoire, pas avant 6h
jour["entree_cible"] = f"{entree_min // 60:02d}:{entree_min % 60:02d}"
worked_before += jour["travaille_min"]
continue
@ -105,15 +137,29 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
worked_morning = 0
if jour.get("matin_entree") and jour.get("matin_sortie"):
worked_morning = max(0, hhmm_to_minutes(jour["matin_sortie"]) - hhmm_to_minutes(jour["matin_entree"]))
elif jour.get("matin_entree") and jour["date"] == today:
# Pas de sortie pointée le matin : projeter une matinée normale jusqu'à midi
# (sinon la cible dumperait tout le quota du jour sur l'après-midi dès l'arrivée).
entree_min = hhmm_to_minutes(jour["matin_entree"])
elapsed = max(0, now_min - entree_min)
projected_to_noon = max(0, 12 * 60 - entree_min)
worked_morning = max(elapsed, projected_to_noon)
needed_aprem = max(0, needed_today - worked_morning)
aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else 14 * 60
aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else APREM_DEBUT_MIN
sortie_min = max(aprem_start + needed_aprem, 17 * 60)
if sortie_min > 23 * 60 + 59:
jour["sortie_cible"] = None # deficit too large to recover in one afternoon
else:
jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
entree_cible = None
if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (17 * 60 - APREM_DEBUT_MIN):
entree_min = 17 * 60 - needed_aprem
if 12 * 60 < entree_min < APREM_DEBUT_MIN: # avant l'heure de reprise par défaut, après le déjeuner
entree_cible = f"{entree_min // 60:02d}:{entree_min % 60:02d}"
jour["entree_cible"] = entree_cible
worked_before += jour["travaille_min"]
return {

View File

@ -123,7 +123,13 @@ def _oob_week_extras(result):
else:
cumul_inner = '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
cible = jour.get("sortie_cible") if not jour.get("is_complete") else None
cible = None
if not jour.get("is_complete"):
if jour.get("entree_cible"):
suffix = "→12:00" if "aprem" in jour.get("conge", set()) else "→17:00"
cible = f'{jour["entree_cible"]}{suffix}'
elif jour.get("sortie_cible"):
cible = jour.get("sortie_cible")
cible_inner = f'<span class="cible-num">{cible}</span>' if cible else '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cible" hx-swap-oob="true">{cible_inner}</span>'
return html
@ -310,6 +316,17 @@ def save_pointage(
return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
@app.get("/refresh/{date}", response_class=HTMLResponse)
def refresh_pointage(request: Request, date: str):
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
d = datetime.strptime(date, "%Y-%m-%d").date()
conges = load_conges(user_id)
return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
@app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse)
def toggle_conge_route(date: str, type_conge: str, request: Request):
user_id = _get_user(request)

View File

@ -5,6 +5,11 @@ from datetime import datetime, timedelta, date as _date
from models import load_week_pointages, load_conges, list_all_weeks, load_config
from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues
MOIS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
def _week_dates(year: int, week: int) -> list[str]:
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
@ -134,13 +139,18 @@ def compute_all_stats(user_id: str = "") -> dict:
weekly_balances.append({
"year": year, "week": week,
"label": f"S{week:02d}",
"date": dates[0],
"delta_min": week_delta,
"delta": minutes_to_hhmm(week_delta),
"partial": week_has_incomplete,
})
# Suffix the label with a 2-digit year when weeks span more than one year,
# otherwise "S24" is ambiguous between e.g. 2024 and 2026.
spans_multiple_years = len({b["year"] for b in weekly_balances}) > 1
for b in weekly_balances:
b["label"] = f"S{b['week']:02d}'{b['year'] % 100:02d}" if spans_multiple_years else f"S{b['week']:02d}"
time_stats = {}
for slot, pairs in slot_pairs.items():
if len(pairs) < 2:
@ -182,7 +192,7 @@ def compute_all_stats(user_id: str = "") -> dict:
"comp_globale": comp_globale,
"comp_mois": comp_mois,
"comp_sem_n1": comp_sem_n1,
"mois_label": today.strftime("%B %Y"),
"mois_label": f"{MOIS_FR[today.month - 1].capitalize()} {today.year}",
"prev_week_label": f"S{prev_iso.week:02d} / {prev_iso.year}",
"today": today.strftime("%Y-%m-%d"),
}

View File

@ -69,11 +69,21 @@
<td class="col-cible">
<span id="calc-{{ j.date }}-cible">
{%- if j.sortie_cible and not j.is_complete -%}
{%- if not j.is_complete -%}
{%- if j.entree_cible -%}
{%- if 'aprem' in j.conge -%}
<span class="cible-num">{{ j.entree_cible }}→12:00</span>
{%- else -%}
<span class="cible-num">{{ j.entree_cible }}→17:00</span>
{%- endif -%}
{%- elif j.sortie_cible -%}
<span class="cible-num">{{ j.sortie_cible }}</span>
{%- else -%}
<span class="delta-zero"></span>
{%- endif %}
{%- else -%}
<span class="delta-zero"></span>
{%- endif %}
</span>
</td>

View File

@ -750,6 +750,7 @@
margin-bottom: .75rem;
}
.wkly-wrap {
position: relative;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
@ -774,6 +775,71 @@
/* Zoomable canvas hint */
canvas.zoomable { cursor: zoom-in; }
/* ── Alerte départ ── */
#alerte-depart {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
background: var(--neg);
color: #fff;
border-left: 4px solid #ff6b5b;
box-shadow: 0 6px 28px rgba(0,0,0,.3);
min-width: 240px;
animation: alerte-slide .35s cubic-bezier(.2,.8,.3,1) both;
}
.alerte-inner {
display: flex;
align-items: center;
gap: .75rem;
padding: .85rem 1rem;
}
.alerte-icon { font-size: 1.5rem; flex-shrink: 0; animation: alerte-pulse 1s ease-in-out 3; }
.alerte-body { flex: 1; }
.alerte-label {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
opacity: .7;
margin-bottom: .2rem;
}
.alerte-time {
font-family: var(--mono);
font-size: 1.75rem;
font-weight: 500;
letter-spacing: -.03em;
line-height: 1;
}
.alerte-close {
background: none;
border: 1px solid rgba(255,255,255,.3);
color: #fff;
width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
font-size: 12px;
flex-shrink: 0;
align-self: flex-start;
transition: background .15s;
}
.alerte-close:hover { background: rgba(255,255,255,.15); }
.alerte-supp {
font-family: var(--mono);
font-size: 11px;
opacity: .8;
margin-top: .25rem;
letter-spacing: .02em;
}
@keyframes alerte-slide {
from { opacity: 0; transform: translateX(120%); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes alerte-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.25); }
}
/* ── Chart zoom modal ── */
.chart-modal {
position: fixed;
@ -831,10 +897,31 @@
.chart-modal-close:hover { border-color: var(--text); color: var(--text); }
.chart-modal-close:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
.chart-modal-body {
position: relative;
padding: 1.25rem;
overflow-x: auto;
}
#chart-modal-canvas { display: block; }
.chart-tooltip {
position: absolute;
pointer-events: none;
background: var(--surface);
border: 1.5px solid var(--rule);
box-shadow: 0 2px 8px rgba(28,23,18,.12);
padding: .35rem .55rem;
font-family: var(--mono);
font-size: 11px;
color: var(--text);
white-space: nowrap;
opacity: 0;
transform: translate(-50%, -100%);
transition: opacity .1s;
z-index: 5;
}
.chart-tooltip.visible { opacity: 1; }
.chart-tooltip .ct-val { font-weight: 500; }
.chart-tooltip .ct-label { color: var(--muted); margin-left: .35em; }
</style>
</head>
<body>

View File

@ -204,6 +204,117 @@
})();
</script>
<script>
// Alerte départ — surveille l'heure cible du jour courant
(function () {
var _alerted = false;
var _lastCible = null;
function todayStr() {
var d = new Date();
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
function getCible() {
var el = document.querySelector('#calc-' + todayStr() + '-cible .cible-num');
if (!el) return null;
var txt = el.textContent.trim();
// Formats: "17:13" ou "16:15→17:00" — on veut l'heure de DÉPART (dernière)
var time = txt.indexOf('→') >= 0 ? txt.split('→').pop().trim() : txt;
return /^\d{2}:\d{2}$/.test(time) ? time : null;
}
function nowMin() {
var d = new Date();
return d.getHours() * 60 + d.getMinutes();
}
function toMin(t) {
var p = t.split(':');
return parseInt(p[0], 10) * 60 + parseInt(p[1], 10);
}
function getSolde() {
var el = document.querySelector('#soldes-section .solde-item:last-child .solde-num');
return el ? el.textContent.trim() : null;
}
function showAlert(time) {
if (document.getElementById('alerte-depart')) return;
var solde = getSolde();
var soldeHtml = '';
if (solde && solde.charAt(0) === '+' && solde !== '+0h00') {
soldeHtml = '<div class="alerte-supp">' + solde + ' de supp.</div>';
}
var notifBody = 'Heure de départ : ' + time;
if (solde && solde.charAt(0) === '+') notifBody += ' · ' + solde + ' de supp.';
var el = document.createElement('div');
el.id = 'alerte-depart';
el.innerHTML =
'<div class="alerte-inner">' +
'<span class="alerte-icon">⏰</span>' +
'<div class="alerte-body">' +
'<div class="alerte-label">Il faut partir</div>' +
'<div class="alerte-time">' + time + '</div>' +
soldeHtml +
'</div>' +
'<button class="alerte-close" title="Fermer" ' +
'onclick="document.getElementById(\'alerte-depart\').remove();' +
'window._alerteDismissed=true;">✕</button>' +
'</div>';
document.body.appendChild(el);
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('⏰ Il faut partir !', { body: notifBody, tag: 'depart' });
}
}
function check() {
if (window._alerteDismissed) return;
var cible = getCible();
if (!cible) return;
if (cible !== _lastCible) {
_lastCible = cible;
if (toMin(cible) > nowMin()) _alerted = false;
}
if (!_alerted && nowMin() >= toMin(cible)) {
_alerted = true;
showAlert(cible);
}
}
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
check();
setInterval(check, 30000);
})();
</script>
<script>
// Rafraîchit automatiquement les compteurs (travaillé/delta/soldes) du jour courant
(function () {
function todayStr() {
var d = new Date();
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
function refresh() {
var date = todayStr();
if (!document.getElementById('row-' + date)) return;
htmx.ajax('GET', '/refresh/' + date, { swap: 'none' });
}
setInterval(refresh, 60000);
})();
</script>
<script>
// Live warn on time inputs that violate minimum directives
document.addEventListener('input', function(e) {

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') {

View File

@ -5,4 +5,6 @@ services:
- "8000:8000"
volumes:
- ./data:/data
environment:
- TZ=Europe/Paris
restart: unless-stopped