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:
@ -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 {
|
||||
|
||||
19
app/main.py
19
app/main.py
@ -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)
|
||||
|
||||
14
app/stats.py
14
app/stats.py
@ -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"),
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -5,4 +5,6 @@ services:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
restart: unless-stopped
|
||||
|
||||
Reference in New Issue
Block a user