Initial commit — décompte horaire

App web de suivi des heures de travail (FastAPI + HTMX + JSON plats).
- Saisie inline par semaine avec bouton ✓ par ligne
- Congés MA/AM/J avec coloration cellule et mise à jour live via OOB HTMX
- Import fichier ODS badgeuse
- Solde semaine et solde global recalculés à la volée
- Design "ledger" écru, DM Mono, zéro border-radius
- Déploiement Docker sur port 8000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
toto
2026-06-24 15:08:32 +02:00
commit 6f6c7f154e
14 changed files with 1398 additions and 0 deletions

74
app/templates/_ligne.html Normal file
View File

@ -0,0 +1,74 @@
<tr id="row-{{ j.date }}">
<td class="col-jour">
<span class="jour-nom">{{ nom }}</span>
<span class="jour-date">{{ j.date[8:] }}/{{ j.date[5:7] }}</span>
</td>
<td class="c-matin">
<div id="cg-matin-{{ j.date }}" class="td-fill{% if 'matin' in j.conge or 'jour' in j.conge %} cg{% endif %}">
<div class="time-pair">
<input class="time-input" type="time" name="matin_entree"
data-date="{{ j.date }}" value="{{ j.matin_entree or '' }}">
<span class="time-sep"></span>
<input class="time-input" type="time" name="matin_sortie"
data-date="{{ j.date }}" value="{{ j.matin_sortie or '' }}">
</div>
</div>
</td>
<td class="c-aprem">
<div id="cg-aprem-{{ j.date }}" class="td-fill{% if 'aprem' in j.conge or 'jour' in j.conge %} cg{% endif %}">
<div class="time-pair">
<input class="time-input" type="time" name="aprem_entree"
data-date="{{ j.date }}" value="{{ j.aprem_entree or '' }}">
<span class="time-sep"></span>
<input class="time-input" type="time" name="aprem_sortie"
data-date="{{ j.date }}" value="{{ j.aprem_sortie or '' }}">
</div>
</div>
</td>
<td class="col-calc">
<span id="calc-{{ j.date }}-trav">
<span class="calc-num">{{ j.travaille if j.travaille_min else '—' }}</span>
</span>
</td>
<td class="col-calc">
<span id="calc-{{ j.date }}-delta">
<span class="{% if j.delta_min > 0 %}delta-pos{% elif j.delta_min < 0 %}delta-neg{% else %}delta-zero{% endif %}">
{%- if j.travaille_min or j.du_min -%}
{% if j.delta_min > 0 %}+{% endif %}{{ j.delta }}
{%- else %}—{%- endif %}
</span>
</span>
</td>
<td class="col-save">
<button class="btn-valider"
hx-post="/pointage/{{ j.date }}"
hx-swap="none"
hx-include="[data-date='{{ j.date }}']"
title="Enregistrer"></button>
</td>
<td class="col-conge">
<div id="cg-btn-{{ j.date }}">
<div class="conge-btns">
<button hx-post="/conge/{{ j.date }}/matin"
hx-swap="none"
class="btn-cg {% if 'matin' in j.conge %}on{% endif %}"
title="Congé matin">MA</button>
<button hx-post="/conge/{{ j.date }}/aprem"
hx-swap="none"
class="btn-cg {% if 'aprem' in j.conge %}on{% endif %}"
title="Congé après-midi">AM</button>
<button hx-post="/conge/{{ j.date }}/jour"
hx-swap="none"
class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}"
title="Journée entière">J</button>
</div>
</div>
</td>
</tr>

View File

@ -0,0 +1,24 @@
<div id="soldes-section" hx-swap-oob="true">
<div class="soldes">
<div class="solde-item">
<div class="solde-eyebrow">Travaillé</div>
<div class="solde-num neutral">{{ result.total_travaille }}</div>
</div>
<div class="solde-item">
<div class="solde-eyebrow">Dû cette semaine</div>
<div class="solde-num neutral">{{ result.total_du }}</div>
</div>
<div class="solde-item {% if result.solde_min > 0 %}item-pos{% elif result.solde_min < 0 %}item-neg{% endif %}">
<div class="solde-eyebrow">Solde semaine</div>
<div class="solde-num {% if result.solde_min > 0 %}pos{% elif result.solde_min < 0 %}neg{% endif %}">
{% if result.solde_min > 0 %}+{% endif %}{{ result.solde }}
</div>
</div>
<div class="solde-item {% if solde_global_min > 0 %}item-pos{% elif solde_global_min < 0 %}item-neg{% endif %}">
<div class="solde-eyebrow">Solde global</div>
<div class="solde-num {% if solde_global_min > 0 %}pos{% elif solde_global_min < 0 %}neg{% endif %}">
{% if solde_global_min > 0 %}+{% endif %}{{ solde_global }}
</div>
</div>
</div>
</div>

515
app/templates/base.html Normal file
View File

@ -0,0 +1,515 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Décompte Horaire</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet">
<script src="/static/htmx.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ground: #F4F0E8;
--surface: #FDFAF4;
--text: #1C1712;
--muted: #6B6152;
--rule: #D8D0C0;
--rule-l: #EAE5DA;
--accent: #2F5FA0;
--accent-2: #B86A08;
--pos: #2A5E42;
--pos-bg: #EBF5EE;
--neg: #9B3528;
--neg-bg: #F9EDEB;
--conge-bg: #FEF6E4;
--conge-bd: #E8C97A;
--conge-txt: #7A5300;
--nav-bg: #1C1712;
--mono: 'DM Mono', 'Cascadia Code', ui-monospace, monospace;
--sans: 'DM Sans', system-ui, -apple-system, sans-serif;
}
body {
font-family: var(--sans);
background: var(--ground);
color: var(--text);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
}
/* ── Nav ── */
nav {
background: var(--nav-bg);
color: var(--ground);
height: 50px;
padding: 0 2rem;
display: flex;
align-items: center;
gap: 2rem;
border-bottom: 3px solid var(--accent-2);
}
.nav-brand {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
color: var(--ground);
text-decoration: none;
letter-spacing: .1em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: .55rem;
}
.nav-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--accent-2);
display: inline-block;
flex-shrink: 0;
}
nav .spacer { flex: 1; }
nav a.nav-link {
color: rgba(244,240,232,.55);
text-decoration: none;
font-size: 12px;
letter-spacing: .02em;
transition: color .15s;
}
nav a.nav-link:hover { color: var(--ground); }
/* ── Layout ── */
.container {
max-width: 940px;
margin: 0 auto;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
/* ── Week header ── */
.week-header {
display: flex;
align-items: center;
gap: 1rem;
border-bottom: 2px solid var(--text);
padding-bottom: .75rem;
}
.week-nav-arrows { display: flex; gap: .4rem; }
.arrow-btn {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border: 1.5px solid var(--rule);
background: transparent;
cursor: pointer;
font-size: 14px;
color: var(--muted);
text-decoration: none;
transition: border-color .15s, color .15s;
}
.arrow-btn:hover { border-color: var(--text); color: var(--text); }
.week-id {
font-family: var(--mono);
font-size: 1.75rem;
font-weight: 500;
letter-spacing: -.02em;
line-height: 1;
}
.week-id .wk { color: var(--muted); font-size: 1rem; font-weight: 300; }
.week-id .slash { color: var(--rule); margin: 0 .2em; }
/* ── Soldes ── */
.soldes {
display: grid;
grid-template-columns: repeat(4, 1fr);
border: 1.5px solid var(--rule);
background: var(--surface);
}
@media (max-width: 600px) { .soldes { grid-template-columns: 1fr 1fr; } }
.solde-item {
padding: 1rem 1.25rem;
border-right: 1px solid var(--rule);
}
.solde-item:last-child { border-right: none; }
.solde-item.item-pos { background: var(--pos-bg); }
.solde-item.item-neg { background: var(--neg-bg); }
.solde-eyebrow {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: .45rem;
}
.solde-num {
font-family: var(--mono);
font-size: 1.9rem;
font-weight: 500;
letter-spacing: -.03em;
line-height: 1;
}
.solde-num.pos { color: var(--pos); }
.solde-num.neg { color: var(--neg); }
.solde-num.neutral { color: var(--text); }
/* ── Table ── */
.table-section { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
border: 1.5px solid var(--rule);
background: var(--surface);
}
.th-group th {
padding: .45rem .85rem .25rem;
font-family: var(--mono);
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
font-weight: 500;
color: var(--muted);
border-bottom: 1px solid var(--rule-l);
background: var(--ground);
white-space: nowrap;
}
.th-group .g-matin {
background: rgba(47,95,160,.07);
color: var(--accent);
border-left: 3px solid var(--accent);
text-align: center;
}
.th-group .g-aprem {
background: rgba(184,106,8,.07);
color: var(--accent-2);
border-left: 3px solid var(--accent-2);
text-align: center;
}
.th-group .g-calc { text-align: right; }
.th-group .g-conge { text-align: center; }
.th-sub th {
padding: .25rem .85rem .5rem;
font-size: 10px;
color: rgba(107,97,82,.65);
border-bottom: 2px solid var(--text);
font-weight: 400;
background: var(--ground);
white-space: nowrap;
}
.th-sub .s-matin {
background: rgba(47,95,160,.04);
border-left: 3px solid var(--accent);
color: rgba(47,95,160,.75);
text-align: center;
font-family: var(--mono);
font-size: 9px;
letter-spacing: .04em;
}
.th-sub .s-aprem {
background: rgba(184,106,8,.04);
border-left: 3px solid var(--accent-2);
color: rgba(184,106,8,.85);
text-align: center;
font-family: var(--mono);
font-size: 9px;
letter-spacing: .04em;
}
.th-sub .s-calc { text-align: right; }
.th-sub .s-conge { text-align: center; }
tbody tr { transition: background .1s; }
tbody tr:hover td { background: rgba(47,95,160,.025); }
tbody tr:hover .td-fill { background: rgba(47,95,160,.025); }
tbody tr:hover .td-fill.cg { background: var(--conge-bg); }
tbody tr:not(:last-child) td { border-bottom: 1px solid var(--rule-l); }
td {
padding: .6rem .85rem;
vertical-align: middle;
}
/* Matin / aprem cells: padding moved to inner .td-fill so OOB can target it */
td.c-matin, td.c-aprem { padding: 0; }
.td-fill {
display: flex;
align-items: center;
padding: .6rem .85rem;
}
td.c-matin .td-fill { border-left: 3px solid rgba(47,95,160,.2); }
td.c-aprem .td-fill { border-left: 3px solid rgba(184,106,8,.2); }
/* Congé state — applied to the inner div, not the <td> */
.td-fill.cg {
background: var(--conge-bg);
}
td.c-matin .td-fill.cg,
td.c-aprem .td-fill.cg { border-left-color: var(--conge-bd); }
.td-fill.cg .time-input { background: var(--conge-bg); }
.col-jour { white-space: nowrap; }
.jour-nom { display: block; font-weight: 500; font-size: 13px; }
.jour-date { display: block; font-family: var(--mono); font-size: 11px; color: var(--muted); }
.time-pair { display: flex; align-items: center; gap: 4px; justify-content: center; }
.time-sep { color: var(--rule); font-size: 11px; flex-shrink: 0; }
.time-input {
width: 80px;
padding: .28rem .4rem;
border: 1px solid var(--rule);
border-radius: 0;
font-family: var(--mono);
font-size: 12px;
color: var(--text);
background: transparent;
text-align: center;
transition: border-color .15s;
}
.time-input:hover { border-color: var(--muted); }
.time-input:focus { outline: none; border-color: var(--accent); background: #fff; }
tr.htmx-request .time-input { opacity: .4; }
.col-calc { text-align: right; white-space: nowrap; width: 72px; }
.calc-num { font-family: var(--mono); font-size: 13px; color: var(--muted); }
.delta-pos { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--pos); }
.delta-neg { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--neg); }
.delta-zero{ font-family: var(--mono); font-size: 13px; color: var(--rule); }
.col-save { width: 36px; text-align: center; padding: .4rem .3rem; }
.g-save { width: 36px; background: var(--ground); }
.btn-valider {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px; height: 26px;
border: 1.5px solid var(--accent);
background: transparent;
cursor: pointer;
font-size: 13px;
color: var(--accent);
transition: background .15s, color .15s;
}
.btn-valider:hover { background: var(--accent); color: #fff; }
.btn-valider.htmx-request { opacity: .4; pointer-events: none; }
.col-conge { width: 88px; text-align: center; }
.conge-btns { display: flex; gap: 3px; justify-content: center; }
.btn-cg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px; height: 24px;
border: 1.5px solid var(--rule);
background: transparent;
cursor: pointer;
font-family: var(--mono);
font-size: 9px;
font-weight: 500;
letter-spacing: .04em;
color: var(--muted);
transition: all .15s;
}
.btn-cg:hover { border-color: var(--conge-txt); color: var(--conge-txt); background: var(--conge-bg); }
.btn-cg.on { background: var(--conge-bg); border-color: var(--conge-bd); color: var(--conge-txt); }
.btn-cg.btn-jour { border-radius: 50%; width: 22px; height: 22px; font-size: 9px; }
.btn-cg.jour-on { background: var(--accent-2); border-color: #955505; color: #fff; }
/* ── Historique ── */
.history {
display: flex;
flex-direction: column;
gap: .75rem;
border-top: 1px solid var(--rule);
padding-top: 1.25rem;
}
.hist-label {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.hist-body { display: flex; flex-direction: column; gap: .6rem; }
.hist-year-group { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.hist-year {
font-family: var(--mono);
font-size: 11px;
font-weight: 500;
color: var(--muted);
width: 36px;
flex-shrink: 0;
}
.hist-chips { display: flex; flex-wrap: wrap; gap: .3rem; }
.chip {
font-family: var(--mono);
font-size: 11px;
padding: .18rem .5rem;
border: 1px solid var(--rule);
color: var(--muted);
text-decoration: none;
letter-spacing: .02em;
transition: all .15s;
}
.chip:hover { border-color: var(--text); color: var(--text); }
.chip.here { background: var(--text); border-color: var(--text); color: var(--ground); }
/* ── Import ── */
.import-wrap { padding: 2rem; }
.import-wrap h2 { font-size: 1rem; font-weight: 600; margin-bottom: .75rem; border-bottom: 1px solid var(--rule); padding-bottom: .5rem; }
.import-desc { color: var(--muted); font-size: 13px; margin-bottom: 1.25rem; line-height: 1.65; }
.file-input { display: block; margin-bottom: 1rem; font-size: 13px; }
.btn-submit {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .5rem 1.1rem;
border: 1.5px solid var(--text);
background: var(--text);
color: var(--ground);
font-size: 12px;
font-family: var(--mono);
font-weight: 500;
letter-spacing: .05em;
text-transform: uppercase;
cursor: pointer;
transition: background .15s;
}
.btn-submit:hover { background: #33271E; border-color: #33271E; }
.msg-ok { padding: .6rem 1rem; border-left: 3px solid var(--pos); background: var(--pos-bg); color: var(--pos); font-size: 13px; margin-bottom: 1rem; }
.msg-err { padding: .6rem 1rem; border-left: 3px solid var(--neg); background: var(--neg-bg); color: var(--neg); font-size: 13px; margin-bottom: 1rem; }
/* ── Card shell (import page) ── */
.card { background: var(--surface); border: 1.5px solid var(--rule); }
/* ── Calendrier sélecteur ── */
.cal-trigger-wrap { position: relative; }
.cal-trigger {
display: flex;
align-items: baseline;
gap: .1em;
background: none;
border: none;
cursor: pointer;
font-family: var(--mono);
font-size: 1.75rem;
font-weight: 500;
letter-spacing: -.02em;
color: var(--text);
padding: 0;
line-height: 1;
}
.cal-trigger .wk { color: var(--muted); font-size: 1rem; font-weight: 300; }
.cal-trigger .slash { color: var(--rule); margin: 0 .2em; }
.cal-trigger .cal-caret {
font-size: .85rem;
color: var(--muted);
margin-left: .35em;
transition: transform .15s;
}
.cal-trigger[aria-expanded="true"] .cal-caret { transform: rotate(180deg); }
.cal-trigger:hover .cal-caret { color: var(--text); }
.cal-popup {
position: absolute;
top: calc(100% + .5rem);
left: 0;
z-index: 100;
background: var(--surface);
border: 1.5px solid var(--rule);
box-shadow: 0 4px 20px rgba(28,23,18,.12);
padding: .75rem;
min-width: 260px;
}
.cal-popup[hidden] { display: none; }
.cal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: .6rem;
}
.cal-month-label {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text);
}
.cal-nav {
background: none;
border: 1px solid var(--rule);
width: 24px; height: 24px;
cursor: pointer;
font-size: 12px;
color: var(--muted);
display: flex; align-items: center; justify-content: center;
transition: border-color .15s, color .15s;
}
.cal-nav:hover { border-color: var(--text); color: var(--text); }
.cal-grid { width: 100%; border-collapse: collapse; }
.cal-grid thead th {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
padding: .2rem .3rem;
text-align: center;
border-bottom: 1px solid var(--rule);
}
.cal-grid thead th.cal-wn {
color: var(--accent);
border-right: 1px solid var(--rule-l);
}
.cal-grid thead th.cal-we { color: var(--neg); }
.cal-grid .cal-row td {
font-family: var(--mono);
font-size: 12px;
text-align: center;
padding: .3rem .25rem;
color: var(--text);
transition: background .1s;
}
.cal-grid .cal-row:hover td { background: var(--ground); }
.cal-grid .cal-row:hover td.cal-wn { background: rgba(47,95,160,.1); }
.cal-grid .cal-row td.cal-wn {
color: var(--accent);
font-weight: 500;
font-size: 11px;
border-right: 1px solid var(--rule-l);
padding-right: .4rem;
}
.cal-grid .cal-row td.cal-out { color: var(--rule); }
.cal-grid .cal-row td.cal-we { color: var(--muted); }
.cal-grid .cal-current td {
background: var(--ground);
font-weight: 500;
}
.cal-grid .cal-current td.cal-wn {
background: var(--accent);
color: #fff;
}
.cal-grid .cal-current:hover td { background: rgba(47,95,160,.06); }
.cal-grid .cal-current:hover td.cal-wn { background: var(--accent); }
</style>
</head>
<body>
<nav>
<a href="/" class="nav-brand"><span class="nav-dot"></span>Décompte Horaire</a>
<span class="spacer"></span>
<a href="/" class="nav-link">Semaine courante</a>
<a href="/import" class="nav-link">Importer ODS</a>
</nav>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>

23
app/templates/import.html Normal file
View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<div class="card import-wrap">
<h2>Importer un fichier ODS</h2>
{% if msg %}
{% if 'Erreur' in msg %}
<div class="msg-err">{{ msg }}</div>
{% else %}
<div class="msg-ok">✓ {{ msg }}</div>
{% endif %}
{% endif %}
<p class="import-desc">
Sélectionne le fichier exporté par la badgeuse.<br>
Les données existantes sont mises à jour — les congés ne sont pas affectés.
</p>
<form method="post" enctype="multipart/form-data">
<input class="file-input" type="file" name="fichier" accept=".ods" required>
<button type="submit" class="btn-submit">Importer</button>
</form>
</div>
{% endblock %}

205
app/templates/semaine.html Normal file
View File

@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block content %}
<div class="week-header">
<div class="week-nav-arrows">
<a href="/semaine/{{ prev_year }}/{{ prev_week }}" class="arrow-btn">&#8592;</a>
<a href="/semaine/{{ next_year }}/{{ next_week }}" class="arrow-btn">&#8594;</a>
</div>
<!-- Sélecteur calendrier -->
<div class="cal-trigger-wrap">
<button class="cal-trigger" id="cal-trigger" aria-expanded="false"
onclick="toggleCal()">
<span class="wk">S</span>{{ week }}<span class="slash">/</span>{{ year }}
<span class="cal-caret"></span>
</button>
<div class="cal-popup" id="cal-popup" hidden>
<div class="cal-header">
<button class="cal-nav" onclick="calPrev()">&#8592;</button>
<span class="cal-month-label" id="cal-month-label"></span>
<button class="cal-nav" onclick="calNext()">&#8594;</button>
</div>
<table class="cal-grid">
<thead>
<tr>
<th class="cal-wn">S</th>
<th>L</th><th>M</th><th>M</th><th>J</th><th>V</th>
<th class="cal-we">S</th><th class="cal-we">D</th>
</tr>
</thead>
<tbody id="cal-body"></tbody>
</table>
</div>
</div>
</div>
<!-- Soldes -->
{% include "_soldes.html" %}
<!-- Tableau -->
<div class="table-section">
<table>
<thead>
<tr class="th-group">
<th class="g-jour" rowspan="2">Jour</th>
<th class="g-matin">Matin</th>
<th class="g-aprem">Après-midi</th>
<th class="g-calc" rowspan="2">Travaillé</th>
<th class="g-calc" rowspan="2">Delta</th>
<th class="g-save" rowspan="2"></th>
<th class="g-conge" rowspan="2">Congé</th>
</tr>
<tr class="th-sub">
<th class="s-matin">entrée &rarr; sortie</th>
<th class="s-aprem">entrée &rarr; sortie</th>
</tr>
</thead>
<tbody>
{% for j, nom in result.jours | zip(jours_fr) %}
{% include "_ligne.html" %}
{% endfor %}
</tbody>
</table>
</div>
<!-- Historique groupé par année -->
{% if weeks_by_year %}
<div class="history">
<span class="hist-label">Historique</span>
<div class="hist-body">
{% for yr, wks in weeks_by_year.items() %}
<div class="hist-year-group">
<span class="hist-year">{{ yr }}</span>
<div class="hist-chips">
{% for yw in wks %}
<a href="/semaine/{{ yw[0] }}/{{ yw[1] }}"
class="chip{% if yw[0] == year and yw[1] == week %} here{% endif %}">
S{{ '%02d' % yw[1] }}
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<script>
(function () {
const CURRENT_YEAR = {{ year }};
const CURRENT_WEEK = {{ week }};
// ISO week number from a Date
function isoWeek(d) {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
// Thursday of this week determines the ISO year
date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return {
week: Math.ceil((((date - yearStart) / 86400000) + 1) / 7),
year: date.getUTCFullYear()
};
}
// Monday of an ISO week
function mondayOfISOWeek(year, week) {
const simple = new Date(Date.UTC(year, 0, 1 + (week - 1) * 7));
const dow = simple.getUTCDay() || 7; // Mon=1..Sun=7
if (dow <= 4) simple.setUTCDate(simple.getUTCDate() - dow + 1);
else simple.setUTCDate(simple.getUTCDate() + 8 - dow);
return simple;
}
const MONTHS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin',
'Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
// Start calendar on the month containing the current week's Monday
const initMonday = mondayOfISOWeek(CURRENT_YEAR, CURRENT_WEEK);
let calYear = initMonday.getUTCFullYear();
let calMonth = initMonday.getUTCMonth(); // 0-based
function renderCal() {
document.getElementById('cal-month-label').textContent =
MONTHS_FR[calMonth] + ' ' + calYear;
const body = document.getElementById('cal-body');
body.innerHTML = '';
// First day of month (Mon-based grid)
const first = new Date(Date.UTC(calYear, calMonth, 1));
const startDow = (first.getUTCDay() || 7) - 1; // 0=Mon
const startDate = new Date(first);
startDate.setUTCDate(1 - startDow);
// 6 rows × 7 days
for (let row = 0; row < 6; row++) {
const monday = new Date(startDate);
monday.setUTCDate(startDate.getUTCDate() + row * 7);
// Skip row if entirely outside ±1 month
const firstOfRow = monday;
const lastOfRow = new Date(monday); lastOfRow.setUTCDate(monday.getUTCDate() + 6);
if (firstOfRow.getUTCMonth() > calMonth + 1 ||
lastOfRow.getUTCMonth() < calMonth - 1) continue;
const { week: wn, year: wy } = isoWeek(monday);
const isCurrent = (wy === CURRENT_YEAR && wn === CURRENT_WEEK);
const tr = document.createElement('tr');
tr.className = 'cal-row' + (isCurrent ? ' cal-current' : '');
tr.style.cursor = 'pointer';
tr.title = `Semaine ${wn} / ${wy}`;
tr.onclick = () => { window.location = `/semaine/${wy}/${wn}`; };
// Week number cell
const wnTd = document.createElement('td');
wnTd.className = 'cal-wn';
wnTd.textContent = wn;
tr.appendChild(wnTd);
for (let d = 0; d < 7; d++) {
const day = new Date(monday); day.setUTCDate(monday.getUTCDate() + d);
const td = document.createElement('td');
td.textContent = day.getUTCDate();
const inMonth = day.getUTCMonth() === calMonth;
if (!inMonth) td.className = 'cal-out';
if (d >= 5) td.className = (td.className ? td.className + ' ' : '') + 'cal-we';
tr.appendChild(td);
}
body.appendChild(tr);
}
}
window.calPrev = () => {
calMonth--;
if (calMonth < 0) { calMonth = 11; calYear--; }
renderCal();
};
window.calNext = () => {
calMonth++;
if (calMonth > 11) { calMonth = 0; calYear++; }
renderCal();
};
window.toggleCal = () => {
const popup = document.getElementById('cal-popup');
const btn = document.getElementById('cal-trigger');
const open = popup.hidden;
popup.hidden = !open;
btn.setAttribute('aria-expanded', String(open));
if (open) renderCal();
};
// Close on outside click
document.addEventListener('click', e => {
const wrap = document.querySelector('.cal-trigger-wrap');
if (wrap && !wrap.contains(e.target)) {
document.getElementById('cal-popup').hidden = true;
document.getElementById('cal-trigger').setAttribute('aria-expanded', 'false');
}
});
})();
</script>
{% endblock %}