aboutsummaryrefslogtreecommitdiff
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
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.
-rw-r--r--CHANGELOG.md25
-rw-r--r--VERSION2
-rw-r--r--src/index.html430
3 files changed, 409 insertions, 48 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3babe97..425418c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,31 @@ All notable changes to this product tree are recorded here.
This file describes the packaged product, not the history of the five source
copies (that history is documented in `docs/CANONICAL.md`).
+## 1.0.1 — 2026-11-09
+
+Seven defects found by driving the app in Chromium, plus three data-integrity guards.
+
+- **Settings never opened**: `openSettings()` called `openModal('settingsModal')`, whose hook called
+ `openSettings()` again — `RangeError: Maximum call stack size exceeded` on every click. The cycle is
+ broken at the source; the hook is the single caller.
+- **"+ Worker" overwrote the last person you edited.** `openWorkerModal()` never cleared `#workerId`,
+ and the header button opened the modal directly, bypassing the reset. Adding a worker now starts blank.
+- **Dates were off by one west of UTC**: `getPrevDay()` built a local-noon `Date` and formatted it with
+ `toISOString()`. Day arithmetic is done on the date string in local time; verified in Pacific/Auckland,
+ America/Los_Angeles and UTC, including a month boundary.
+- **Overlaps were never detected.** The check only looked at the previous calendar day, so 22:00–06:00
+ followed by 02:00–10:00 the same day passed. Shifts are compared as absolute intervals now, including
+ overnight spill into the next day.
+- **The generator sliders did not persist**: conflict detection kept using 5/11 while the wizard showed
+ the new values. Both are written to `settings` on change and before generating.
+- **CSV export could execute formulas.** Cells are quoted with doubled inner quotes, and a leading `'`
+ is added to any value starting with `= + - @`, tab or CR.
+- **The header never wrapped**: at 390 px the document became 1139 px wide. It wraps now; 390 px stays 390 px.
+- Data integrity: an unparseable saved plan is copied to `shiftplanner_v2_recovery_<timestamp>` instead of
+ being silently overwritten, wrong-typed fields are normalised instead of blanking the page, unknown
+ top-level fields survive a save, `saveToStorage()` is guarded, and a `storage` listener reacts to
+ another tab.
+
## [1.0.0] - 2026-11-09
### Added
diff --git a/VERSION b/VERSION
index 3eefcb9..7dea76e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.0.0
+1.0.1
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;
+ }
};
// ============================================================