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:
Antoine Jacquin
2026-07-16 14:14:04 +02:00
12 changed files with 1073 additions and 155 deletions

View File

@ -1,6 +1,8 @@
"""Business logic: compute worked hours and expected hours per day/week."""
from datetime import datetime, timedelta
APREM_DEBUT_MIN = 13 * 60 + 30 # 13:30 — pause déjeuner de base 12h00-13h30
def hhmm_to_minutes(val: str | None) -> int:
if not val:
@ -16,14 +18,26 @@ def minutes_to_hhmm(minutes: int) -> str:
def heures_travaillees(p: dict) -> int:
"""Return worked minutes for a pointage row."""
matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - hhmm_to_minutes(p["matin_entree"]))
aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - hhmm_to_minutes(p["aprem_entree"]))
# only count if entry AND exit are present
if not (p["matin_entree"] and p["matin_sortie"]):
matin = 0
if not (p["aprem_entree"] and p["aprem_sortie"]):
aprem = 0
"""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 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 {

48
app/classement.py Normal file
View 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

View File

@ -1,18 +1,20 @@
import json
import re
from datetime import datetime, timedelta
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.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from models import (
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 stats import compute_all_stats
from classement import compute_classement
app = FastAPI(title="Décompte Horaire")
templates = Jinja2Templates(directory="templates")
@ -20,46 +22,62 @@ templates.env.filters["zip"] = zip
app.mount("/static", StaticFiles(directory="static"), name="static")
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]:
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)]
def heures_jour_min() -> int:
cfg = load_config()
return int(float(cfg.get("heures_jour", 7.8)) * 60)
return int(float(load_config().get("heures_jour", 7.8)) * 60)
def _weeks_by_year(all_weeks: list) -> dict:
"""Group sorted weeks by year, most recent first."""
result = {}
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)
return result
def _build_soldes_ctx(year: int, week: int, conges: list[dict], h_jour: int) -> dict:
"""Compute week result for the soldes partial."""
def _build_soldes_ctx(year, week, conges, h_jour, user_id):
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}
pointages = [
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour)
return {"result": result}
return {"result": compute_week(pointages, conges, h_jour)}
def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict:
"""Return template context for a single day row."""
def _build_jour_ctx(date, conges, h_jour, user_id):
from calcul import heures_travaillees
d = datetime.strptime(date, "%Y-%m-%d").date()
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 = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_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:
"""Build OOB <span> fragments for the two calc cells (safe non-table elements)."""
def _oob_calc(date, jour):
trav_inner = f'<span class="calc-num">{"" if not jour["travaille_min"] else jour["travaille"]}</span>'
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 ""
delta_inner = f'<span class="{delta_cls}">{sign}{jour["delta"]}</span>'
delta_inner = f'<span class="{cls}">{sign}{jour["delta"]}</span>'
else:
delta_inner = '<span class="delta-zero">—</span>'
return (
@ -91,12 +108,10 @@ def _oob_calc(date: str, jour: dict) -> str:
)
def _oob_week_extras(result: dict) -> str:
"""OOB <span> for all 5 days' cumulative delta and sortie cible."""
def _oob_week_extras(result):
html = ""
for jour in result["jours"]:
date = jour["date"]
# Cumulative delta — only when chain of complete days is unbroken
cm = jour["delta_cumul_min"]
if jour.get("cumul_visible"):
if cm == 0:
@ -108,15 +123,19 @@ def _oob_week_extras(result: dict) -> str:
else:
cumul_inner = '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
# Sortie cible — only for incomplete days
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
def _oob_time_cells(date: str, jour: dict) -> str:
"""OOB <div> for matin/aprem cells with congé state and warn class."""
def _oob_time_cells(date, jour):
cg = jour["conge"]
matin_cg = "matin" 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
def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse:
"""Return OOB <div>/<span> fragments for congé state + calc + soldes. No table elements."""
def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
iso = d.isocalendar()
jour = _build_jour_ctx(date, conges, h_jour)
jour = _build_jour_ctx(date, conges, h_jour, user_id)
cg = jour["conge"]
btn_ma_on = " on" if "matin" in cg else ""
btn_am_on = " on" if "aprem" 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'</div></div>'
)
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})
return HTMLResponse(
_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:
"""Return OOB <span>/<div> for calc cells, time cells, week extras, and soldes."""
def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
iso = d.isocalendar()
jour = _build_jour_ctx(date, conges, h_jour)
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, 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, user_id)
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse(
_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, 132 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)
def index(request: Request):
if not _get_user(request):
return RedirectResponse("/login", status_code=302)
today = datetime.today()
iso = today.isocalendar()
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}")
@ -201,12 +253,16 @@ def index(request: Request):
@app.get("/semaine/{year}/{week}", response_class=HTMLResponse)
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()
conges = load_conges()
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour)
conges = load_conges(user_id)
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id)
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}
pointages = [
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)
next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
prev_iso = prev_d.isocalendar()
next_iso = next_d.isocalendar()
all_weeks = list_all_weeks()
all_weeks = list_all_weeks(user_id)
return templates.TemplateResponse("semaine.html", {
"request": request,
"year": year, "week": week,
"result": result,
**soldes_ctx,
"prev_year": prev_iso.year, "prev_week": prev_iso.week,
"next_year": next_iso.year, "next_week": next_iso.week,
"prev_year": prev_d.isocalendar().year, "prev_week": prev_d.isocalendar().week,
"next_year": next_d.isocalendar().year, "next_week": next_d.isocalendar().week,
"jours_fr": JOURS_FR,
"all_weeks": all_weeks,
"weeks_by_year": _weeks_by_year(all_weeks),
"current_user": user_id,
})
@app.post("/pointage/{date}", response_class=HTMLResponse)
def save_pointage(
request: Request,
date: str,
matin_entree: str = Form(""),
matin_sortie: str = Form(""),
aprem_entree: str = Form(""),
aprem_sortie: str = Form(""),
request: Request, date: str,
matin_entree: str = Form(""), matin_sortie: str = Form(""),
aprem_entree: str = Form(""), aprem_sortie: str = Form(""),
):
def clean(v: str):
v = v.strip()
return v if v else None
user_id = _get_user(request)
if not user_id:
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()
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[date] = {
"date": date,
"matin_entree": clean(matin_entree),
"matin_sortie": clean(matin_sortie),
"aprem_entree": clean(aprem_entree),
"aprem_sortie": clean(aprem_sortie),
"matin_entree": clean(matin_entree), "matin_sortie": clean(matin_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()
return _htmx_calc_and_soldes(request, date, d, conges, h_jour)
@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)
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)
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({
"time_stats": {
slot: {
@ -286,18 +366,19 @@ def stats_page(request: Request):
"request": request,
"stats": stats,
"data_json": data_json,
"current_user": user_id,
})
@app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse)
def toggle_conge_route(date: str, type_conge: str, request: Request):
toggle_conge(date, type_conge)
d = datetime.strptime(date, "%Y-%m-%d").date()
if request.headers.get("HX-Request"):
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)
@app.get("/classement", response_class=HTMLResponse)
def classement_page(request: Request):
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
ranking = compute_classement()
return templates.TemplateResponse("classement.html", {
"request": request,
"ranking": ranking,
"current_user": user_id,
})

View File

@ -1,25 +1,75 @@
"""Flat-file storage: JSON files per week for pointages, one file for congés."""
import json
import shutil
from pathlib import Path
DATA_DIR = Path("/data")
POINTAGES_DIR = DATA_DIR / "pointages"
CONGES_FILE = DATA_DIR / "conges.json"
CONFIG_FILE = DATA_DIR / "config.json"
DEFAULT_CONFIG = {
"heures_jour": 7.8, # 7h48 = 39h/semaine
}
# ── User path helpers ──────────────────────────────────────────────────────────
def _ensure_dirs():
POINTAGES_DIR.mkdir(parents=True, exist_ok=True)
def _user_root(user_id: str) -> Path:
"""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:
_ensure_dirs()
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
if CONFIG_FILE.exists():
return json.loads(CONFIG_FILE.read_text())
CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2))
@ -27,78 +77,62 @@ def load_config() -> 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))
# ---------- pointages ----------
# ── Pointages ─────────────────────────────────────────────────────────────────
def _week_file(year: int, week: int) -> Path:
return POINTAGES_DIR / f"{year}-W{week:02d}.json"
def _week_file(user_id: str, year: int, week: int) -> Path:
return _pointages_dir(user_id) / f"{year}-W{week:02d}.json"
def load_week_pointages(year: int, week: int) -> list[dict]:
f = _week_file(year, week)
if f.exists():
return json.loads(f.read_text())
return []
def load_week_pointages(year: int, week: int, user_id: str = "") -> list[dict]:
f = _week_file(user_id, year, week)
return json.loads(f.read_text()) if f.exists() else []
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 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 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()
def list_all_weeks(user_id: str = "") -> list[tuple[int, int]]:
pt_dir = _pointages_dir(user_id)
if not pt_dir.exists():
return []
weeks = []
for f in POINTAGES_DIR.glob("????-W??.json"):
for f in pt_dir.glob("????-W??.json"):
parts = f.stem.split("-W")
if len(parts) == 2:
weeks.append((int(parts[0]), int(parts[1])))
return sorted(weeks)
# ---------- congés ----------
# ── Congés ────────────────────────────────────────────────────────────────────
def load_conges() -> list[dict]:
if CONGES_FILE.exists():
return json.loads(CONGES_FILE.read_text())
return []
def load_conges(user_id: str = "") -> list[dict]:
f = _conges_file(user_id)
return json.loads(f.read_text()) if f.exists() else []
def save_conges(conges: list[dict]):
_ensure_dirs()
CONGES_FILE.write_text(json.dumps(conges, indent=2, ensure_ascii=False))
def save_conges(conges: list[dict], user_id: str = ""):
_ensure_user_dirs(user_id)
_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")
conges = load_conges()
conges = load_conges(user_id)
already = any(c["date"] == date and c["type"] == type_conge for c in conges)
if already:
conges = [c for c in conges if not (c["date"] == date and c["type"] == type_conge)]
else:
# Adding: remove conflicting entries first
if type_conge == "jour":
conges = [c for c in conges if c["date"] != date]
else:
@ -106,4 +140,4 @@ def toggle_conge(date: str, type_conge: str):
conges.append({"date": date, "type": type_conge})
conges.sort(key=lambda c: (c["date"], c["type"]))
save_conges(conges)
save_conges(conges, user_id)

View File

@ -5,6 +5,11 @@ from datetime import datetime, timedelta, date as _date
from models import load_week_pointages, load_conges, list_all_weeks, load_config
from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues
MOIS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
def _week_dates(year: int, week: int) -> list[str]:
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
@ -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}
def compute_all_stats() -> dict:
all_weeks = sorted(list_all_weeks())
conges = load_conges()
def compute_all_stats(user_id: str = "") -> dict:
all_weeks = sorted(list_all_weeks(user_id))
conges = load_conges(user_id)
h_jour = int(float(load_config().get("heures_jour", 7.8)) * 60)
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)
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}
comp_n = {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] = []
for year, week in all_weeks:
raw = load_week_pointages(year, week)
raw = load_week_pointages(year, week, user_id)
if not raw:
continue
@ -135,13 +139,18 @@ def compute_all_stats() -> dict:
weekly_balances.append({
"year": year, "week": week,
"label": f"S{week:02d}",
"date": dates[0], # Monday — used for period filtering
"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:
@ -163,7 +172,6 @@ def compute_all_stats() -> dict:
for slot in comp_n
}
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"]))
deltas = [b["delta_min"] for b in weekly_balances]
@ -176,7 +184,6 @@ def compute_all_stats() -> dict:
return {
"time_stats": time_stats,
"compliance": compliance,
"overall_compliance": overall_slot,
"weekly_balances": weekly_balances,
"total_weeks": len(weekly_balances),
"n_jours": n_jours,
@ -185,7 +192,7 @@ def compute_all_stats() -> dict:
"comp_globale": comp_globale,
"comp_mois": comp_mois,
"comp_sem_n1": comp_sem_n1,
"mois_label": today.strftime("%B %Y"),
"mois_label": f"{MOIS_FR[today.month - 1].capitalize()} {today.year}",
"prev_week_label": f"S{prev_iso.week:02d} / {prev_iso.year}",
"today": today.strftime("%Y-%m-%d"),
}

View File

@ -69,8 +69,18 @@
<td class="col-cible">
<span id="calc-{{ j.date }}-cible">
{%- if j.sortie_cible and not j.is_complete -%}
<span class="cible-num">{{ j.sortie_cible }}</span>
{%- 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 %}

View File

@ -79,6 +79,13 @@
transition: color .15s;
}
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 ── */
.container {
@ -743,6 +750,7 @@
margin-bottom: .75rem;
}
.wkly-wrap {
position: relative;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
@ -767,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;
@ -824,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>
@ -836,6 +930,11 @@
<span class="spacer"></span>
<a href="/" class="nav-link">Semaine courante</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>
<div class="container">
{% block content %}{% endblock %}

View 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é (0100)">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
View 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 %}

View File

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

View File

@ -34,7 +34,7 @@
<canvas class="cp-donut" id="cpd-{{ loop.index }}" width="90" height="90"
data-pct="{{ pct if pct is not none else 0 }}"
data-has-data="{{ 'true' if pct is not none else 'false' }}"
aria-label="{{ pct }}%"></canvas>
aria-label="{{ (pct ~ '%') if pct is not none else 'pas de données' }}"></canvas>
</div>
<div class="cp-n {{ cls if pct is not none }}">
{% if pct is not none %}{{ comp.n_ok }}/{{ comp.n }} jours conformes
@ -107,6 +107,7 @@
<canvas id="c-weekly" class="zoomable" height="180"
data-chart-type="weekly" data-chart-title="Soldes hebdomadaires"
aria-label="Soldes hebdomadaires"></canvas>
<div class="chart-tooltip" id="wkly-tooltip"></div>
</div>
{% set has_partial = stats.weekly_balances | selectattr("partial") | list %}
{% if has_partial %}
@ -127,6 +128,7 @@
</div>
<div class="chart-modal-body">
<canvas id="chart-modal-canvas"></canvas>
<div class="chart-tooltip" id="modal-tooltip"></div>
</div>
</div>
</div>
@ -191,6 +193,34 @@
return Math.round(100 * ok / vals.length);
}
/* ─── Rounded bar helper: flat at the baseline, rounded at the growing end ─── */
function drawBarFromBaseline(ctx, x, w, baseline, far, r) {
const grows_up = far < baseline;
const top = grows_up ? far : baseline;
const h = Math.abs(baseline - far);
r = Math.max(0, Math.min(r, w / 2, h));
if (r <= 0.5 || h <= 0) { ctx.fillRect(x, top, w, h); return; }
ctx.beginPath();
if (grows_up) {
ctx.moveTo(x, top + h);
ctx.lineTo(x, top + r);
ctx.arcTo(x, top, x + r, top, r);
ctx.lineTo(x + w - r, top);
ctx.arcTo(x + w, top, x + w, top + r, r);
ctx.lineTo(x + w, top + h);
} else {
ctx.moveTo(x, top);
ctx.lineTo(x + w, top);
ctx.lineTo(x + w, top + h - r);
ctx.arcTo(x + w, top + h, x + w - r, top + h, r);
ctx.lineTo(x + r, top + h);
ctx.arcTo(x, top + h, x, top + h - r, r);
ctx.lineTo(x, top);
}
ctx.closePath();
ctx.fill();
}
/* ─── Donut ─── */
function drawDonut(canvas, pct, op, progress) {
const W = canvas.width, H = canvas.height;
@ -209,7 +239,7 @@
}
const color = pct >= 95 ? C.pos : (pct >= 80 ? C.warn : C.neg);
ctx.beginPath(); ctx.arc(cx, cy, r, -Math.PI/2, -Math.PI/2 + 2*Math.PI*(pct/100)*progress);
ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineCap = 'butt'; ctx.stroke();
ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineCap = pct >= 100 ? 'butt' : 'round'; ctx.stroke();
if (progress >= 1) {
ctx.fillStyle = color;
ctx.font = `500 ${Math.round(W*0.19)}px ${MONO}`;
@ -262,7 +292,7 @@
if (!count) return;
const x = (i/nBins)*W, bh = (count/maxBin)*chartH;
ctx.fillStyle = color + 'BB';
ctx.fillRect(x, PAD_T+chartH-bh, bw, bh);
drawBarFromBaseline(ctx, x, bw, PAD_T + chartH, PAD_T + chartH - bh, 2);
});
// Baseline
@ -337,7 +367,7 @@
}
/* ─── Weekly bars ─── */
function _drawWeekly(canvas, balances) {
function _drawWeekly(canvas, balances, hoverIndex) {
if (!balances || !balances.length) return;
const W = canvas.width, H = canvas.height;
const ctx = canvas.getContext('2d');
@ -371,26 +401,36 @@
}
ctx.fillText('0', PAD_L-5, midY+3);
const layout = [];
balances.forEach((b, i) => {
const x = PAD_L+i*barSlot+(barSlot-barW)/2;
const hPx = (Math.abs(b.delta_min)/axisMax)*(chartH/2);
const far = b.delta_min >= 0 ? midY-hPx : midY+hPx;
layout.push({ x, w: barW, top: Math.min(midY, far), bottom: Math.max(midY, far), b });
const hovered = i === hoverIndex;
const baseColor = b.delta_min >= 0 ? C.pos : C.neg;
const alpha = b.partial ? '55' : 'CC';
const alpha = hovered ? 'FF' : (b.partial ? '55' : 'CC');
if (b.delta_min >= 0) {
const g = ctx.createLinearGradient(x, midY-hPx, x, midY);
g.addColorStop(0, baseColor+alpha); g.addColorStop(1, baseColor+(b.partial?'33':'88'));
ctx.fillStyle = g; ctx.fillRect(x, midY-hPx, barW, hPx);
g.addColorStop(0, baseColor+alpha); g.addColorStop(1, baseColor+(hovered ? 'AA' : (b.partial?'33':'88')));
ctx.fillStyle = g; drawBarFromBaseline(ctx, x, barW, midY, midY-hPx, 4);
} else {
const g = ctx.createLinearGradient(x, midY, x, midY+hPx);
g.addColorStop(0, baseColor+(b.partial?'33':'88')); g.addColorStop(1, baseColor+alpha);
ctx.fillStyle = g; ctx.fillRect(x, midY, barW, hPx);
g.addColorStop(0, baseColor+(hovered ? 'AA' : (b.partial?'33':'88'))); g.addColorStop(1, baseColor+alpha);
ctx.fillStyle = g; drawBarFromBaseline(ctx, x, barW, midY, midY+hPx, 4);
}
if (b.partial && hPx > 2) {
const yEdge = b.delta_min >= 0 ? midY-hPx : midY+hPx;
ctx.save(); ctx.strokeStyle = baseColor+'88'; ctx.lineWidth=1; ctx.setLineDash([2,2]);
ctx.beginPath(); ctx.moveTo(x,yEdge); ctx.lineTo(x+barW,yEdge); ctx.stroke(); ctx.restore();
}
if (hovered) {
ctx.save(); ctx.strokeStyle = baseColor; ctx.lineWidth = 1.5;
ctx.strokeRect(x+0.75, Math.min(midY,far)+0.75, Math.max(0,barW-1.5), Math.max(0,hPx-1.5));
ctx.restore();
}
});
canvas.__wklyLayout = { layout, PAD_L, PAD_R, chartW };
ctx.font = `9px ${MONO}`; ctx.fillStyle = C.muted;
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
@ -408,6 +448,7 @@
if (!c) return;
c.width = c.parentElement.clientWidth || 800;
const balances = filterWeekly(currentPeriod);
c.__wklyBalances = balances;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
_drawWeekly(c, balances); return;
}
@ -424,6 +465,52 @@
requestAnimationFrame(step);
}
/* ─── Weekly hover: tooltip + bar lift, hit target = the bar's whole column ─── */
function attachWeeklyHover(canvas, tooltipEl) {
if (!canvas || !tooltipEl || canvas.__hoverAttached) return;
canvas.__hoverAttached = true;
let hoverIndex = -1;
function clear() {
const balances = canvas.__wklyBalances;
if (balances && hoverIndex !== -1) _drawWeekly(canvas, balances, -1);
hoverIndex = -1;
tooltipEl.classList.remove('visible');
}
canvas.addEventListener('mousemove', evt => {
const info = canvas.__wklyLayout, balances = canvas.__wklyBalances;
if (!info || !balances || !balances.length) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width, scaleY = canvas.height / rect.height;
const xCanvas = (evt.clientX - rect.left) * scaleX;
const { PAD_L, chartW } = info;
if (xCanvas < PAD_L || xCanvas > PAD_L + chartW) { clear(); return; }
const barSlot = chartW / balances.length;
const idx = Math.min(balances.length - 1, Math.max(0, Math.floor((xCanvas - PAD_L) / barSlot)));
if (idx !== hoverIndex) { hoverIndex = idx; _drawWeekly(canvas, balances, idx); }
const b = balances[idx];
const sign = b.delta_min > 0 ? '+' : (b.delta_min < 0 ? '' : '');
const abs = Math.abs(b.delta_min);
tooltipEl.replaceChildren();
const valSpan = document.createElement('span');
valSpan.className = 'ct-val';
valSpan.textContent = `${sign}${Math.floor(abs/60)}h${String(abs%60).padStart(2,'0')}`;
const labelSpan = document.createElement('span');
labelSpan.className = 'ct-label';
labelSpan.textContent = b.label + (b.partial ? ' · incomplète' : '');
tooltipEl.append(valSpan, labelSpan);
tooltipEl.classList.add('visible');
const entry = canvas.__wklyLayout.layout[idx];
tooltipEl.style.left = ((entry.x + entry.w/2) / scaleX) + 'px';
tooltipEl.style.top = (entry.top / scaleY - 6) + 'px';
});
canvas.addEventListener('mouseleave', clear);
}
/* ─── Update everything for a period ─── */
function updateForPeriod(period) {
currentPeriod = period;
@ -485,12 +572,17 @@
document.getElementById('chart-modal-title').textContent = title;
document.getElementById('modal-tooltip').classList.remove('visible');
if (type === 'dist') {
mc.width = maxW; mc.height = 180;
mc.__wklyBalances = null;
_drawDist(mc, slot, true);
} else if (type === 'weekly') {
mc.width = maxW; mc.height = 300;
_drawWeekly(mc, filterWeekly(currentPeriod));
const balances = filterWeekly(currentPeriod);
mc.__wklyBalances = balances;
_drawWeekly(mc, balances);
attachWeeklyHover(mc, document.getElementById('modal-tooltip'));
}
document.getElementById('chart-modal').removeAttribute('hidden');
@ -527,6 +619,7 @@
if (el) animateDonut(el, parseInt(el.dataset.pct), el.dataset.op);
});
drawWeekly();
attachWeeklyHover(document.getElementById('c-weekly'), document.getElementById('wkly-tooltip'));
}
if (document.readyState === 'loading') {

View File

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