feat(planif): fenêtre usuelle APPRISE + persistée par tech (préréglage sur jours vides)

Un jour VIDE (ou une semaine future non planifiée) ne voyait aucun quart → le préréglage retombait
sur 8–16 générique. Désormais on APPREND la fenêtre la plus fréquente de chaque tech depuis ses vrais
quarts à chaque loadWeek et on la PERSISTE (localStorage roster-usual-win-v1). Sur un jour sans quart,
le préréglage propose donc le « quart normal le plus probable » du tech (ex. Houssam → 8–18), même
sur une semaine future. Priorité : perso enregistré > usuel appris > patron hebdo > 8–16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-23 20:48:03 -04:00
parent 6f4a2f0cb3
commit 53cf52bb13

View File

@ -2175,27 +2175,44 @@ const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).lengt
// personnalisés depuis le menu de cellule (ex. Philip 7:3017:30) réutilisés à chaque clic « Jour »/« Soir ».
const LS_SHIFT_PRESETS = 'roster-shift-presets-v1'
const techShiftPresets = ref({}) // { [techId]: { day:{start,end}, eve:{start,end} } }
// Préréglage Jour = perso enregistré, SINON la fenêtre USUELLE du tech (« gabarit le plus probable »), SINON 816.
// Fenêtre USUELLE APPRISE + PERSISTÉE par tech : { techId: {start,end} }. Alimentée depuis les VRAIS quarts chaque fois
// qu'une semaine est chargée (refreshUsualCache) survit aux semaines VIDES/futures (là où le scan direct ne verrait rien)
// ET aux rechargements. C'est le « quart normal le plus probable » demandé (ex. Houssam 818 même sur un jour sans quart).
const LS_USUAL = 'roster-usual-win-v1'
const usualCache = ref({})
// Préréglage Jour = perso enregistré, SINON la fenêtre USUELLE du tech, SINON 816.
function presetDayFor (techId) { const p = techId && techShiftPresets.value[techId]; return (p && p.day) || usualWindowFor(techId) || { start: 8, end: 16 } }
function presetEveFor (techId) { const p = techId && techShiftPresets.value[techId]; return (p && p.eve) || { start: 16, end: 20 } }
// Fenêtre USUELLE d'un tech : la plus fréquente de son patron hebdo (weekly_schedule), sinon de ses quarts réguliers
// déjà assignés (hors garde). Pré-remplit le préréglage Jour (ex. Houssam 818) l'utilisateur n'a plus à ressaisir.
// Recalcule la fenêtre la plus fréquente de CHAQUE tech depuis les quarts réguliers chargés met à jour le cache persistant
// (les techs sans quart chargé gardent leur valeur connue). Appelé après chaque loadWeek.
function refreshUsualCache () {
const byTech = {}
for (const a of (assignments.value || [])) {
const tp = tplByName.value[a.shift]; if (!tp || tp.on_call) continue
const k = (tp.start_time || '').slice(0, 5) + '-' + (tp.end_time || '').slice(0, 5); if (k === '-') continue
;(byTech[a.tech] || (byTech[a.tech] = {}))[k] = (byTech[a.tech][k] || 0) + 1
}
let changed = false; const next = { ...usualCache.value }
for (const tid of Object.keys(byTech)) {
const best = Object.keys(byTech[tid]).sort((x, y) => byTech[tid][y] - byTech[tid][x])[0]
const [s, e] = best.split('-'); const sd = hToNum(s); const ed = hToNum(e)
if (sd != null && ed != null && ed > sd && (!next[tid] || next[tid].start !== sd || next[tid].end !== ed)) { next[tid] = { start: sd, end: ed }; changed = true }
}
if (changed) { usualCache.value = next; try { localStorage.setItem(LS_USUAL, JSON.stringify(next)) } catch (e) {} }
}
// Fenêtre USUELLE d'un tech : d'abord la fenêtre APPRISE (cache, = ses vrais quarts, persistée), sinon son patron hebdo
// (weekly_schedule). null si vraiment rien de connu l'appelant retombe sur 816.
function usualWindowFor (techId) {
if (!techId) return null
const counts = {}
const add = (s, e) => { if (s && e) { const k = String(s).slice(0, 5) + '-' + String(e).slice(0, 5); counts[k] = (counts[k] || 0) + 1 } }
const t = (techs.value || []).find(x => x.id === techId)
const ws = t && t.weekly_schedule
const c = usualCache.value[techId]; if (c && c.start != null && c.end != null) return c
const t = (techs.value || []).find(x => x.id === techId); const ws = t && t.weekly_schedule
if (ws && typeof ws === 'object') {
const counts = {}; const add = (s, e) => { if (s && e) { const k = String(s).slice(0, 5) + '-' + String(e).slice(0, 5); counts[k] = (counts[k] || 0) + 1 } }
for (const k of Object.keys(ws)) { const day = ws[k]; if (Array.isArray(day)) day.forEach(w => w && add(w.start, w.end)); else if (day && day.start && day.end) add(day.start, day.end) }
const best = Object.keys(counts).sort((x, y) => counts[y] - counts[x])[0]
if (best) { const [s, e] = best.split('-'); const sd = hToNum(s); const ed = hToNum(e); if (sd != null && ed != null && ed > sd) return { start: sd, end: ed } }
}
if (!Object.keys(counts).length) { // repli : quarts réguliers (hors garde) déjà assignés au tech
for (const a of (assignments.value || [])) { if (a.tech !== techId) continue; const tp = tplByName.value[a.shift]; if (!tp || tp.on_call) continue; add(tp.start_time, tp.end_time) }
}
const best = Object.keys(counts).sort((x, y) => counts[y] - counts[x])[0]
if (!best) return null
const [s, e] = best.split('-'); const sd = hToNum(s); const ed = hToNum(e)
return (sd != null && ed != null && ed > sd) ? { start: sd, end: ed } : null
return null
}
function onSetPreset (techId, { kind, start, end } = {}) {
if (!techId || start == null || end == null) return
@ -6149,6 +6166,7 @@ async function loadWeek () {
const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || []
snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null
lastWeek.start = start.value; lastWeek.days = days.value
refreshUsualCache() // apprend/persiste la fenêtre usuelle par tech depuis les quarts chargés (sert les semaines vides)
await loadStats()
} catch (e) { err(e) } finally { loading.value = false }
}
@ -6256,7 +6274,7 @@ async function doWeekStatus (mode) {
}
// demande
function loadLS () { try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } try { pendingAbs.value = JSON.parse(localStorage.getItem(LS_PENDING_ABS) || '{}') } catch { pendingAbs.value = {} } try { pendingPause.value = JSON.parse(localStorage.getItem(LS_PENDING_PAUSE) || '{}') } catch { pendingPause.value = {} } try { techShiftPresets.value = JSON.parse(localStorage.getItem(LS_SHIFT_PRESETS) || '{}') || {} } catch { techShiftPresets.value = {} } }
function loadLS () { try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } try { pendingAbs.value = JSON.parse(localStorage.getItem(LS_PENDING_ABS) || '{}') } catch { pendingAbs.value = {} } try { pendingPause.value = JSON.parse(localStorage.getItem(LS_PENDING_PAUSE) || '{}') } catch { pendingPause.value = {} } try { techShiftPresets.value = JSON.parse(localStorage.getItem(LS_SHIFT_PRESETS) || '{}') || {} } catch { techShiftPresets.value = {} } try { usualCache.value = JSON.parse(localStorage.getItem(LS_USUAL) || '{}') || {} } catch { usualCache.value = {} } }
// Rotation de garde par département (récurrence + rotation)
const GARDE_EPOCH = '2026-01-05' // lundi de référence pour l'index de semaine