Files
pointeuse-optimisator/app/templates/semaine.html
Antoine c2d7d0e3ac Rend les plages horaires et les rappels ntfy configurables, supprime le
classement, ajoute une suite de tests pytest

- calcul.py: matin_debut/fin, aprem_debut/fin et pause_dejeuner_fin sont
  paramétrables via config.json > plages (moteur de calcul de l'heure de
  sortie optimale inclus, pas seulement l'affichage). Défauts identiques
  à l'ancien comportement, validé par la suite de tests.
- stats.py: les seuils de conformité utilisent la même config.
- main.py/templates: warn de saisie et suffixes de cible dynamiques.
- Rappels ntfy: serveur, topic et plage horaire (rappel_debut_h/fin_h)
  configurables par utilisateur depuis /settings, plus en dur dans le code.
- Page classement supprimée (pas de compétition entre utilisateurs).
- Nouvelle suite pytest (app/tests/), à lancer via
  `docker compose run --rm pointeuse pytest -v`.
2026-07-17 22:40:55 +02:00

340 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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">Δ jour</th>
<th class="g-cumul" rowspan="2">Δ sem.</th>
<th class="g-cible" rowspan="2">Fin cible</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>
<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) {
if (!e.target.matches('.time-input')) return;
var date = e.target.dataset.date;
var me = document.querySelector('[name="matin_entree"][data-date="' + date + '"]');
var ms = document.querySelector('[name="matin_sortie"][data-date="' + date + '"]');
var ae = document.querySelector('[name="aprem_entree"][data-date="' + date + '"]');
var as = document.querySelector('[name="aprem_sortie"][data-date="' + date + '"]');
var maDiv = document.getElementById('cg-matin-' + date);
var apDiv = document.getElementById('cg-aprem-' + date);
if (maDiv && !maDiv.classList.contains('cg')) {
var warn = (me && me.value && me.value > '{{ plages.matin_debut }}') || (ms && ms.value && ms.value < '{{ plages.matin_fin }}');
maDiv.classList.toggle('warn', !!warn);
}
if (apDiv && !apDiv.classList.contains('cg')) {
var warnA = (ae && ae.value && ae.value > '{{ plages.aprem_debut }}') || (as && as.value && as.value < '{{ plages.aprem_fin }}');
apDiv.classList.toggle('warn', !!warnA);
}
});
</script>
{% endblock %}