summaryrefslogtreecommitdiff
path: root/src/index.html
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-09 15:36:11 +0100
committerAgando <roduccomm@gmail.com>2026-11-09 15:36:11 +0100
commita3732122a461c9aa654401f6e2c54f168eedaad5 (patch)
treec6aeec0f89b65f8f870b912bdb16474d7a4ff8e2 /src/index.html
parentff6155599ccc6b594dd91d0afab32515b5d98f51 (diff)
fix(products): ship the reviewed 1.0.1 builds — the 1.0.0 pdf2txt never ranHEADv1.0.1main
A review of all three products in Chromium found defects that only execution exposes. pdf2txt 1.0.0 DID NOT RUN. Both the packaged file and the live demo contained a duplicated const declaration inside fetchModels, so the inline script threw a SyntaxError at parse time and no handler was ever bound: no extraction, no translation, no config. Proven by extracting both inline scripts from the committed file and from spaces/chatstudio/pdf2txt.html and running node --check (SyntaxError: Identifier 'config' has already been declared). The two published versions of this product were dead on arrival. pdf2txt 1.0.1 also stops writing the API key and the whole extracted document to localStorage (sessionStorage for the key, nothing for the text), keeps paragraphs (hasEOL), splits chunks on word boundaries, reports a non-JSON 200 or a missing response field instead of writing "undefined" into the editor, re-enables Translate after a run, resets stopRequested, stops a workflow on a failed step, warns on a PDF with no text layer, recognises .PDF, fixes an unterminated mobile media query, stops the Fullscreen button covering line 1, and makes the drop zone and all 39 controls reachable and named. shiftplanner 1.0.1 fixes seven demonstrated defects: the settings modal recursed until the stack overflowed, "+ Worker" prefilled the last edited worker id and overwrote that person, date maths was off by one west of UTC (local-noon Date formatted with toISOString), same-day and overnight shift overlaps were never flagged, the generator sliders never reached state.settings, CSV export could execute a formula and broke on a quote, and the header never wrapped (390 px viewport gave a 1139 px document). Plus four data-integrity guards: unparseable saved plans are copied to a recovery key instead of silently overwritten, wrong-typed fields are normalised, unknown top-level fields survive a save, and a storage event syncs another tab. chat-widget 1.0.1 closes a critical attribute-injection XSS. escapeHtml() round-tripped through textContent, which does not escape a double quote, so a reply containing https://x.test/"onclick="alert(1)// produced a real <a onclick=...> and fired alert(1) when clicked. Proven both ways: the 1.0.0 file fired a dialog, the fixed one renders only href/target/rel and stays silent. The launcher also covered the Send button at 390 px, so the widget could not be used on a phone at all. Also in this commit: - Landing pages and demos are generated (build-product-pages.py). The "Try the demo" button used to point at spaces/chatstudio/pdf2txt.html (the dead copy above) and at a v2.1 shiftplanner with no AI panel: a demo that was a different build than the ZIP. Demos are now built into /demos/<slug>/ from products/<slug>/, with the ../vendor/ prefix rewritten, and the publish wrapper regenerates them before every publish. - publish-public-repo.py refuses to publish a subtree with uncommitted changes. It reads git, so it silently republished the old tip while rc was 0 for every slug.
Diffstat (limited to 'src/index.html')
-rw-r--r--src/index.html430
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;
+ }
};
// ============================================================