diff options
Diffstat (limited to 'src/index.html')
| -rw-r--r-- | src/index.html | 430 |
1 files changed, 383 insertions, 47 deletions
diff --git a/src/index.html b/src/index.html index 86ff355..6a6d871 100644 --- a/src/index.html +++ b/src/index.html @@ -461,11 +461,22 @@ .toggle-sidebar { display: block !important; } .header-right .btn .btn-label { display: none; } .ai-panel { width: min(var(--ai-panel-w), 100vw); } + /* Header must wrap instead of pushing the document wider than the phone. */ + .header { flex-wrap: wrap; row-gap: 6px; } + .header-left, .nav-center, .header-right { flex-wrap: wrap; } + .header-right { justify-content: flex-end; } } @media (max-width: 600px) { .header { padding: 8px 12px; gap: 6px; } + .header-left, .nav-center, .header-right { gap: 5px; } + .logo { font-size: 1.05rem; } + .logo-ai { padding: 1px 5px; font-size: 0.65rem; margin-left: 4px; } + .stat-chip { font-size: 0.6rem; padding: 2px 5px; } + .header-divider { display: none; } + .btn { padding: 5px 9px; } + .ai-toggle-btn { padding: 5px 9px; font-size: 0.72rem; } .nav-btn { padding: 5px 10px; font-size: 0.8rem; } - .month-display { min-width: 140px; font-size: 1rem; } + .month-display { min-width: 120px; font-size: 0.9rem; } .calendar-grid { grid-template-columns: repeat(7, minmax(120px, 1fr)); } .day-cell { min-height: 130px; } } @@ -790,14 +801,14 @@ <span class="form-label" style="margin:0;">Max Consecutive Days</span> <span class="slider-value" id="maxConsecVal">5</span> </div> - <input type="range" class="slider" id="maxConsecutive" min="3" max="7" value="5" oninput="document.getElementById('maxConsecVal').textContent=this.value"> + <input type="range" class="slider" id="maxConsecutive" min="3" max="7" value="5" oninput="document.getElementById('maxConsecVal').textContent=this.value" onchange="persistGenSetting('maxConsecutive', this.value)"> </div> <div class="slider-group"> <div class="slider-header"> <span class="form-label" style="margin:0;">Min Rest Hours</span> <span class="slider-value" id="minRestVal">11</span> </div> - <input type="range" class="slider" id="minRestHours" min="8" max="14" value="11" oninput="document.getElementById('minRestVal').textContent=this.value"> + <input type="range" class="slider" id="minRestHours" min="8" max="14" value="11" oninput="document.getElementById('minRestVal').textContent=this.value" onchange="persistGenSetting('minRestHours', this.value)"> </div> <div class="form-group"> <label class="form-checkbox"> @@ -916,6 +927,7 @@ let currentMonth, currentYear; let genState = { step: 1, selectedWorkers: new Set(), generatedPlan: null }; let tempDayOffs = [], tempSickDays = [], tempVacations = []; + let pendingWorkerId = null; // AI state let aiEndpoint = 'http://localhost:11434'; @@ -932,38 +944,268 @@ const now = new Date(); currentMonth = now.getMonth(); currentYear = now.getFullYear(); - renderAll(); + // A render must not be able to leave the page half built: if one + // section throws, the rest still renders and the user is told. + try { + renderAll(); + } catch (e) { + console.error('Initial render failed:', e); + showToast('Part of the schedule could not be displayed. Export JSON to back up your data.', 'error'); + } applyTheme(); initAI(); + setupStorageSync(); + if (window.innerWidth <= 900) document.getElementById('sidebar').classList.add('collapsed'); } - function loadFromStorage() { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - try { - const parsed = JSON.parse(saved); - state = { - version: '2.1', - settings: { - darkMode: false, enforceRest: true, enforce5Day: true, - minRestHours: 11, maxConsecutive: 5, maxWorkers: DEFAULT_MAX_WORKERS, - ...(parsed.settings || {}) - }, - workers: parsed.workers || [], - shifts: parsed.shifts || [], - assignments: parsed.assignments || [], - customShifts: parsed.customShifts || {}, - templates: parsed.templates || [], - notes: parsed.notes || {} - }; - } catch(e) { - console.warn('Failed to load state:', e); + // ---- persistence ------------------------------------------------ + // A saved plan must never be lost silently: anything that cannot be + // parsed or repaired is copied to a recovery key before we move on. + const RECOVERY_PREFIX = STORAGE_KEY + '_recovery_'; + + function backupRaw(raw) { + const key = RECOVERY_PREFIX + new Date().toISOString().replace(/[:.]/g, '-'); + try { + localStorage.setItem(key, raw); + return key; + } catch (e) { + console.warn('Could not store recovery copy:', e); + return null; + } + } + + function defaultSettings() { + return { + darkMode: false, enforceRest: true, enforce5Day: true, + minRestHours: 11, maxConsecutive: 5, maxWorkers: DEFAULT_MAX_WORKERS + }; + } + + function isObj(v) { return !!v && typeof v === 'object' && !Array.isArray(v); } + function asArray(v) { return Array.isArray(v) ? v : []; } + function asObject(v) { return isObj(v) ? v : {}; } + function asDateKey(v) { + return (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)) ? v : null; + } + function asTime(v) { + return (typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v)) ? v : null; + } + function asInt(v, dflt, min, max) { + const n = parseInt(v, 10); + if (!isFinite(n)) return dflt; + return Math.min(max, Math.max(min, n)); + } + function asColor(v) { + return (typeof v === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(v)) ? v : WORKER_COLORS[0]; + } + const SAFE_ID = /^[A-Za-z0-9_.:-]{1,80}$/; + + // Validate every field and coerce wrong types instead of letting a + // render throw half way through. Returns {data, repaired}; data is null + // when the payload is not an object at all. + function normalizeState(parsed) { + const repaired = []; + if (!isObj(parsed)) return { data: null, repaired: ['root'] }; + + const data = {}; + Object.keys(parsed).forEach(k => { + if (k === '__proto__' || k === 'constructor' || k === 'prototype') return; + data[k] = parsed[k]; // unknown fields survive a re-save + }); + data.version = '2.1'; + + const rawSettings = asObject(parsed.settings); + const settings = defaultSettings(); + ['darkMode', 'enforceRest', 'enforce5Day'].forEach(k => { + if (!(k in rawSettings)) return; + if (typeof rawSettings[k] === 'boolean') settings[k] = rawSettings[k]; + else repaired.push('settings.' + k); + }); + settings.minRestHours = asInt(rawSettings.minRestHours, 11, 0, 24); + settings.maxConsecutive = asInt(rawSettings.maxConsecutive, 5, 1, 31); + settings.maxWorkers = asInt(rawSettings.maxWorkers, DEFAULT_MAX_WORKERS, 1, 500); + data.settings = settings; + + const idMap = Object.create(null); + const mapId = (raw, prefix, i) => { + if (SAFE_ID.test(raw || '')) return raw; + return prefix + Date.now().toString(36) + '_' + i; + }; + + data.workers = []; + asArray(parsed.workers).forEach((w, i) => { + if (!isObj(w) || typeof w.name !== 'string' || !w.name.trim()) { + repaired.push('workers[' + i + ']'); + return; } + const id = mapId(w.id, 'w_', i); + if (id !== w.id) repaired.push('workers[' + i + '].id'); + if (w.id) idMap[w.id] = id; + data.workers.push({ + id, + name: w.name.trim().slice(0, 200), + role: w.role === 'shiftleader' ? 'shiftleader' : 'employee', + contractedHours: asInt(w.contractedHours, 40, 0, 168), + color: asColor(w.color), + dayOffRequests: asArray(w.dayOffRequests).filter(asDateKey), + sickDays: asArray(w.sickDays) + .filter(s => isObj(s) && asDateKey(s.date)) + .map(s => ({ date: s.date, note: typeof s.note === 'string' ? s.note.slice(0, 200) : '' })), + vacation: asArray(w.vacation) + .filter(v => isObj(v) && asDateKey(v.start) && asDateKey(v.end)) + .map(v => ({ start: v.start, end: v.end, note: typeof v.note === 'string' ? v.note.slice(0, 200) : '' })), + notes: typeof w.notes === 'string' ? w.notes : '' + }); + }); + + const normalizeShift = (s, i, prefix) => { + if (!isObj(s) || typeof s.name !== 'string' || !s.name.trim()) return null; + const startTime = asTime(s.startTime); + const endTime = asTime(s.endTime); + if (!startTime || !endTime) return null; // a shift without valid hours is unusable + const id = mapId(s.id, prefix, i); + if (id !== s.id) repaired.push(prefix + 'id[' + i + ']'); + if (s.id) idMap[s.id] = id; + return { + id, + name: s.name.trim().slice(0, 120), + startTime, endTime, + minWorkers: asInt(s.minWorkers, 1, 0, 500), + maxWorkers: asInt(s.maxWorkers, 5, 1, 500), + requiresLeader: !!s.requiresLeader + }; + }; + + data.shifts = []; + asArray(parsed.shifts).forEach((s, i) => { + const shift = normalizeShift(s, i, 's_'); + if (!shift) { repaired.push('shifts[' + i + ']'); return; } + data.shifts.push(shift); + }); + + data.customShifts = {}; + const rawCustom = asObject(parsed.customShifts); + Object.keys(rawCustom).forEach(date => { + if (!asDateKey(date)) { repaired.push('customShifts[' + date + ']'); return; } + const list = asArray(rawCustom[date]).map((s, i) => normalizeShift(s, i, 'cs_')).filter(Boolean); + if (list.length !== asArray(rawCustom[date]).length) repaired.push('customShifts[' + date + ']'); + if (list.length) data.customShifts[date] = list; + }); + + const shiftExists = (dateStr, shiftId) => + data.shifts.some(s => s.id === shiftId) || + (data.customShifts[dateStr] || []).some(s => s.id === shiftId); + + data.assignments = []; + asArray(parsed.assignments).forEach((a, i) => { + if (!isObj(a) || !asDateKey(a.date) || typeof a.shiftId !== 'string' || typeof a.workerId !== 'string') { + repaired.push('assignments[' + i + ']'); + return; + } + const workerId = idMap[a.workerId] || a.workerId; + const shiftId = idMap[a.shiftId] || a.shiftId; + if (!data.workers.some(w => w.id === workerId) || !shiftExists(a.date, shiftId)) { + repaired.push('assignments[' + i + '] (orphaned)'); + return; + } + data.assignments.push({ date: a.date, shiftId, workerId }); + }); + + data.templates = []; + asArray(parsed.templates).forEach((t, i) => { + if (!isObj(t) || typeof t.name !== 'string') { repaired.push('templates[' + i + ']'); return; } + data.templates.push({ + id: SAFE_ID.test(t.id || '') ? t.id : 't_' + Date.now().toString(36) + '_' + i, + name: t.name.slice(0, 120), + data: { + shifts: asArray(asObject(t.data).shifts).map((s, j) => normalizeShift(s, j, 's_')).filter(Boolean), + assignments: asArray(asObject(t.data).assignments) + .filter(a => isObj(a) && asDateKey(a.date) && typeof a.shiftId === 'string' && typeof a.workerId === 'string') + .map(a => ({ date: a.date, shiftId: idMap[a.shiftId] || a.shiftId, workerId: idMap[a.workerId] || a.workerId })) + } + }); + }); + + data.notes = {}; + const rawNotes = asObject(parsed.notes); + Object.keys(rawNotes).forEach(date => { + if (asDateKey(date) && typeof rawNotes[date] === 'string') data.notes[date] = rawNotes[date]; + else repaired.push('notes[' + date + ']'); + }); + + return { data, repaired }; + } + + function loadFromStorage() { + let saved = null; + try { + saved = localStorage.getItem(STORAGE_KEY); + } catch (e) { + console.warn('localStorage is not available:', e); + return; + } + if (!saved) return; + + let parsed; + try { + parsed = JSON.parse(saved); + } catch (e) { + console.warn('Failed to load state:', e); + const key = backupRaw(saved); + showToast('Saved schedule could not be read. A copy was kept in this browser' + + (key ? ' under ' + key : '') + '.', 'error'); + return; + } + + const result = normalizeState(parsed); + if (!result.data) { + const key = backupRaw(saved); + showToast('Saved schedule had an unexpected format. A copy was kept in this browser' + + (key ? ' under ' + key : '') + '.', 'error'); + return; + } + state = result.data; + if (result.repaired.length) { + const key = backupRaw(saved); + console.warn('Repaired saved data:', result.repaired); + showToast('Skipped ' + result.repaired.length + ' invalid saved entr' + + (result.repaired.length === 1 ? 'y' : 'ies') + '; the original data is kept' + + (key ? ' under ' + key : '') + '.', 'warning'); } } function saveToStorage() { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + return true; + } catch (e) { + console.error('Failed to save state:', e); + showToast('Could not save — browser storage is full or blocked. Use Export JSON to keep your plan.', 'error'); + return false; + } + } + + // Another tab (or a manual clear) changed the stored plan: reload it so + // the two views cannot drift apart and silently overwrite each other. + function setupStorageSync() { + window.addEventListener('storage', function (e) { + if (e.key === null) { + state = normalizeState({}).data; + renderAll(); + applyTheme(); + showToast('Site data was cleared in another tab — the plan is now empty here too.', 'warning'); + } else if (e.key === STORAGE_KEY) { + loadFromStorage(); + renderAll(); + applyTheme(); + showToast('Schedule updated from another tab.', 'info'); + } else if (e.key === 'sp_ai_endpoint') { + aiEndpoint = e.newValue || 'http://localhost:11434'; + document.getElementById('aiEndpointDisplay').textContent = + aiEndpoint.replace(/^https?:\/\//, ''); + testAIConnection(); + } + }); } // ============================================================ @@ -1096,7 +1338,8 @@ let html = '<div class="shift-block">'; html += '<div class="shift-label ' + shiftClass + '">'; - html += '<span style="color:' + leaderColor + ';">' + escapeHtml(shift.name) + ' ' + shift.startTime + '–' + shift.endTime + leaderStatus + '</span>'; + html += '<span style="color:' + leaderColor + ';">' + escapeHtml(shift.name) + ' ' + + escapeHtml(shift.startTime) + '–' + escapeHtml(shift.endTime) + leaderStatus + '</span>'; if (customTag) html += customTag; html += '</div>'; @@ -1105,11 +1348,11 @@ if (!w) return; const bgColor = w.role === 'shiftleader' ? 'var(--warning)' - : w.color + '30'; + : escapeHtml(w.color) + '30'; html += '<div class="assignment" style="background:' + bgColor + ';" ' + 'onclick="event.stopPropagation();removeAssignment(\'' + dateStr + '\',\'' + shift.id + '\',\'' + w.id + '\')">'; - html += '<span class="dot" style="background:' + w.color + ';"></span>'; - html += '<span class="name">' + w.name + '</span>'; + html += '<span class="dot" style="background:' + escapeHtml(w.color) + ';"></span>'; + html += '<span class="name">' + escapeHtml(w.name) + '</span>'; if (w.role === 'shiftleader') html += '<span class="leader-badge">★</span>'; html += '<span class="remove-assign">×</span>'; html += '</div>'; @@ -1130,13 +1373,13 @@ const badges = []; state.workers.forEach(w => { if (w.sickDays?.some(s => s.date === dateStr)) { - badges.push('<span class="badge sick">🏥 ' + w.name + '</span>'); + badges.push('<span class="badge sick">🏥 ' + escapeHtml(w.name) + '</span>'); } if (w.vacation?.some(v => dateStr >= v.start && dateStr <= v.end)) { - badges.push('<span class="badge vacation">✈️ ' + w.name + '</span>'); + badges.push('<span class="badge vacation">✈️ ' + escapeHtml(w.name) + '</span>'); } if (w.dayOffRequests?.includes(dateStr)) { - badges.push('<span class="badge request">📋 ' + w.name + '</span>'); + badges.push('<span class="badge request">📋 ' + escapeHtml(w.name) + '</span>'); } }); return badges; @@ -1186,7 +1429,7 @@ : ''; return '<div class="shift-item" onclick="editShift(\'' + s.id + '\')">' + '<span style="font-weight:600;font-size:0.85rem;">' + escapeHtml(s.name) + '</span>' + - '<span class="shift-time">' + s.startTime + '–' + s.endTime + '</span>' + + '<span class="shift-time">' + escapeHtml(s.startTime) + '–' + escapeHtml(s.endTime) + '</span>' + leaderTag + '<span style="font-size:0.7rem;color:var(--muted);">min' + s.minWorkers + '</span>' + '<div class="item-actions">' + @@ -1211,11 +1454,27 @@ // ============================================================ // WORKERS CRUD // ============================================================ + // Ask for the modal, then let the openModal hook (the single place that + // knows the modal is really opening) populate the form. The header + // "+ Worker" button calls openModal('workerModal') directly, so a reset + // here alone would be bypassed. function openWorkerModal(id = null) { + pendingWorkerId = id || null; + openModal('workerModal'); + } + + function populateWorkerModal(id) { tempDayOffs = []; tempSickDays = []; tempVacations = []; document.getElementById('workerForm').reset(); + // Always start from a clean slate: a stale #workerId turns "Add Worker" + // into "overwrite the worker that was last edited". + document.getElementById('workerId').value = ''; + document.getElementById('workerName').value = ''; + document.getElementById('workerRole').value = 'employee'; + document.getElementById('workerHours').value = 40; + document.getElementById('workerNotes').value = ''; document.getElementById('colorPicker').innerHTML = ''; document.getElementById('deleteWorkerBtn').style.display = 'none'; @@ -1252,7 +1511,6 @@ } renderTempData(); - openModal('workerModal'); } function updateMaxWorkers() { @@ -1789,6 +2047,42 @@ seen.add(id); }); + // Real time-overlap. A shift ending after midnight can collide with a + // shift starting the same day or the next one, so compare absolute + // intervals instead of only "same calendar day" duplicates. + const spanDates = [getPrevDay(dateStr), dateStr, getNextDay(dateStr)]; + [...new Set(workerIds)].forEach(wId => { + const spans = []; + spanDates.forEach(ds => { + state.assignments + .filter(a => a.date === ds && a.workerId === wId) + .forEach(a => { + const sh = getShiftById(a.shiftId) || + (state.customShifts[ds] || []).find(s => s.id === a.shiftId); + if (!sh) return; + const base = dateToEpochMinutes(ds); + const start = base + timeToMinutes(sh.startTime); + let end = base + timeToMinutes(sh.endTime); + if (end <= start) end += 24 * 60; + spans.push({ date: ds, shift: sh, start, end }); + }); + }); + spans.sort((a, b) => a.start - b.start || b.end - a.end); + for (let i = 1; i < spans.length; i++) { + const prev = spans[i - 1], cur = spans[i]; + if (cur.start < prev.end && (prev.date === dateStr || cur.date === dateStr)) { + const w = state.workers.find(x => x.id === wId); + conflicts.push({ + type: 'error', + msg: (w?.name || 'Worker') + ': shifts overlap (' + + prev.shift.startTime + '–' + prev.shift.endTime + ' on ' + prev.date + + ' overlaps ' + cur.shift.startTime + '–' + cur.shift.endTime + + ' on ' + cur.date + ')' + }); + } + } + }); + const allShifts = [ ...state.shifts, ...(state.customShifts[dateStr] || []) @@ -1952,16 +2246,29 @@ return dates; } + // Date-string maths stays in the local calendar: going through + // Date#toISOString() converts to UTC and shifts the day west of UTC. + function formatDateLocal(y, m, d) { + const dt = new Date(y, m, d); + return dt.getFullYear() + '-' + + String(dt.getMonth() + 1).padStart(2, '0') + '-' + + String(dt.getDate()).padStart(2, '0'); + } + function getPrevDay(dateStr) { - const d = new Date(dateStr + 'T12:00:00'); - d.setDate(d.getDate() - 1); - return d.toISOString().split('T')[0]; + const [y, m, d] = dateStr.split('-').map(Number); + return formatDateLocal(y, m - 1, d - 1); } function getNextDay(dateStr) { - const d = new Date(dateStr + 'T12:00:00'); - d.setDate(d.getDate() + 1); - return d.toISOString().split('T')[0]; + const [y, m, d] = dateStr.split('-').map(Number); + return formatDateLocal(y, m - 1, d + 1); + } + + // Absolute minutes for a date, independent of the browser time zone. + function dateToEpochMinutes(dateStr) { + const [y, m, d] = dateStr.split('-').map(Number); + return Date.UTC(y, m - 1, d) / 60000; } function timeToMinutes(time) { @@ -2080,9 +2387,21 @@ if (genState.step > 1) setGenStep(genState.step - 1); } + function persistGenSetting(key, val) { + const n = parseInt(val, 10); + if (!isFinite(n)) return; + state.settings[key] = n; + saveToStorage(); + } + function generatePlan() { const maxConsec = parseInt(document.getElementById('maxConsecutive').value); const minRest = parseInt(document.getElementById('minRestHours').value); + // The wizard values are the live rules: persist them so conflict + // detection agrees with what the user just configured. + if (isFinite(maxConsec)) state.settings.maxConsecutive = maxConsec; + if (isFinite(minRest)) state.settings.minRestHours = minRest; + saveToStorage(); const priority = document.getElementById('fairnessPriority').value; const includeWeekends = document.getElementById('includeWeekends').checked; @@ -2356,10 +2675,20 @@ event.target.value = ''; } + // One quoting rule for every CSV field: always quoted, embedded quotes + // doubled, and a leading ' for anything a spreadsheet would run as a + // formula (= + - @ or a leading tab/CR). + function csvCell(value) { + let s = (value === null || value === undefined) ? '' : String(value); + if (/^[=+\-@\t\r]/.test(s.trim())) s = "'" + s; + return '"' + s.replace(/"/g, '""') + '"'; + } + function exportCSV() { const dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; - let csv = 'Date,Day,Shift,Start,End,Type,Workers\n'; - + const row = cells => cells.map(csvCell).join(',') + '\n'; + let csv = row(['Date','Day','Shift','Start','End','Type','Workers']); + let rows = 0; getMonthDates().forEach(d => { const dow = new Date(d + 'T12:00:00').getDay(); const dayCustomShifts = state.customShifts[d] || []; @@ -2371,7 +2700,8 @@ const w = state.workers.find(wk => wk.id === a.workerId); return w ? w.name + (w.role === 'shiftleader' ? ' ★' : '') : ''; }).filter(Boolean).join('; '); - csv += '"' + d + '","' + dayNames[dow] + '","' + s.name + '","' + s.startTime + '","' + s.endTime + '","template","' + workerNames + '"\n'; + csv += row([d, dayNames[dow], s.name, s.startTime, s.endTime, 'template', workerNames]); + rows++; } }); @@ -2381,11 +2711,12 @@ const w = state.workers.find(wk => wk.id === a.workerId); return w ? w.name + (w.role === 'shiftleader' ? ' ★' : '') : ''; }).filter(Boolean).join('; '); - csv += '"' + d + '","' + dayNames[dow] + '","' + cs.name + '","' + cs.startTime + '","' + cs.endTime + '","one-off","' + workerNames + '"\n'; + csv += row([d, dayNames[dow], cs.name, cs.startTime, cs.endTime, 'one-off', workerNames]); + rows++; }); }); - if (csv.split('\n').length <= 1) { + if (rows === 0) { showToast('No data to export', 'warning'); return; } @@ -2422,7 +2753,8 @@ document.getElementById('settingRestRule').checked = state.settings.enforceRest; document.getElementById('setting5DayRule').checked = state.settings.enforce5Day; document.getElementById('aiEndpointInput').value = aiEndpoint; - openModal('settingsModal'); + // The modal-open hook is the single caller of openSettings(); opening + // the modal from here would recurse forever. } function saveSettings() { @@ -2779,6 +3111,10 @@ Rules: _openModal(id); if (id === 'generatorModal') initGenerator(); if (id === 'settingsModal') openSettings(); + if (id === 'workerModal') { + populateWorkerModal(pendingWorkerId); + pendingWorkerId = null; + } }; // ============================================================ |