Merge branch 'feature/cibles-temps-reel' into 'main'
Projette les cibles en temps réel et corrige le fuseau horaire See merge request antoine/pointeuse-optimisator!1
This commit is contained in:
@ -1,6 +1,8 @@
|
|||||||
"""Business logic: compute worked hours and expected hours per day/week."""
|
"""Business logic: compute worked hours and expected hours per day/week."""
|
||||||
from datetime import datetime, timedelta
|
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:
|
def hhmm_to_minutes(val: str | None) -> int:
|
||||||
if not val:
|
if not val:
|
||||||
@ -16,14 +18,26 @@ def minutes_to_hhmm(minutes: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def heures_travaillees(p: dict) -> int:
|
def heures_travaillees(p: dict) -> int:
|
||||||
"""Return worked minutes for a pointage row."""
|
"""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"]))
|
If a half-day has started but has no exit time yet, count elapsed time up to now
|
||||||
# only count if entry AND exit are present
|
(today only — past dates with missing exits are treated as 0).
|
||||||
if not (p["matin_entree"] and p["matin_sortie"]):
|
"""
|
||||||
|
today = datetime.today().strftime("%Y-%m-%d")
|
||||||
|
now_min = datetime.today().hour * 60 + datetime.today().minute
|
||||||
|
|
||||||
matin = 0
|
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
|
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
|
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
|
jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok
|
||||||
|
|
||||||
# Sortie cible: optimal aprem exit so the week totals exactly the target hours
|
# 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
|
worked_before = 0
|
||||||
for i, jour in enumerate(jours):
|
for i, jour in enumerate(jours):
|
||||||
cg = jour["conge"]
|
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:
|
if jour["du_min"] == 0 or "aprem" in cg or "jour" in cg:
|
||||||
jour["sortie_cible"] = None
|
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"]
|
worked_before += jour["travaille_min"]
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -105,15 +137,29 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
|||||||
worked_morning = 0
|
worked_morning = 0
|
||||||
if jour.get("matin_entree") and jour.get("matin_sortie"):
|
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"]))
|
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)
|
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)
|
sortie_min = max(aprem_start + needed_aprem, 17 * 60)
|
||||||
|
|
||||||
if sortie_min > 23 * 60 + 59:
|
if sortie_min > 23 * 60 + 59:
|
||||||
jour["sortie_cible"] = None # deficit too large to recover in one afternoon
|
jour["sortie_cible"] = None # deficit too large to recover in one afternoon
|
||||||
else:
|
else:
|
||||||
jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
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"]
|
worked_before += jour["travaille_min"]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
48
app/classement.py
Normal file
48
app/classement.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"""Leaderboard stats: compare all users on overtime precision and directive compliance."""
|
||||||
|
from models import list_users
|
||||||
|
from stats import compute_all_stats, _compliance_over, _day_compliant
|
||||||
|
from calcul import minutes_to_hhmm
|
||||||
|
|
||||||
|
|
||||||
|
def compute_classement() -> list[dict]:
|
||||||
|
"""Return ranked list of users with their key metrics."""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for user_id in list_users():
|
||||||
|
s = compute_all_stats(user_id)
|
||||||
|
if not s["weekly_balances"] and s["n_jours"] == 0:
|
||||||
|
continue # skip empty accounts
|
||||||
|
|
||||||
|
balances = [b["delta_min"] for b in s["weekly_balances"]]
|
||||||
|
|
||||||
|
# Precision: % of weeks where |solde| ≤ 30 min (≈ at zero)
|
||||||
|
if balances:
|
||||||
|
pct_zero = round(100 * sum(1 for b in balances if abs(b) <= 30) / len(balances))
|
||||||
|
mean_abs = round(sum(abs(b) for b in balances) / len(balances))
|
||||||
|
else:
|
||||||
|
pct_zero = None
|
||||||
|
mean_abs = None
|
||||||
|
|
||||||
|
conf_pct = s["comp_globale"]["pct"]
|
||||||
|
|
||||||
|
# Combined score (SSO era: will weight per role/contract)
|
||||||
|
score = round(
|
||||||
|
(pct_zero or 0) * 0.6 + (conf_pct or 0) * 0.4
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"user_id": user_id,
|
||||||
|
"display_name": user_id.replace("_", " ").replace("-", " ").title(),
|
||||||
|
"n_semaines": len(balances),
|
||||||
|
"n_jours": s["n_jours"],
|
||||||
|
"pct_zero": pct_zero, # % weeks ≈ 0 HS — higher = better
|
||||||
|
"mean_abs_min": mean_abs, # mean abs(solde) in minutes — lower = better
|
||||||
|
"mean_abs_str": minutes_to_hhmm(mean_abs) if mean_abs is not None else "—",
|
||||||
|
"conf_pct": conf_pct, # directive compliance % — higher = better
|
||||||
|
"score": score, # combined 0-100 — higher = better
|
||||||
|
"avg_delta": s["avg_delta"],
|
||||||
|
"avg_delta_min": s["avg_delta_min"],
|
||||||
|
})
|
||||||
|
|
||||||
|
results.sort(key=lambda r: -r["score"])
|
||||||
|
return results
|
||||||
227
app/main.py
227
app/main.py
@ -1,18 +1,20 @@
|
|||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
|
|
||||||
from fastapi import FastAPI, Form, Request
|
from fastapi import FastAPI, Form, Request, Response
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from models import (
|
from models import (
|
||||||
load_config, load_week_pointages, save_week_pointages, load_conges,
|
load_config, load_week_pointages, save_week_pointages, load_conges,
|
||||||
toggle_conge, list_all_weeks,
|
toggle_conge, list_all_weeks, needs_migration, migrate_legacy_to_user,
|
||||||
)
|
)
|
||||||
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
|
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
|
||||||
from stats import compute_all_stats
|
from stats import compute_all_stats
|
||||||
|
from classement import compute_classement
|
||||||
|
|
||||||
app = FastAPI(title="Décompte Horaire")
|
app = FastAPI(title="Décompte Horaire")
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@ -20,46 +22,62 @@ templates.env.filters["zip"] = zip
|
|||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
|
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
|
||||||
|
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,32}$")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_user(request: Request) -> str | None:
|
||||||
|
"""Extract current user from cookie.
|
||||||
|
TODO: replace with SSO token validation when integrating corporate SSO.
|
||||||
|
"""
|
||||||
|
uid = request.cookies.get("user_id", "").strip()
|
||||||
|
return uid if _USERNAME_RE.match(uid) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_user(request: Request):
|
||||||
|
"""Return user_id or redirect to login."""
|
||||||
|
uid = _get_user(request)
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
# ── Shared helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def week_dates(year: int, week: int) -> list[str]:
|
def week_dates(year: int, week: int) -> list[str]:
|
||||||
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
|
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
|
||||||
return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)]
|
return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)]
|
||||||
|
|
||||||
|
|
||||||
def heures_jour_min() -> int:
|
def heures_jour_min() -> int:
|
||||||
cfg = load_config()
|
return int(float(load_config().get("heures_jour", 7.8)) * 60)
|
||||||
return int(float(cfg.get("heures_jour", 7.8)) * 60)
|
|
||||||
|
|
||||||
|
|
||||||
def _weeks_by_year(all_weeks: list) -> dict:
|
def _weeks_by_year(all_weeks: list) -> dict:
|
||||||
"""Group sorted weeks by year, most recent first."""
|
|
||||||
result = {}
|
result = {}
|
||||||
for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]):
|
for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]):
|
||||||
result[yr] = sorted(wks, key=lambda x: x[1], reverse=True)
|
result[yr] = sorted(wks, key=lambda x: x[1], reverse=True)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _build_soldes_ctx(year: int, week: int, conges: list[dict], h_jour: int) -> dict:
|
def _build_soldes_ctx(year, week, conges, h_jour, user_id):
|
||||||
"""Compute week result for the soldes partial."""
|
|
||||||
dates = week_dates(year, week)
|
dates = week_dates(year, week)
|
||||||
raw = load_week_pointages(year, week)
|
raw = load_week_pointages(year, week, user_id)
|
||||||
p_by_date = {p["date"]: p for p in raw}
|
p_by_date = {p["date"]: p for p in raw}
|
||||||
pointages = [
|
pointages = [
|
||||||
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
|
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
|
||||||
"aprem_entree": None, "aprem_sortie": None})
|
"aprem_entree": None, "aprem_sortie": None})
|
||||||
for d in dates
|
for d in dates
|
||||||
]
|
]
|
||||||
result = compute_week(pointages, conges, h_jour)
|
return {"result": compute_week(pointages, conges, h_jour)}
|
||||||
return {"result": result}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict:
|
def _build_jour_ctx(date, conges, h_jour, user_id):
|
||||||
"""Return template context for a single day row."""
|
|
||||||
from calcul import heures_travaillees
|
from calcul import heures_travaillees
|
||||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||||
iso = d.isocalendar()
|
iso = d.isocalendar()
|
||||||
raw = load_week_pointages(iso.year, iso.week)
|
raw = load_week_pointages(iso.year, iso.week, user_id)
|
||||||
p_by_date = {p["date"]: p for p in raw}
|
p_by_date = {p["date"]: p for p in raw}
|
||||||
p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None,
|
p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None,
|
||||||
"aprem_entree": None, "aprem_sortie": None})
|
"aprem_entree": None, "aprem_sortie": None})
|
||||||
@ -76,13 +94,12 @@ def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _oob_calc(date: str, jour: dict) -> str:
|
def _oob_calc(date, jour):
|
||||||
"""Build OOB <span> fragments for the two calc cells (safe non-table elements)."""
|
|
||||||
trav_inner = f'<span class="calc-num">{"—" if not jour["travaille_min"] else jour["travaille"]}</span>'
|
trav_inner = f'<span class="calc-num">{"—" if not jour["travaille_min"] else jour["travaille"]}</span>'
|
||||||
if jour["travaille_min"]:
|
if jour["travaille_min"]:
|
||||||
delta_cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero")
|
cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero")
|
||||||
sign = "+" if jour["delta_min"] > 0 else ""
|
sign = "+" if jour["delta_min"] > 0 else ""
|
||||||
delta_inner = f'<span class="{delta_cls}">{sign}{jour["delta"]}</span>'
|
delta_inner = f'<span class="{cls}">{sign}{jour["delta"]}</span>'
|
||||||
else:
|
else:
|
||||||
delta_inner = '<span class="delta-zero">—</span>'
|
delta_inner = '<span class="delta-zero">—</span>'
|
||||||
return (
|
return (
|
||||||
@ -91,12 +108,10 @@ def _oob_calc(date: str, jour: dict) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _oob_week_extras(result: dict) -> str:
|
def _oob_week_extras(result):
|
||||||
"""OOB <span> for all 5 days' cumulative delta and sortie cible."""
|
|
||||||
html = ""
|
html = ""
|
||||||
for jour in result["jours"]:
|
for jour in result["jours"]:
|
||||||
date = jour["date"]
|
date = jour["date"]
|
||||||
# Cumulative delta — only when chain of complete days is unbroken
|
|
||||||
cm = jour["delta_cumul_min"]
|
cm = jour["delta_cumul_min"]
|
||||||
if jour.get("cumul_visible"):
|
if jour.get("cumul_visible"):
|
||||||
if cm == 0:
|
if cm == 0:
|
||||||
@ -108,15 +123,19 @@ def _oob_week_extras(result: dict) -> str:
|
|||||||
else:
|
else:
|
||||||
cumul_inner = '<span class="delta-zero">—</span>'
|
cumul_inner = '<span class="delta-zero">—</span>'
|
||||||
html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
|
html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
|
||||||
# Sortie cible — only for incomplete days
|
cible = None
|
||||||
cible = jour.get("sortie_cible") if not jour.get("is_complete") else 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>'
|
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>'
|
html += f'<span id="calc-{date}-cible" hx-swap-oob="true">{cible_inner}</span>'
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
def _oob_time_cells(date: str, jour: dict) -> str:
|
def _oob_time_cells(date, jour):
|
||||||
"""OOB <div> for matin/aprem cells with congé state and warn class."""
|
|
||||||
cg = jour["conge"]
|
cg = jour["conge"]
|
||||||
matin_cg = "matin" in cg or "jour" in cg
|
matin_cg = "matin" in cg or "jour" in cg
|
||||||
aprem_cg = "aprem" in cg or "jour" in cg
|
aprem_cg = "aprem" in cg or "jour" in cg
|
||||||
@ -149,12 +168,10 @@ def _oob_time_cells(date: str, jour: dict) -> str:
|
|||||||
return matin_html + aprem_html
|
return matin_html + aprem_html
|
||||||
|
|
||||||
|
|
||||||
def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse:
|
def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
|
||||||
"""Return OOB <div>/<span> fragments for congé state + calc + soldes. No table elements."""
|
|
||||||
iso = d.isocalendar()
|
iso = d.isocalendar()
|
||||||
jour = _build_jour_ctx(date, conges, h_jour)
|
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
||||||
cg = jour["conge"]
|
cg = jour["conge"]
|
||||||
|
|
||||||
btn_ma_on = " on" if "matin" in cg else ""
|
btn_ma_on = " on" if "matin" in cg else ""
|
||||||
btn_am_on = " on" if "aprem" in cg else ""
|
btn_am_on = " on" if "aprem" in cg else ""
|
||||||
btn_j_on = " jour-on" if "jour" in cg else ""
|
btn_j_on = " jour-on" if "jour" in cg else ""
|
||||||
@ -166,8 +183,7 @@ def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h
|
|||||||
f'<button hx-post="/conge/{date}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
|
f'<button hx-post="/conge/{date}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
|
||||||
f'</div></div>'
|
f'</div></div>'
|
||||||
)
|
)
|
||||||
|
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id)
|
||||||
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour)
|
|
||||||
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_oob_time_cells(date, jour)
|
_oob_time_cells(date, jour)
|
||||||
@ -178,11 +194,10 @@ def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _htmx_calc_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse:
|
def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
|
||||||
"""Return OOB <span>/<div> for calc cells, time cells, week extras, and soldes."""
|
|
||||||
iso = d.isocalendar()
|
iso = d.isocalendar()
|
||||||
jour = _build_jour_ctx(date, conges, h_jour)
|
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
||||||
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour)
|
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id)
|
||||||
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_oob_calc(date, jour)
|
_oob_calc(date, jour)
|
||||||
@ -192,8 +207,45 @@ def _htmx_calc_and_soldes(request: Request, date: str, d, conges: list[dict], h_
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Login / logout ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
|
def login_page(request: Request):
|
||||||
|
if _get_user(request):
|
||||||
|
return RedirectResponse("/", status_code=302)
|
||||||
|
return templates.TemplateResponse("login.html", {"request": request, "error": None})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login", response_class=HTMLResponse)
|
||||||
|
def login_submit(request: Request, response: Response, username: str = Form("")):
|
||||||
|
username = username.strip().lower()
|
||||||
|
if not _USERNAME_RE.match(username):
|
||||||
|
return templates.TemplateResponse("login.html", {
|
||||||
|
"request": request,
|
||||||
|
"error": "Nom d'utilisateur invalide (lettres, chiffres, - et _ uniquement, 1–32 caractères).",
|
||||||
|
})
|
||||||
|
# First-ever login: migrate legacy flat data to this user's directory
|
||||||
|
if needs_migration():
|
||||||
|
migrate_legacy_to_user(username)
|
||||||
|
|
||||||
|
resp = RedirectResponse("/", status_code=302)
|
||||||
|
resp.set_cookie("user_id", username, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
def logout():
|
||||||
|
resp = RedirectResponse("/login", status_code=302)
|
||||||
|
resp.delete_cookie("user_id")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request):
|
def index(request: Request):
|
||||||
|
if not _get_user(request):
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
today = datetime.today()
|
today = datetime.today()
|
||||||
iso = today.isocalendar()
|
iso = today.isocalendar()
|
||||||
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}")
|
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}")
|
||||||
@ -201,12 +253,16 @@ def index(request: Request):
|
|||||||
|
|
||||||
@app.get("/semaine/{year}/{week}", response_class=HTMLResponse)
|
@app.get("/semaine/{year}/{week}", response_class=HTMLResponse)
|
||||||
def semaine(request: Request, year: int, week: int):
|
def semaine(request: Request, year: int, week: int):
|
||||||
|
user_id = _get_user(request)
|
||||||
|
if not user_id:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
h_jour = heures_jour_min()
|
h_jour = heures_jour_min()
|
||||||
conges = load_conges()
|
conges = load_conges(user_id)
|
||||||
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour)
|
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id)
|
||||||
|
|
||||||
dates = week_dates(year, week)
|
dates = week_dates(year, week)
|
||||||
raw = load_week_pointages(year, week)
|
raw = load_week_pointages(year, week, user_id)
|
||||||
p_by_date = {p["date"]: p for p in raw}
|
p_by_date = {p["date"]: p for p in raw}
|
||||||
pointages = [
|
pointages = [
|
||||||
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
|
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
|
||||||
@ -217,58 +273,82 @@ def semaine(request: Request, year: int, week: int):
|
|||||||
|
|
||||||
prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1)
|
prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1)
|
||||||
next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
|
next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
|
||||||
prev_iso = prev_d.isocalendar()
|
all_weeks = list_all_weeks(user_id)
|
||||||
next_iso = next_d.isocalendar()
|
|
||||||
|
|
||||||
all_weeks = list_all_weeks()
|
|
||||||
|
|
||||||
return templates.TemplateResponse("semaine.html", {
|
return templates.TemplateResponse("semaine.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"year": year, "week": week,
|
"year": year, "week": week,
|
||||||
"result": result,
|
"result": result,
|
||||||
**soldes_ctx,
|
**soldes_ctx,
|
||||||
"prev_year": prev_iso.year, "prev_week": prev_iso.week,
|
"prev_year": prev_d.isocalendar().year, "prev_week": prev_d.isocalendar().week,
|
||||||
"next_year": next_iso.year, "next_week": next_iso.week,
|
"next_year": next_d.isocalendar().year, "next_week": next_d.isocalendar().week,
|
||||||
"jours_fr": JOURS_FR,
|
"jours_fr": JOURS_FR,
|
||||||
"all_weeks": all_weeks,
|
"all_weeks": all_weeks,
|
||||||
"weeks_by_year": _weeks_by_year(all_weeks),
|
"weeks_by_year": _weeks_by_year(all_weeks),
|
||||||
|
"current_user": user_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/pointage/{date}", response_class=HTMLResponse)
|
@app.post("/pointage/{date}", response_class=HTMLResponse)
|
||||||
def save_pointage(
|
def save_pointage(
|
||||||
request: Request,
|
request: Request, date: str,
|
||||||
date: str,
|
matin_entree: str = Form(""), matin_sortie: str = Form(""),
|
||||||
matin_entree: str = Form(""),
|
aprem_entree: str = Form(""), aprem_sortie: str = Form(""),
|
||||||
matin_sortie: str = Form(""),
|
|
||||||
aprem_entree: str = Form(""),
|
|
||||||
aprem_sortie: str = Form(""),
|
|
||||||
):
|
):
|
||||||
def clean(v: str):
|
user_id = _get_user(request)
|
||||||
v = v.strip()
|
if not user_id:
|
||||||
return v if v else None
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
|
def clean(v):
|
||||||
|
v = v.strip(); return v if v else None
|
||||||
|
|
||||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||||
iso = d.isocalendar()
|
iso = d.isocalendar()
|
||||||
raw = load_week_pointages(iso.year, iso.week)
|
raw = load_week_pointages(iso.year, iso.week, user_id)
|
||||||
p_by_date = {p["date"]: p for p in raw}
|
p_by_date = {p["date"]: p for p in raw}
|
||||||
p_by_date[date] = {
|
p_by_date[date] = {
|
||||||
"date": date,
|
"date": date,
|
||||||
"matin_entree": clean(matin_entree),
|
"matin_entree": clean(matin_entree), "matin_sortie": clean(matin_sortie),
|
||||||
"matin_sortie": clean(matin_sortie),
|
"aprem_entree": clean(aprem_entree), "aprem_sortie": clean(aprem_sortie),
|
||||||
"aprem_entree": clean(aprem_entree),
|
|
||||||
"aprem_sortie": clean(aprem_sortie),
|
|
||||||
}
|
}
|
||||||
save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]))
|
save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]), user_id)
|
||||||
|
conges = load_conges(user_id)
|
||||||
|
return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
|
||||||
|
|
||||||
h_jour = heures_jour_min()
|
|
||||||
conges = load_conges()
|
@app.get("/refresh/{date}", response_class=HTMLResponse)
|
||||||
return _htmx_calc_and_soldes(request, date, d, conges, h_jour)
|
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)
|
||||||
|
if not user_id:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
|
toggle_conge(date, type_conge, user_id)
|
||||||
|
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
conges = load_conges(user_id)
|
||||||
|
return _htmx_conge_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
|
||||||
|
iso = d.isocalendar()
|
||||||
|
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/stats", response_class=HTMLResponse)
|
@app.get("/stats", response_class=HTMLResponse)
|
||||||
def stats_page(request: Request):
|
def stats_page(request: Request):
|
||||||
stats = compute_all_stats()
|
user_id = _get_user(request)
|
||||||
|
if not user_id:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
|
stats = compute_all_stats(user_id)
|
||||||
data_json = json.dumps({
|
data_json = json.dumps({
|
||||||
"time_stats": {
|
"time_stats": {
|
||||||
slot: {
|
slot: {
|
||||||
@ -286,18 +366,19 @@ def stats_page(request: Request):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"data_json": data_json,
|
"data_json": data_json,
|
||||||
|
"current_user": user_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse)
|
@app.get("/classement", response_class=HTMLResponse)
|
||||||
def toggle_conge_route(date: str, type_conge: str, request: Request):
|
def classement_page(request: Request):
|
||||||
toggle_conge(date, type_conge)
|
user_id = _get_user(request)
|
||||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
if not user_id:
|
||||||
if request.headers.get("HX-Request"):
|
return RedirectResponse("/login", status_code=302)
|
||||||
h_jour = heures_jour_min()
|
|
||||||
conges = load_conges()
|
|
||||||
return _htmx_conge_and_soldes(request, date, d, conges, h_jour)
|
|
||||||
iso = d.isocalendar()
|
|
||||||
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
|
ranking = compute_classement()
|
||||||
|
return templates.TemplateResponse("classement.html", {
|
||||||
|
"request": request,
|
||||||
|
"ranking": ranking,
|
||||||
|
"current_user": user_id,
|
||||||
|
})
|
||||||
|
|||||||
142
app/models.py
142
app/models.py
@ -1,25 +1,75 @@
|
|||||||
"""Flat-file storage: JSON files per week for pointages, one file for congés."""
|
"""Flat-file storage: JSON files per week for pointages, one file for congés."""
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
DATA_DIR = Path("/data")
|
DATA_DIR = Path("/data")
|
||||||
POINTAGES_DIR = DATA_DIR / "pointages"
|
|
||||||
CONGES_FILE = DATA_DIR / "conges.json"
|
|
||||||
CONFIG_FILE = DATA_DIR / "config.json"
|
CONFIG_FILE = DATA_DIR / "config.json"
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
"heures_jour": 7.8, # 7h48 = 39h/semaine
|
"heures_jour": 7.8, # 7h48 = 39h/semaine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── User path helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _ensure_dirs():
|
def _user_root(user_id: str) -> Path:
|
||||||
POINTAGES_DIR.mkdir(parents=True, exist_ok=True)
|
"""Return data directory for a user.
|
||||||
|
|
||||||
|
Legacy fallback: if no users/ directory exists yet but /data/pointages/ does,
|
||||||
|
the first login migrates the data automatically (see migrate_legacy_to_user).
|
||||||
|
After migration, each user gets /data/users/{user_id}/.
|
||||||
|
"""
|
||||||
|
return DATA_DIR / "users" / user_id
|
||||||
|
|
||||||
|
|
||||||
# ---------- config ----------
|
def _pointages_dir(user_id: str) -> Path:
|
||||||
|
return _user_root(user_id) / "pointages"
|
||||||
|
|
||||||
|
|
||||||
|
def _conges_file(user_id: str) -> Path:
|
||||||
|
return _user_root(user_id) / "conges.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_user_dirs(user_id: str):
|
||||||
|
_pointages_dir(user_id).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Legacy migration (called once on first login) ─────────────────────────────
|
||||||
|
|
||||||
|
def needs_migration() -> bool:
|
||||||
|
"""True when legacy flat data exists and no users/ directory has been created yet."""
|
||||||
|
return (DATA_DIR / "pointages").exists() and not (DATA_DIR / "users").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_legacy_to_user(user_id: str):
|
||||||
|
"""Move /data/pointages/ and /data/conges.json into /data/users/{user_id}/."""
|
||||||
|
user_root = _user_root(user_id)
|
||||||
|
user_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
legacy_pt = DATA_DIR / "pointages"
|
||||||
|
dest_pt = user_root / "pointages"
|
||||||
|
if legacy_pt.exists() and not dest_pt.exists():
|
||||||
|
shutil.copytree(legacy_pt, dest_pt)
|
||||||
|
|
||||||
|
legacy_cg = DATA_DIR / "conges.json"
|
||||||
|
dest_cg = user_root / "conges.json"
|
||||||
|
if legacy_cg.exists() and not dest_cg.exists():
|
||||||
|
shutil.copy2(legacy_cg, dest_cg)
|
||||||
|
|
||||||
|
|
||||||
|
# ── User list ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_users() -> list[str]:
|
||||||
|
users_dir = DATA_DIR / "users"
|
||||||
|
if not users_dir.exists():
|
||||||
|
return []
|
||||||
|
return sorted(p.name for p in users_dir.iterdir() if p.is_dir())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config (global) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def load_config() -> dict:
|
def load_config() -> dict:
|
||||||
_ensure_dirs()
|
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
return json.loads(CONFIG_FILE.read_text())
|
return json.loads(CONFIG_FILE.read_text())
|
||||||
CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2))
|
CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2))
|
||||||
@ -27,78 +77,62 @@ def load_config() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def save_config(cfg: dict):
|
def save_config(cfg: dict):
|
||||||
_ensure_dirs()
|
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
# ---------- pointages ----------
|
# ── Pointages ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _week_file(year: int, week: int) -> Path:
|
def _week_file(user_id: str, year: int, week: int) -> Path:
|
||||||
return POINTAGES_DIR / f"{year}-W{week:02d}.json"
|
return _pointages_dir(user_id) / f"{year}-W{week:02d}.json"
|
||||||
|
|
||||||
|
|
||||||
def load_week_pointages(year: int, week: int) -> list[dict]:
|
def load_week_pointages(year: int, week: int, user_id: str = "") -> list[dict]:
|
||||||
f = _week_file(year, week)
|
f = _week_file(user_id, year, week)
|
||||||
if f.exists():
|
return json.loads(f.read_text()) if f.exists() else []
|
||||||
return json.loads(f.read_text())
|
|
||||||
|
|
||||||
|
def save_week_pointages(year: int, week: int, pointages: list[dict], user_id: str = ""):
|
||||||
|
_ensure_user_dirs(user_id)
|
||||||
|
_week_file(user_id, year, week).write_text(
|
||||||
|
json.dumps(pointages, indent=2, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_weeks(user_id: str = "") -> list[tuple[int, int]]:
|
||||||
|
pt_dir = _pointages_dir(user_id)
|
||||||
|
if not pt_dir.exists():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def save_week_pointages(year: int, week: int, pointages: list[dict]):
|
|
||||||
_ensure_dirs()
|
|
||||||
_week_file(year, week).write_text(json.dumps(pointages, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_pointages(records: list[dict]):
|
|
||||||
"""Insert or update pointage records, grouped by ISO week."""
|
|
||||||
from datetime import datetime
|
|
||||||
by_week: dict[tuple, dict[str, dict]] = {}
|
|
||||||
for r in records:
|
|
||||||
d = datetime.strptime(r["date"], "%Y-%m-%d").date()
|
|
||||||
iso = d.isocalendar()
|
|
||||||
key = (iso.year, iso.week)
|
|
||||||
by_week.setdefault(key, {})
|
|
||||||
by_week[key][r["date"]] = r
|
|
||||||
|
|
||||||
for (year, week), new_by_date in by_week.items():
|
|
||||||
existing = {p["date"]: p for p in load_week_pointages(year, week)}
|
|
||||||
existing.update(new_by_date)
|
|
||||||
save_week_pointages(year, week, sorted(existing.values(), key=lambda p: p["date"]))
|
|
||||||
|
|
||||||
|
|
||||||
def list_all_weeks() -> list[tuple[int, int]]:
|
|
||||||
"""Return sorted list of (year, week) for which we have data."""
|
|
||||||
_ensure_dirs()
|
|
||||||
weeks = []
|
weeks = []
|
||||||
for f in POINTAGES_DIR.glob("????-W??.json"):
|
for f in pt_dir.glob("????-W??.json"):
|
||||||
parts = f.stem.split("-W")
|
parts = f.stem.split("-W")
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
weeks.append((int(parts[0]), int(parts[1])))
|
weeks.append((int(parts[0]), int(parts[1])))
|
||||||
return sorted(weeks)
|
return sorted(weeks)
|
||||||
|
|
||||||
|
|
||||||
# ---------- congés ----------
|
# ── Congés ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def load_conges() -> list[dict]:
|
def load_conges(user_id: str = "") -> list[dict]:
|
||||||
if CONGES_FILE.exists():
|
f = _conges_file(user_id)
|
||||||
return json.loads(CONGES_FILE.read_text())
|
return json.loads(f.read_text()) if f.exists() else []
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def save_conges(conges: list[dict]):
|
def save_conges(conges: list[dict], user_id: str = ""):
|
||||||
_ensure_dirs()
|
_ensure_user_dirs(user_id)
|
||||||
CONGES_FILE.write_text(json.dumps(conges, indent=2, ensure_ascii=False))
|
_conges_file(user_id).write_text(
|
||||||
|
json.dumps(conges, indent=2, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def toggle_conge(date: str, type_conge: str):
|
def toggle_conge(date: str, type_conge: str, user_id: str = ""):
|
||||||
assert type_conge in ("jour", "matin", "aprem")
|
assert type_conge in ("jour", "matin", "aprem")
|
||||||
conges = load_conges()
|
conges = load_conges(user_id)
|
||||||
already = any(c["date"] == date and c["type"] == type_conge for c in conges)
|
already = any(c["date"] == date and c["type"] == type_conge for c in conges)
|
||||||
|
|
||||||
if already:
|
if already:
|
||||||
conges = [c for c in conges if not (c["date"] == date and c["type"] == type_conge)]
|
conges = [c for c in conges if not (c["date"] == date and c["type"] == type_conge)]
|
||||||
else:
|
else:
|
||||||
# Adding: remove conflicting entries first
|
|
||||||
if type_conge == "jour":
|
if type_conge == "jour":
|
||||||
conges = [c for c in conges if c["date"] != date]
|
conges = [c for c in conges if c["date"] != date]
|
||||||
else:
|
else:
|
||||||
@ -106,4 +140,4 @@ def toggle_conge(date: str, type_conge: str):
|
|||||||
conges.append({"date": date, "type": type_conge})
|
conges.append({"date": date, "type": type_conge})
|
||||||
|
|
||||||
conges.sort(key=lambda c: (c["date"], c["type"]))
|
conges.sort(key=lambda c: (c["date"], c["type"]))
|
||||||
save_conges(conges)
|
save_conges(conges, user_id)
|
||||||
|
|||||||
27
app/stats.py
27
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 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
|
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]:
|
def _week_dates(year: int, week: int) -> list[str]:
|
||||||
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
|
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
|
||||||
@ -50,9 +55,9 @@ def _compliance_over(all_days: list[tuple], cg_set: set) -> dict:
|
|||||||
return {"pct": round(100 * n_ok / n) if n else None, "n": n, "n_ok": n_ok}
|
return {"pct": round(100 * n_ok / n) if n else None, "n": n, "n_ok": n_ok}
|
||||||
|
|
||||||
|
|
||||||
def compute_all_stats() -> dict:
|
def compute_all_stats(user_id: str = "") -> dict:
|
||||||
all_weeks = sorted(list_all_weeks())
|
all_weeks = sorted(list_all_weeks(user_id))
|
||||||
conges = load_conges()
|
conges = load_conges(user_id)
|
||||||
h_jour = int(float(load_config().get("heures_jour", 7.8)) * 60)
|
h_jour = int(float(load_config().get("heures_jour", 7.8)) * 60)
|
||||||
cg_set = {(c["date"], c["type"]) for c in conges}
|
cg_set = {(c["date"], c["type"]) for c in conges}
|
||||||
|
|
||||||
@ -63,7 +68,6 @@ def compute_all_stats() -> dict:
|
|||||||
current_ym = (today.year, today.month)
|
current_ym = (today.year, today.month)
|
||||||
|
|
||||||
SLOTS = ["matin_entree", "matin_sortie", "aprem_entree", "aprem_sortie"]
|
SLOTS = ["matin_entree", "matin_sortie", "aprem_entree", "aprem_sortie"]
|
||||||
# Store (value, date) pairs per slot
|
|
||||||
slot_pairs: dict[str, list[tuple[int, str]]] = {s: [] for s in SLOTS}
|
slot_pairs: dict[str, list[tuple[int, str]]] = {s: [] for s in SLOTS}
|
||||||
comp_n = {s: 0 for s in SLOTS}
|
comp_n = {s: 0 for s in SLOTS}
|
||||||
comp_ok = {s: 0 for s in SLOTS}
|
comp_ok = {s: 0 for s in SLOTS}
|
||||||
@ -81,7 +85,7 @@ def compute_all_stats() -> dict:
|
|||||||
prev_week_day_list: list[tuple] = []
|
prev_week_day_list: list[tuple] = []
|
||||||
|
|
||||||
for year, week in all_weeks:
|
for year, week in all_weeks:
|
||||||
raw = load_week_pointages(year, week)
|
raw = load_week_pointages(year, week, user_id)
|
||||||
if not raw:
|
if not raw:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -135,13 +139,18 @@ def compute_all_stats() -> dict:
|
|||||||
|
|
||||||
weekly_balances.append({
|
weekly_balances.append({
|
||||||
"year": year, "week": week,
|
"year": year, "week": week,
|
||||||
"label": f"S{week:02d}",
|
"date": dates[0],
|
||||||
"date": dates[0], # Monday — used for period filtering
|
|
||||||
"delta_min": week_delta,
|
"delta_min": week_delta,
|
||||||
"delta": minutes_to_hhmm(week_delta),
|
"delta": minutes_to_hhmm(week_delta),
|
||||||
"partial": week_has_incomplete,
|
"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 = {}
|
time_stats = {}
|
||||||
for slot, pairs in slot_pairs.items():
|
for slot, pairs in slot_pairs.items():
|
||||||
if len(pairs) < 2:
|
if len(pairs) < 2:
|
||||||
@ -163,7 +172,6 @@ def compute_all_stats() -> dict:
|
|||||||
for slot in comp_n
|
for slot in comp_n
|
||||||
}
|
}
|
||||||
rates = [v for v in compliance.values() if v is not None]
|
rates = [v for v in compliance.values() if v is not None]
|
||||||
overall_slot = round(sum(rates) / len(rates)) if rates else None
|
|
||||||
|
|
||||||
n_jours = max(len(slot_pairs["matin_entree"]), len(slot_pairs["aprem_entree"]))
|
n_jours = max(len(slot_pairs["matin_entree"]), len(slot_pairs["aprem_entree"]))
|
||||||
deltas = [b["delta_min"] for b in weekly_balances]
|
deltas = [b["delta_min"] for b in weekly_balances]
|
||||||
@ -176,7 +184,6 @@ def compute_all_stats() -> dict:
|
|||||||
return {
|
return {
|
||||||
"time_stats": time_stats,
|
"time_stats": time_stats,
|
||||||
"compliance": compliance,
|
"compliance": compliance,
|
||||||
"overall_compliance": overall_slot,
|
|
||||||
"weekly_balances": weekly_balances,
|
"weekly_balances": weekly_balances,
|
||||||
"total_weeks": len(weekly_balances),
|
"total_weeks": len(weekly_balances),
|
||||||
"n_jours": n_jours,
|
"n_jours": n_jours,
|
||||||
@ -185,7 +192,7 @@ def compute_all_stats() -> dict:
|
|||||||
"comp_globale": comp_globale,
|
"comp_globale": comp_globale,
|
||||||
"comp_mois": comp_mois,
|
"comp_mois": comp_mois,
|
||||||
"comp_sem_n1": comp_sem_n1,
|
"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}",
|
"prev_week_label": f"S{prev_iso.week:02d} / {prev_iso.year}",
|
||||||
"today": today.strftime("%Y-%m-%d"),
|
"today": today.strftime("%Y-%m-%d"),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,11 +69,21 @@
|
|||||||
|
|
||||||
<td class="col-cible">
|
<td class="col-cible">
|
||||||
<span id="calc-{{ j.date }}-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>
|
<span class="cible-num">{{ j.sortie_cible }}</span>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<span class="delta-zero">—</span>
|
<span class="delta-zero">—</span>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
|
{%- else -%}
|
||||||
|
<span class="delta-zero">—</span>
|
||||||
|
{%- endif %}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|||||||
@ -79,6 +79,13 @@
|
|||||||
transition: color .15s;
|
transition: color .15s;
|
||||||
}
|
}
|
||||||
nav a.nav-link:hover { color: var(--ground); }
|
nav a.nav-link:hover { color: var(--ground); }
|
||||||
|
.nav-user {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-2);
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
.nav-logout { opacity: .6; font-size: 11px; }
|
||||||
|
|
||||||
/* ── Layout ── */
|
/* ── Layout ── */
|
||||||
.container {
|
.container {
|
||||||
@ -743,6 +750,7 @@
|
|||||||
margin-bottom: .75rem;
|
margin-bottom: .75rem;
|
||||||
}
|
}
|
||||||
.wkly-wrap {
|
.wkly-wrap {
|
||||||
|
position: relative;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
@ -767,6 +775,71 @@
|
|||||||
/* Zoomable canvas hint */
|
/* Zoomable canvas hint */
|
||||||
canvas.zoomable { cursor: zoom-in; }
|
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 zoom modal ── */
|
||||||
.chart-modal {
|
.chart-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -824,10 +897,31 @@
|
|||||||
.chart-modal-close:hover { border-color: var(--text); color: var(--text); }
|
.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-close:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
.chart-modal-body {
|
.chart-modal-body {
|
||||||
|
position: relative;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
#chart-modal-canvas { display: block; }
|
#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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -836,6 +930,11 @@
|
|||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a href="/" class="nav-link">Semaine courante</a>
|
<a href="/" class="nav-link">Semaine courante</a>
|
||||||
<a href="/stats" class="nav-link">Statistiques</a>
|
<a href="/stats" class="nav-link">Statistiques</a>
|
||||||
|
<a href="/classement" class="nav-link">Classement</a>
|
||||||
|
{% if current_user is defined and current_user %}
|
||||||
|
<span class="nav-user">{{ current_user }}</span>
|
||||||
|
<a href="/logout" class="nav-link nav-logout">Déconnexion</a>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|||||||
278
app/templates/classement.html
Normal file
278
app/templates/classement.html
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="clmt-head">
|
||||||
|
<span class="clmt-eyebrow">Compétition interne</span>
|
||||||
|
<h1 class="clmt-title">Classement</h1>
|
||||||
|
<p class="clmt-sub">Précision sur le zéro heure supp · Conformité aux directives</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if ranking %}
|
||||||
|
|
||||||
|
{# ── Podium top 3 ── #}
|
||||||
|
{% if ranking|length >= 1 %}
|
||||||
|
<div class="podium">
|
||||||
|
{% set top3 = ranking[:3] %}
|
||||||
|
{# réordonner : 2e à gauche, 1er au centre, 3e à droite #}
|
||||||
|
{% if top3|length == 1 %}
|
||||||
|
{% set order = [top3[0]] %}
|
||||||
|
{% elif top3|length == 2 %}
|
||||||
|
{% set order = [top3[1], top3[0]] %}
|
||||||
|
{% else %}
|
||||||
|
{% set order = [top3[1], top3[0], top3[2]] %}
|
||||||
|
{% endif %}
|
||||||
|
{% for u in order %}
|
||||||
|
{% set rank = ranking.index(u) + 1 %}
|
||||||
|
<div class="podium-slot podium-rank-{{ rank }}{% if u.user_id == current_user %} podium-me{% endif %}">
|
||||||
|
<div class="podium-score">{{ u.score }}</div>
|
||||||
|
<div class="podium-name">{{ u.display_name }}</div>
|
||||||
|
<div class="podium-medal">{% if rank == 1 %}🥇{% elif rank == 2 %}🥈{% else %}🥉{% endif %}</div>
|
||||||
|
<div class="podium-bar-wrap">
|
||||||
|
<div class="podium-bar podium-bar-{{ rank }}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="podium-rank-label">#{{ rank }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Tableau complet ── #}
|
||||||
|
<div class="clmt-table-wrap">
|
||||||
|
<table class="clmt-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-rank">#</th>
|
||||||
|
<th class="col-name">Utilisateur</th>
|
||||||
|
<th class="col-num" title="Score combiné (0–100)">Score</th>
|
||||||
|
<th class="col-num" title="% semaines avec |solde| ≤ 30 min">Préc. zéro</th>
|
||||||
|
<th class="col-num" title="Conformité aux horaires direction">Conformité</th>
|
||||||
|
<th class="col-num" title="Écart moyen absolu en heures">Écart moy.</th>
|
||||||
|
<th class="col-num" title="Nombre de semaines">Semaines</th>
|
||||||
|
<th class="col-num" title="Nombre de jours complets">Jours</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in ranking %}
|
||||||
|
<tr class="clmt-row{% if u.user_id == current_user %} clmt-row-me{% endif %}">
|
||||||
|
<td class="col-rank">
|
||||||
|
{% if loop.index == 1 %}<span class="rank-badge rank-1">1</span>
|
||||||
|
{% elif loop.index == 2 %}<span class="rank-badge rank-2">2</span>
|
||||||
|
{% elif loop.index == 3 %}<span class="rank-badge rank-3">3</span>
|
||||||
|
{% else %}<span class="rank-num">{{ loop.index }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-name">
|
||||||
|
{{ u.display_name }}
|
||||||
|
{% if u.user_id == current_user %}<span class="me-tag">moi</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-num">
|
||||||
|
<div class="score-bar-wrap">
|
||||||
|
<div class="score-bar" style="width:{{ u.score }}%"></div>
|
||||||
|
<span class="score-val">{{ u.score }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="col-num">{{ u.pct_zero if u.pct_zero is not none else '—' }}{% if u.pct_zero is not none %}<span class="pct-sym">%</span>{% endif %}</td>
|
||||||
|
<td class="col-num">{{ u.conf_pct if u.conf_pct is not none else '—' }}{% if u.conf_pct is not none %}<span class="pct-sym">%</span>{% endif %}</td>
|
||||||
|
<td class="col-num {% if u.avg_delta_min > 0 %}num-pos{% elif u.avg_delta_min < 0 %}num-neg{% endif %}">{{ u.avg_delta }}</td>
|
||||||
|
<td class="col-num col-minor">{{ u.n_semaines }}</td>
|
||||||
|
<td class="col-num col-minor">{{ u.n_jours }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clmt-legend">
|
||||||
|
<div class="clmt-legend-item"><strong>Score</strong> = préc. zéro × 0,6 + conformité × 0,4</div>
|
||||||
|
<div class="clmt-legend-item"><strong>Préc. zéro</strong> = % semaines avec |solde| ≤ 30 min</div>
|
||||||
|
<div class="clmt-legend-item"><strong>Conformité</strong> = % jours respectant les 4 créneaux (≤9h, ≥12h, ≤14h, ≥17h)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="clmt-empty">Aucun utilisateur enregistré pour le moment.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.clmt-head { border-bottom: 2px solid var(--text); padding-bottom: .75rem; }
|
||||||
|
.clmt-eyebrow {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: .3rem;
|
||||||
|
}
|
||||||
|
.clmt-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
.clmt-sub { font-size: 12px; color: var(--muted); margin-top: .3rem; }
|
||||||
|
|
||||||
|
/* ── Podium ── */
|
||||||
|
.podium {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--rule);
|
||||||
|
padding: 2rem 2rem 0;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.podium-slot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: .3rem;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
.podium-slot.podium-me .podium-name { color: var(--accent); }
|
||||||
|
.podium-score {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.podium-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.podium-medal { font-size: 22px; }
|
||||||
|
.podium-bar-wrap { width: 100%; }
|
||||||
|
.podium-bar {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: .25;
|
||||||
|
}
|
||||||
|
.podium-bar-1 { height: 80px; }
|
||||||
|
.podium-bar-2 { height: 55px; }
|
||||||
|
.podium-bar-3 { height: 35px; }
|
||||||
|
.podium-rank-1 .podium-bar { opacity: .4; }
|
||||||
|
.podium-rank-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface);
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: .3rem 0;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Table ── */
|
||||||
|
.clmt-table-wrap { overflow-x: auto; }
|
||||||
|
.clmt-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--rule);
|
||||||
|
}
|
||||||
|
.clmt-table th {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
padding: .6rem .9rem;
|
||||||
|
border-bottom: 1.5px solid var(--rule);
|
||||||
|
background: var(--ground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.clmt-table th.col-name { text-align: left; }
|
||||||
|
.clmt-table th.col-rank { text-align: center; }
|
||||||
|
.clmt-row td {
|
||||||
|
padding: .6rem .9rem;
|
||||||
|
border-bottom: 1px solid var(--rule-l);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.clmt-row:last-child td { border-bottom: none; }
|
||||||
|
.clmt-row-me td { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||||
|
.clmt-row-me:hover td { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||||
|
.clmt-row:hover td { background: var(--ground); }
|
||||||
|
|
||||||
|
.col-rank { text-align: center; }
|
||||||
|
.col-num {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.col-minor { opacity: .65; }
|
||||||
|
.rank-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.rank-1 { background: #F0C040; color: #3A2800; }
|
||||||
|
.rank-2 { background: #C8C8C8; color: #2A2A2A; }
|
||||||
|
.rank-3 { background: #D4956A; color: #3A1A00; }
|
||||||
|
.rank-num { font-family: var(--mono); font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
.me-tag {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: .1rem .35rem;
|
||||||
|
margin-left: .4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.pct-sym { font-size: 10px; color: var(--muted); margin-left: 1px; }
|
||||||
|
.num-pos { color: var(--pos); }
|
||||||
|
.num-neg { color: var(--neg); }
|
||||||
|
|
||||||
|
.score-bar-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: .4rem;
|
||||||
|
}
|
||||||
|
.score-bar {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: .12;
|
||||||
|
transition: width .4s ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.score-val {
|
||||||
|
position: relative;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clmt-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem 2rem;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: .5rem 0;
|
||||||
|
border-top: 1px solid var(--rule-l);
|
||||||
|
}
|
||||||
|
.clmt-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
109
app/templates/login.html
Normal file
109
app/templates/login.html
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="login-wrap">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<span class="login-eyebrow">Décompte Horaire</span>
|
||||||
|
<h1 class="login-title">Connexion</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="login-error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/login" class="login-form">
|
||||||
|
<label class="login-label" for="username">Identifiant</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
class="login-input"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
autocomplete="username"
|
||||||
|
autofocus
|
||||||
|
placeholder="prenom.nom"
|
||||||
|
pattern="[a-zA-Z0-9_\-]{1,32}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<p class="login-hint">Lettres, chiffres, tirets — 1 à 32 caractères.</p>
|
||||||
|
<button class="login-btn" type="submit">Entrer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 4rem;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--rule);
|
||||||
|
padding: 2.5rem 3rem;
|
||||||
|
width: 380px;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.login-header { display: flex; flex-direction: column; gap: .35rem; }
|
||||||
|
.login-eyebrow {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.login-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
.login-error {
|
||||||
|
background: var(--neg-bg);
|
||||||
|
border-left: 3px solid var(--neg);
|
||||||
|
color: var(--neg);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: .6rem .9rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.login-form { display: flex; flex-direction: column; gap: .75rem; }
|
||||||
|
.login-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.login-input {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 15px;
|
||||||
|
padding: .6rem .75rem;
|
||||||
|
border: 1.5px solid var(--rule);
|
||||||
|
background: var(--ground);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.login-input:focus { border-color: var(--accent); }
|
||||||
|
.login-hint { font-size: 11px; color: var(--muted); }
|
||||||
|
.login-btn {
|
||||||
|
margin-top: .5rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: .7rem 1.5rem;
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--surface);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.login-btn:hover { background: var(--accent); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@ -204,6 +204,117 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
<script>
|
||||||
// Live warn on time inputs that violate minimum directives
|
// Live warn on time inputs that violate minimum directives
|
||||||
document.addEventListener('input', function(e) {
|
document.addEventListener('input', function(e) {
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
<canvas class="cp-donut" id="cpd-{{ loop.index }}" width="90" height="90"
|
<canvas class="cp-donut" id="cpd-{{ loop.index }}" width="90" height="90"
|
||||||
data-pct="{{ pct if pct is not none else 0 }}"
|
data-pct="{{ pct if pct is not none else 0 }}"
|
||||||
data-has-data="{{ 'true' if pct is not none else 'false' }}"
|
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>
|
||||||
<div class="cp-n {{ cls if pct is not none }}">
|
<div class="cp-n {{ cls if pct is not none }}">
|
||||||
{% if pct is not none %}{{ comp.n_ok }}/{{ comp.n }} jours conformes
|
{% if pct is not none %}{{ comp.n_ok }}/{{ comp.n }} jours conformes
|
||||||
@ -107,6 +107,7 @@
|
|||||||
<canvas id="c-weekly" class="zoomable" height="180"
|
<canvas id="c-weekly" class="zoomable" height="180"
|
||||||
data-chart-type="weekly" data-chart-title="Soldes hebdomadaires"
|
data-chart-type="weekly" data-chart-title="Soldes hebdomadaires"
|
||||||
aria-label="Soldes hebdomadaires"></canvas>
|
aria-label="Soldes hebdomadaires"></canvas>
|
||||||
|
<div class="chart-tooltip" id="wkly-tooltip"></div>
|
||||||
</div>
|
</div>
|
||||||
{% set has_partial = stats.weekly_balances | selectattr("partial") | list %}
|
{% set has_partial = stats.weekly_balances | selectattr("partial") | list %}
|
||||||
{% if has_partial %}
|
{% if has_partial %}
|
||||||
@ -127,6 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="chart-modal-body">
|
<div class="chart-modal-body">
|
||||||
<canvas id="chart-modal-canvas"></canvas>
|
<canvas id="chart-modal-canvas"></canvas>
|
||||||
|
<div class="chart-tooltip" id="modal-tooltip"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -191,6 +193,34 @@
|
|||||||
return Math.round(100 * ok / vals.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 ─── */
|
/* ─── Donut ─── */
|
||||||
function drawDonut(canvas, pct, op, progress) {
|
function drawDonut(canvas, pct, op, progress) {
|
||||||
const W = canvas.width, H = canvas.height;
|
const W = canvas.width, H = canvas.height;
|
||||||
@ -209,7 +239,7 @@
|
|||||||
}
|
}
|
||||||
const color = pct >= 95 ? C.pos : (pct >= 80 ? C.warn : C.neg);
|
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.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) {
|
if (progress >= 1) {
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.font = `500 ${Math.round(W*0.19)}px ${MONO}`;
|
ctx.font = `500 ${Math.round(W*0.19)}px ${MONO}`;
|
||||||
@ -262,7 +292,7 @@
|
|||||||
if (!count) return;
|
if (!count) return;
|
||||||
const x = (i/nBins)*W, bh = (count/maxBin)*chartH;
|
const x = (i/nBins)*W, bh = (count/maxBin)*chartH;
|
||||||
ctx.fillStyle = color + 'BB';
|
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
|
// Baseline
|
||||||
@ -337,7 +367,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Weekly bars ─── */
|
/* ─── Weekly bars ─── */
|
||||||
function _drawWeekly(canvas, balances) {
|
function _drawWeekly(canvas, balances, hoverIndex) {
|
||||||
if (!balances || !balances.length) return;
|
if (!balances || !balances.length) return;
|
||||||
const W = canvas.width, H = canvas.height;
|
const W = canvas.width, H = canvas.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@ -371,26 +401,36 @@
|
|||||||
}
|
}
|
||||||
ctx.fillText('0', PAD_L-5, midY+3);
|
ctx.fillText('0', PAD_L-5, midY+3);
|
||||||
|
|
||||||
|
const layout = [];
|
||||||
balances.forEach((b, i) => {
|
balances.forEach((b, i) => {
|
||||||
const x = PAD_L+i*barSlot+(barSlot-barW)/2;
|
const x = PAD_L+i*barSlot+(barSlot-barW)/2;
|
||||||
const hPx = (Math.abs(b.delta_min)/axisMax)*(chartH/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 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) {
|
if (b.delta_min >= 0) {
|
||||||
const g = ctx.createLinearGradient(x, midY-hPx, x, midY);
|
const g = ctx.createLinearGradient(x, midY-hPx, x, midY);
|
||||||
g.addColorStop(0, baseColor+alpha); g.addColorStop(1, baseColor+(b.partial?'33':'88'));
|
g.addColorStop(0, baseColor+alpha); g.addColorStop(1, baseColor+(hovered ? 'AA' : (b.partial?'33':'88')));
|
||||||
ctx.fillStyle = g; ctx.fillRect(x, midY-hPx, barW, hPx);
|
ctx.fillStyle = g; drawBarFromBaseline(ctx, x, barW, midY, midY-hPx, 4);
|
||||||
} else {
|
} else {
|
||||||
const g = ctx.createLinearGradient(x, midY, x, midY+hPx);
|
const g = ctx.createLinearGradient(x, midY, x, midY+hPx);
|
||||||
g.addColorStop(0, baseColor+(b.partial?'33':'88')); g.addColorStop(1, baseColor+alpha);
|
g.addColorStop(0, baseColor+(hovered ? 'AA' : (b.partial?'33':'88'))); g.addColorStop(1, baseColor+alpha);
|
||||||
ctx.fillStyle = g; ctx.fillRect(x, midY, barW, hPx);
|
ctx.fillStyle = g; drawBarFromBaseline(ctx, x, barW, midY, midY+hPx, 4);
|
||||||
}
|
}
|
||||||
if (b.partial && hPx > 2) {
|
if (b.partial && hPx > 2) {
|
||||||
const yEdge = b.delta_min >= 0 ? midY-hPx : midY+hPx;
|
const yEdge = b.delta_min >= 0 ? midY-hPx : midY+hPx;
|
||||||
ctx.save(); ctx.strokeStyle = baseColor+'88'; ctx.lineWidth=1; ctx.setLineDash([2,2]);
|
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();
|
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.font = `9px ${MONO}`; ctx.fillStyle = C.muted;
|
||||||
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
||||||
@ -408,6 +448,7 @@
|
|||||||
if (!c) return;
|
if (!c) return;
|
||||||
c.width = c.parentElement.clientWidth || 800;
|
c.width = c.parentElement.clientWidth || 800;
|
||||||
const balances = filterWeekly(currentPeriod);
|
const balances = filterWeekly(currentPeriod);
|
||||||
|
c.__wklyBalances = balances;
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
_drawWeekly(c, balances); return;
|
_drawWeekly(c, balances); return;
|
||||||
}
|
}
|
||||||
@ -424,6 +465,52 @@
|
|||||||
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 ─── */
|
/* ─── Update everything for a period ─── */
|
||||||
function updateForPeriod(period) {
|
function updateForPeriod(period) {
|
||||||
currentPeriod = period;
|
currentPeriod = period;
|
||||||
@ -485,12 +572,17 @@
|
|||||||
|
|
||||||
document.getElementById('chart-modal-title').textContent = title;
|
document.getElementById('chart-modal-title').textContent = title;
|
||||||
|
|
||||||
|
document.getElementById('modal-tooltip').classList.remove('visible');
|
||||||
if (type === 'dist') {
|
if (type === 'dist') {
|
||||||
mc.width = maxW; mc.height = 180;
|
mc.width = maxW; mc.height = 180;
|
||||||
|
mc.__wklyBalances = null;
|
||||||
_drawDist(mc, slot, true);
|
_drawDist(mc, slot, true);
|
||||||
} else if (type === 'weekly') {
|
} else if (type === 'weekly') {
|
||||||
mc.width = maxW; mc.height = 300;
|
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');
|
document.getElementById('chart-modal').removeAttribute('hidden');
|
||||||
@ -527,6 +619,7 @@
|
|||||||
if (el) animateDonut(el, parseInt(el.dataset.pct), el.dataset.op);
|
if (el) animateDonut(el, parseInt(el.dataset.pct), el.dataset.op);
|
||||||
});
|
});
|
||||||
drawWeekly();
|
drawWeekly();
|
||||||
|
attachWeeklyHover(document.getElementById('c-weekly'), document.getElementById('wkly-tooltip'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@ -5,4 +5,6 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
Reference in New Issue
Block a user