From 761a86986a927063aceb16fe1d96a7cd67ec81bb Mon Sep 17 00:00:00 2001 From: Antoine Date: Thu, 16 Jul 2026 14:12:03 +0200 Subject: [PATCH] =?UTF-8?q?Projette=20les=20cibles=20en=20temps=20r=C3=A9e?= =?UTF-8?q?l=20et=20corrige=20le=20fuseau=20horaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calcule les heures travaillées et cibles d'entrée/sortie même quand une demi-journée est en cours (pas encore de pointage de sortie), au lieu d'attendre la fin de journée. Ajoute un endpoint /refresh pour rafraîchir l'affichage sans repointer. Désambiguïse les libellés de semaine sur plusieurs années et force TZ=Europe/Paris dans le conteneur. Co-Authored-By: Claude Sonnet 5 --- app/calcul.py | 64 ++++++++++++++++++--- app/main.py | 19 ++++++- app/stats.py | 14 ++++- app/templates/_ligne.html | 14 ++++- app/templates/base.html | 87 ++++++++++++++++++++++++++++ app/templates/semaine.html | 111 ++++++++++++++++++++++++++++++++++++ app/templates/stats.html | 113 +++++++++++++++++++++++++++++++++---- compose.yml | 2 + 8 files changed, 400 insertions(+), 24 deletions(-) diff --git a/app/calcul.py b/app/calcul.py index 01b8ff8..ebd6cd3 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -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 { diff --git a/app/main.py b/app/main.py index 70c6ef7..ef2425e 100644 --- a/app/main.py +++ b/app/main.py @@ -123,7 +123,13 @@ def _oob_week_extras(result): else: cumul_inner = '' html += f'{cumul_inner}' - 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'{cible}' if cible else '' html += f'{cible_inner}' return html @@ -310,6 +316,17 @@ def save_pointage( return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id) +@app.get("/refresh/{date}", response_class=HTMLResponse) +def refresh_pointage(request: Request, date: str): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + d = datetime.strptime(date, "%Y-%m-%d").date() + conges = load_conges(user_id) + return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id) + + @app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse) def toggle_conge_route(date: str, type_conge: str, request: Request): user_id = _get_user(request) diff --git a/app/stats.py b/app/stats.py index 7ddc507..c56038e 100644 --- a/app/stats.py +++ b/app/stats.py @@ -5,6 +5,11 @@ from datetime import datetime, timedelta, date as _date from models import load_week_pointages, load_conges, list_all_weeks, load_config from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues +MOIS_FR = [ + "janvier", "février", "mars", "avril", "mai", "juin", + "juillet", "août", "septembre", "octobre", "novembre", "décembre", +] + def _week_dates(year: int, week: int) -> list[str]: first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() @@ -134,13 +139,18 @@ def compute_all_stats(user_id: str = "") -> dict: weekly_balances.append({ "year": year, "week": week, - "label": f"S{week:02d}", "date": dates[0], "delta_min": week_delta, "delta": minutes_to_hhmm(week_delta), "partial": week_has_incomplete, }) + # Suffix the label with a 2-digit year when weeks span more than one year, + # otherwise "S24" is ambiguous between e.g. 2024 and 2026. + spans_multiple_years = len({b["year"] for b in weekly_balances}) > 1 + for b in weekly_balances: + b["label"] = f"S{b['week']:02d}'{b['year'] % 100:02d}" if spans_multiple_years else f"S{b['week']:02d}" + time_stats = {} for slot, pairs in slot_pairs.items(): if len(pairs) < 2: @@ -182,7 +192,7 @@ def compute_all_stats(user_id: str = "") -> dict: "comp_globale": comp_globale, "comp_mois": comp_mois, "comp_sem_n1": comp_sem_n1, - "mois_label": today.strftime("%B %Y"), + "mois_label": f"{MOIS_FR[today.month - 1].capitalize()} {today.year}", "prev_week_label": f"S{prev_iso.week:02d} / {prev_iso.year}", "today": today.strftime("%Y-%m-%d"), } diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index 40b102c..cbca643 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -69,8 +69,18 @@ - {%- if j.sortie_cible and not j.is_complete -%} - {{ j.sortie_cible }} + {%- if not j.is_complete -%} + {%- if j.entree_cible -%} + {%- if 'aprem' in j.conge -%} + {{ j.entree_cible }}→12:00 + {%- else -%} + {{ j.entree_cible }}→17:00 + {%- endif -%} + {%- elif j.sortie_cible -%} + {{ j.sortie_cible }} + {%- else -%} + + {%- endif %} {%- else -%} {%- endif %} diff --git a/app/templates/base.html b/app/templates/base.html index 278ba8a..5af9d3b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -750,6 +750,7 @@ margin-bottom: .75rem; } .wkly-wrap { + position: relative; overflow-x: auto; -webkit-overflow-scrolling: touch; } @@ -774,6 +775,71 @@ /* Zoomable canvas hint */ canvas.zoomable { cursor: zoom-in; } + /* ── Alerte départ ── */ + #alerte-depart { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9999; + background: var(--neg); + color: #fff; + border-left: 4px solid #ff6b5b; + box-shadow: 0 6px 28px rgba(0,0,0,.3); + min-width: 240px; + animation: alerte-slide .35s cubic-bezier(.2,.8,.3,1) both; + } + .alerte-inner { + display: flex; + align-items: center; + gap: .75rem; + padding: .85rem 1rem; + } + .alerte-icon { font-size: 1.5rem; flex-shrink: 0; animation: alerte-pulse 1s ease-in-out 3; } + .alerte-body { flex: 1; } + .alerte-label { + font-family: var(--mono); + font-size: 9px; + letter-spacing: .12em; + text-transform: uppercase; + opacity: .7; + margin-bottom: .2rem; + } + .alerte-time { + font-family: var(--mono); + font-size: 1.75rem; + font-weight: 500; + letter-spacing: -.03em; + line-height: 1; + } + .alerte-close { + background: none; + border: 1px solid rgba(255,255,255,.3); + color: #fff; + width: 24px; height: 24px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; + font-size: 12px; + flex-shrink: 0; + align-self: flex-start; + transition: background .15s; + } + .alerte-close:hover { background: rgba(255,255,255,.15); } + .alerte-supp { + font-family: var(--mono); + font-size: 11px; + opacity: .8; + margin-top: .25rem; + letter-spacing: .02em; + } + @keyframes alerte-slide { + from { opacity: 0; transform: translateX(120%); } + to { opacity: 1; transform: translateX(0); } + } + @keyframes alerte-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.25); } + } + /* ── Chart zoom modal ── */ .chart-modal { position: fixed; @@ -831,10 +897,31 @@ .chart-modal-close:hover { border-color: var(--text); color: var(--text); } .chart-modal-close:focus { outline: 2px solid var(--accent); outline-offset: 2px; } .chart-modal-body { + position: relative; padding: 1.25rem; overflow-x: auto; } #chart-modal-canvas { display: block; } + + .chart-tooltip { + position: absolute; + pointer-events: none; + background: var(--surface); + border: 1.5px solid var(--rule); + box-shadow: 0 2px 8px rgba(28,23,18,.12); + padding: .35rem .55rem; + font-family: var(--mono); + font-size: 11px; + color: var(--text); + white-space: nowrap; + opacity: 0; + transform: translate(-50%, -100%); + transition: opacity .1s; + z-index: 5; + } + .chart-tooltip.visible { opacity: 1; } + .chart-tooltip .ct-val { font-weight: 500; } + .chart-tooltip .ct-label { color: var(--muted); margin-left: .35em; } diff --git a/app/templates/semaine.html b/app/templates/semaine.html index 48c847c..3644681 100644 --- a/app/templates/semaine.html +++ b/app/templates/semaine.html @@ -204,6 +204,117 @@ })(); + + + +