Files
pointeuse-optimisator/app/templates/settings.html
Jacquin Antoine dc80e81db2 Ajoute les jours fériés, l'astreinte week-end 0h-0h et les ratios éditables
- Bouton F : marque un jour férié (dû 0, sans consommer de congé) ; il
  qualifie les règles « ferie » et étend la fenêtre d'astreinte du jour.
- Samedi, dimanche et fériés : l'astreinte compte de 0h00 à 0h00 ; un jour
  AS déclaré garde sa borne du lendemain 18h pour ne pas tronquer les
  interventions nocturnes.
- Ratios d'astreinte éditables à la main dans Réglages (nom, jours, fenêtre,
  ratio), avec ajout/suppression de règles et validation.
- Un quart d'heure entamé par deux interventions qui se suivent n'est compté
  qu'une fois : les lignes d'événements somment au total du jour.
- Décompte mieux expliqué dans l'interface : quarts d'horloge (:00/:15/:30/:45),
  fenêtre couverte par événement, exemple chiffré.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-09-08 23:30:15 +02:00

479 lines
19 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="stats-head">
<div>
<span class="stats-title">Réglages</span>
<div class="stats-meta">Rappels de pointage sur ton téléphone</div>
</div>
</div>
<div class="settings-card">
<span class="hist-label">Plages horaires</span>
<p class="settings-p">
Bornes de présence obligatoire matin/après-midi et heure de reprise par défaut de
l'après-midi, utilisées pour les avertissements de saisie et les préconisations de
fin de demi-journée. Les heures visées (arrivée le matin, départ le soir) guident
les préconisations : le matin est planifié depuis ton arrivée visée, et le rattrapage
est casé avant ton départ visé tant que c'est possible. Propres à ton compte —
ne change rien pour les autres.
</p>
{% if plages_saved %}
<div class="settings-saved">Enregistré.</div>
{% endif %}
{% if plages_error %}
<div class="settings-error">Horaires invalides (format HH:MM, matin_debut &lt; matin_fin &lt; aprem_debut &lt; aprem_fin).</div>
{% endif %}
<form method="post" action="/settings/plages" class="settings-form">
<label class="login-label" for="matin_debut">Présence matin</label>
<div class="settings-range">
<input id="matin_debut" class="login-input time-range-input" type="time" name="matin_debut" value="{{ plages.matin_debut }}">
<span class="settings-range-sep">→</span>
<input id="matin_fin" class="login-input time-range-input" type="time" name="matin_fin" value="{{ plages.matin_fin }}">
</div>
<label class="login-label" for="aprem_debut">Présence après-midi</label>
<div class="settings-range">
<input id="aprem_debut" class="login-input time-range-input" type="time" name="aprem_debut" value="{{ plages.aprem_debut }}">
<span class="settings-range-sep">→</span>
<input id="aprem_fin" class="login-input time-range-input" type="time" name="aprem_fin" value="{{ plages.aprem_fin }}">
</div>
<label class="login-label" for="pause_dejeuner_fin">Reprise par défaut de l'après-midi</label>
<input id="pause_dejeuner_fin" class="login-input time-range-input" type="time" name="pause_dejeuner_fin" value="{{ plages.pause_dejeuner_fin }}">
<label class="login-label" for="arrivee_visee">Heures visées arrivée / départ (optionnel)</label>
<div class="settings-range">
<input id="arrivee_visee" class="login-input time-range-input" type="time" name="arrivee_visee" value="{{ plages.arrivee_visee }}">
<span class="settings-range-sep">→</span>
<input id="depart_vise" class="login-input time-range-input" type="time" name="depart_vise" value="{{ plages.depart_vise }}" aria-label="Départ visé le soir">
</div>
<button class="login-btn" type="submit">Enregistrer</button>
</form>
</div>
<div class="settings-card">
<span class="hist-label">Ratios des heures d'astreinte</span>
<p class="settings-p">
Pondération des heures d'astreinte selon le type de jour et la fenêtre horaire :
le décompte se fait par quarts d'heure de l'horloge (:00, :15, :30, :45) — tout
quart entamé compte 15 min entières (ex. 22:39 → 23:49 touche les quarts 22:30 à
23:45, soit 6 quarts = 1h30) — puis chaque quart est multiplié par le ratio de la
première règle qui le couvre. Un quart hors règle compte ×1 (« Autre ») ; la
première règle qui couvre un quart gagne, place les fenêtres spécifiques avant
les générales. Samedi, dimanche et jours fériés comptent de 0h00 à 0h00. Règles
globales à tous les comptes.
</p>
{% if facteurs_saved %}
<div class="settings-saved">Enregistré.</div>
{% endif %}
{% if facteurs_error %}
<div class="settings-error">Règles invalides : chaque règle doit avoir un nom, au moins un type de jour, une fenêtre HH:MM et un ratio &gt; 0 ; au moins une règle est requise (efface le nom pour supprimer une règle).</div>
{% endif %}
<form method="post" action="/settings/astreinte-facteurs" class="as-facteurs-form">
<div id="as-ratio-rows">
{% for f in facteurs %}
{% set i = loop.index0 %}
<div class="as-ratio-edit" data-idx="{{ i }}">
<input type="text" class="login-input as-f-nom" name="f_nom_{{ i }}" value="{{ f.nom }}" maxlength="40" placeholder="Nom de la règle">
<div class="as-f-jours">
{% for j, lbl in [('semaine', 'Lun–Ven'), ('sam', 'Sam'), ('dim', 'Dim'), ('ferie', 'Férié')] %}
<label class="as-f-jour" title="Types de jours couverts par la règle"><input type="checkbox" name="f_jours_{{ i }}" value="{{ j }}" {% if j in f.jours %}checked{% endif %}> {{ lbl }}</label>
{% endfor %}
</div>
<input class="login-input time-range-input as-f-h" type="time" name="f_debut_{{ i }}" value="{{ f.debut }}" aria-label="Début de fenêtre">
<span class="settings-range-sep">→</span>
<input class="login-input time-range-input as-f-h" type="time" name="f_fin_{{ i }}" value="{{ f.fin }}" aria-label="Fin de fenêtre (avant début = à cheval sur minuit)">
<label class="as-f-x">×<input class="login-input as-f-facteur" type="number" name="f_facteur_{{ i }}" value="{{ '%g' % f.facteur }}" step="0.05" min="0.05" aria-label="Ratio"></label>
<button type="button" class="as-f-del" title="Supprimer la règle" onclick="asDelRatio(this)">×</button>
</div>
{% endfor %}
</div>
<div class="as-ratio-actions">
<button type="button" class="as-f-add" onclick="asAddRatio()">+ règle</button>
<button class="login-btn" type="submit">Enregistrer les ratios</button>
</div>
</form>
</div>
<script>
function asAddRatio() {
const rows = document.getElementById('as-ratio-rows');
const idx = Math.max(-1, ...[...rows.querySelectorAll('.as-ratio-edit')].map(r => +r.dataset.idx)) + 1;
const div = document.createElement('div');
div.className = 'as-ratio-edit';
div.dataset.idx = idx;
div.innerHTML =
'<input type="text" class="login-input as-f-nom" name="f_nom_' + idx + '" maxlength="40" placeholder="Nom de la règle">' +
'<div class="as-f-jours">' +
'<label class="as-f-jour"><input type="checkbox" name="f_jours_' + idx + '" value="semaine" checked> Lun–Ven</label>' +
'<label class="as-f-jour"><input type="checkbox" name="f_jours_' + idx + '" value="sam"> Sam</label>' +
'<label class="as-f-jour"><input type="checkbox" name="f_jours_' + idx + '" value="dim"> Dim</label>' +
'<label class="as-f-jour"><input type="checkbox" name="f_jours_' + idx + '" value="ferie"> Férié</label>' +
'</div>' +
'<input class="login-input time-range-input as-f-h" type="time" name="f_debut_' + idx + '" value="20:00">' +
'<span class="settings-range-sep">→</span>' +
'<input class="login-input time-range-input as-f-h" type="time" name="f_fin_' + idx + '" value="08:00">' +
'<label class="as-f-x">×<input class="login-input as-f-facteur" type="number" name="f_facteur_' + idx + '" value="1.5" step="0.05" min="0.05"></label>' +
'<button type="button" class="as-f-del" title="Supprimer la règle" onclick="asDelRatio(this)">×</button>';
rows.appendChild(div);
div.querySelector('.as-f-nom').focus();
}
function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); }
</script>
<div class="settings-card">
<span class="hist-label">Forfait par jour d'astreinte</span>
<p class="settings-p">
Durée ajoutée automatiquement au temps travaillé de chaque jour d'astreinte
déclaré (bouton <b>AS</b>), en plus des interventions saisies. Elle compte dans
le bilan de la semaine ; le lendemain de l'astreinte n'est pas concerné.
Vide = pas de forfait.
</p>
{% if forfait_saved %}
<div class="settings-saved">Enregistré.</div>
{% endif %}
{% if forfait_error %}
<div class="settings-error">Durée invalide (format HH:MM, 12h00 maximum).</div>
{% endif %}
<form method="post" action="/settings/astreinte-forfait" class="settings-form">
<label class="login-label" for="astreinte_forfait">Durée ajoutée par jour d'astreinte</label>
<input id="astreinte_forfait" class="login-input time-range-input" type="time" step="900"
name="astreinte_forfait" value="{{ plages.astreinte_forfait }}">
<button class="login-btn" type="submit">Enregistrer</button>
</form>
</div>
<div class="settings-card">
<span class="hist-label">Notifications (ntfy) <a href="/aide" class="settings-help-link">Aide</a></span>
<p class="settings-p">
Crée un topic sur ton serveur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy</a>
(public ou auto-hébergé), un identifiant secret que toi seul connais, abonne-toi dessus avec
l'app ntfy sur ton téléphone, puis renseigne les deux champs ci-dessous. Guide complet sur
la page <a href="/aide">Aide</a>.
</p>
{% if saved %}
<div class="settings-saved">Enregistré.</div>
{% endif %}
<form method="post" action="/settings" class="settings-form">
<label class="login-label" for="ntfy_server">Serveur ntfy</label>
<input
id="ntfy_server"
class="login-input"
type="text"
name="ntfy_server"
value="{{ ntfy_server }}"
placeholder="https://ntfy.sh"
autocomplete="off"
>
<label class="login-label" for="ntfy_topic">Topic ntfy</label>
<input
id="ntfy_topic"
class="login-input"
type="text"
name="ntfy_topic"
value="{{ ntfy_topic }}"
placeholder="ex: antoine-pointeuse-x7f2"
autocomplete="off"
>
<label class="login-label" for="ntfy_token">Jeton d'accès (optionnel, serveur protégé)</label>
<input
id="ntfy_token"
class="login-input"
type="password"
name="ntfy_token"
value="{{ ntfy_token }}"
placeholder="ex: tk_xxxxxxxxxxxxxxxx"
autocomplete="off"
>
<label class="login-label" for="rappel_debut_h">Plage horaire des rappels</label>
<div class="settings-range">
<input
id="rappel_debut_h"
class="login-input settings-range-input"
type="number" min="0" max="23" name="rappel_debut_h"
value="{{ rappel_debut_h }}"
>
<span class="settings-range-sep">h → </span>
<input
id="rappel_fin_h"
class="login-input settings-range-input"
type="number" min="1" max="24" name="rappel_fin_h"
value="{{ rappel_fin_h }}"
>
<span class="settings-range-sep">h, jours ouvrés</span>
</div>
<button class="login-btn" type="submit">Enregistrer</button>
</form>
<div class="settings-test-row">
<button
class="login-btn settings-test-btn"
hx-post="/settings/test-ntfy"
hx-target="#ntfy-test-result"
hx-swap="innerHTML"
hx-disabled-elt="this"
>Tester la notification</button>
<span id="ntfy-test-result"></span>
</div>
</div>
<div class="settings-card">
<span class="hist-label">Détection de présence (Automation) <a href="/aide" class="settings-help-link">Aide</a></span>
<p class="settings-p">
Crée une automatisation <a href="https://f-droid.org/en/packages/com.jens.automation2/" target="_blank" rel="noopener">Automation</a>
déclenchée par le Wi-Fi du bureau (ou une géofence) : à la <b>connexion</b>/entrée, appelle l'URL « arrivée » ;
à la <b>déconnexion</b>/sortie, l'URL « départ ». Tant que tu es détecté présent sans
avoir pointé l'entrée du matin, ou parti sans avoir pointé la sortie, tu reçois un rappel
selon l'intervalle réglé ci-dessus, dans la plage horaire configurée (jours ouvrés).
Étapes détaillées sur la page <a href="/aide">Aide</a>.
</p>
<div id="presence-urls">
<div class="settings-urlrow">
<span class="settings-urllabel">Arrivée</span>
<code class="settings-url" id="url-arrivee">{{ arrivee_url }}</code>
<button type="button" class="settings-copy-btn" data-copy-target="url-arrivee">Copier</button>
</div>
<div class="settings-urlrow">
<span class="settings-urllabel">Départ</span>
<code class="settings-url" id="url-depart">{{ depart_url }}</code>
<button type="button" class="settings-copy-btn" data-copy-target="url-depart">Copier</button>
</div>
</div>
<p class="settings-hint">
Ces URLs sont secrètes (elles contiennent ton token) — ne les partage pas.
En cas de compromission, régénère le token : les anciennes URLs cesseront de fonctionner
et tu devras mettre à jour Tasker avec les nouvelles.
</p>
<button
class="login-btn settings-regen-btn"
hx-post="/settings/regenerate-presence-token"
hx-target="#presence-urls"
hx-swap="outerHTML"
hx-confirm="Régénérer le token ? Les URLs actuelles cesseront immédiatement de fonctionner, tu devras mettre à jour Tasker."
hx-disabled-elt="this"
>Régénérer le token</button>
</div>
<style>
.settings-card {
background: var(--surface);
border: 1.5px solid var(--rule);
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
.settings-p { font-size: 13px; color: var(--text); line-height: 1.6; }
.settings-p a { color: var(--accent); }
.settings-p code {
font-family: var(--mono);
font-size: 12px;
background: var(--ground);
border: 1px solid var(--rule-l);
padding: 0 .25rem;
}
.as-facteurs-form { display: flex; flex-direction: column; gap: .5rem; }
.as-ratio-edit {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem .75rem;
padding: .5rem .8rem;
border: 1px solid var(--rule-l);
}
.as-ratio-edit + .as-ratio-edit { margin-top: .4rem; }
.as-ratio-edit .as-f-nom { width: 140px; flex-shrink: 0; }
.as-f-jours { display: flex; gap: .75rem; flex-wrap: wrap; flex: 1; }
.as-f-jour { font-size: 12px; color: var(--text); display: inline-flex; align-items: center; gap: .3rem; white-space: nowrap; }
.as-ratio-edit .as-f-h { width: 90px; flex-shrink: 0; }
.as-f-x { font-family: var(--mono); color: var(--muted); display: inline-flex; align-items: center; gap: .3rem; }
.as-ratio-edit .as-f-facteur { width: 70px; }
.as-f-del {
border: 1px solid var(--rule);
background: var(--surface);
color: var(--muted);
border-radius: 4px;
width: 22px;
height: 22px;
cursor: pointer;
line-height: 1;
flex-shrink: 0;
}
.as-f-del:hover { color: var(--neg); border-color: var(--neg); }
.as-ratio-actions { display: flex; gap: .75rem; align-items: center; margin-top: .3rem; }
.as-f-add {
border: 1px dashed var(--rule);
background: none;
color: var(--muted);
border-radius: 4px;
padding: .35rem .7rem;
cursor: pointer;
font-size: 12px;
}
.as-f-add:hover { color: var(--accent); border-color: var(--accent); }
@media (max-width: 600px) {
.as-f-jours { order: 3; width: 100%; }
}
.settings-form { display: flex; flex-direction: column; gap: .5rem; max-width: 420px; }
.settings-saved {
background: var(--pos-bg);
border-left: 3px solid var(--pos);
color: var(--pos);
font-size: 12px;
padding: .5rem .8rem;
font-family: var(--mono);
}
.settings-error {
background: var(--neg-bg);
border-left: 3px solid var(--neg);
color: var(--neg);
font-size: 12px;
padding: .5rem .8rem;
font-family: var(--mono);
}
.settings-urlrow { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.settings-urllabel {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
width: 70px;
flex-shrink: 0;
}
.settings-url {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
background: var(--ground);
border: 1px solid var(--rule);
padding: .4rem .6rem;
word-break: break-all;
}
.settings-hint { font-size: 11px; color: var(--muted); }
.settings-range { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
.settings-range-input { width: 70px; text-align: center; }
.settings-range-sep { font-size: 12px; color: var(--muted); font-family: var(--mono); }
.time-range-input {
width: 128px;
text-align: left;
padding-right: .4rem;
}
.time-range-input::-webkit-calendar-picker-indicator {
margin-left: .5rem;
}
.settings-help-link {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .04em;
color: var(--accent);
text-decoration: none;
border: 1px solid var(--rule);
padding: .1rem .4rem;
margin-left: .5rem;
vertical-align: middle;
transition: border-color .15s, background .15s;
}
.settings-help-link:hover {
border-color: var(--accent);
background: rgba(47,95,160,.06);
}
.settings-test-row {
display: flex;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
}
.settings-test-btn {
flex-shrink: 0;
}
.settings-regen-btn {
align-self: flex-start;
border-color: var(--neg);
color: var(--neg);
}
.settings-regen-btn:hover:not([disabled]) {
background: var(--neg-bg);
}
.settings-copy-btn {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .04em;
color: var(--accent);
background: none;
border: 1px solid var(--rule);
padding: .35rem .6rem;
cursor: pointer;
flex-shrink: 0;
transition: border-color .15s, background .15s;
}
.settings-copy-btn:hover {
border-color: var(--accent);
background: rgba(47,95,160,.06);
}
.settings-copy-btn.settings-copy-btn--done {
color: var(--pos);
border-color: var(--pos);
}
#ntfy-test-result {
flex: 1;
min-width: 0;
}
#ntfy-test-result:empty { display: none; }
#ntfy-test-result:not(:empty) {
font-family: var(--mono);
font-size: 12px;
padding: .5rem .8rem;
border-left: 3px solid var(--muted);
background: var(--ground);
line-height: 1.45;
}
</style>
<script>
(function () {
// Délégation sur document : #presence-urls est remplacé par un swap OOB
// htmx quand le token est régénéré, les boutons "Copier" doivent continuer
// à fonctionner après ce remplacement.
document.addEventListener("click", function (e) {
var btn = e.target.closest(".settings-copy-btn");
if (!btn) return;
var target = document.getElementById(btn.dataset.copyTarget);
if (!target) return;
var text = target.textContent;
var onDone = function (ok) {
var previous = btn.textContent;
btn.textContent = ok ? "Copié !" : "Échec";
btn.classList.toggle("settings-copy-btn--done", ok);
setTimeout(function () {
btn.textContent = previous;
btn.classList.remove("settings-copy-btn--done");
}, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function () { onDone(true); },
function () { onDone(false); }
);
} else {
var textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
var ok = false;
try { ok = document.execCommand("copy"); } catch (err) { ok = false; }
document.body.removeChild(textarea);
onDone(ok);
}
});
})();
</script>
{% endblock %}