From ed0a6cac85b5793bc23efe2965464e696d9ebf8c Mon Sep 17 00:00:00 2001
From: elkimek <36666630+elkimek@users.noreply.github.com>
Date: Sat, 30 May 2026 17:56:40 +0200
Subject: [PATCH 1/2] Extract marker detail editing workflows
---
js/marker-detail-editing.js | 475 +++++++++++++++++++++++++++++
js/marker-detail-modal.js | 500 +++----------------------------
service-worker.js | 1 +
tests/test-audit.js | 2 +-
tests/test-manual-entry-flow.js | 67 +++--
tests/test-marker-value-notes.js | 37 +--
tests/test-multi-unit.js | 5 +-
tests/test-provenance.js | 7 +-
tests/verify-modules.js | 3 +
version.js | 2 +-
10 files changed, 583 insertions(+), 516 deletions(-)
create mode 100644 js/marker-detail-editing.js
diff --git a/js/marker-detail-editing.js b/js/marker-detail-editing.js
new file mode 100644
index 00000000..d0f69d36
--- /dev/null
+++ b/js/marker-detail-editing.js
@@ -0,0 +1,475 @@
+// marker-detail-editing.js — Marker value, range, and note mutation workflows
+
+import { state } from './state.js';
+import { getAlternateUnit, convertUserInputToSI } from './schema.js';
+import { escapeHTML, escapeAttr, formatValue, showNotification, showConfirmDialog, showPromptDialog } from './utils.js';
+import { getActiveData, saveImportedData, recalculateHOMAIR, updateHeaderDates, convertDisplayToSI } from './data.js';
+import { clearTombstone } from './data-merge.js';
+
+const markerDetailDeps = {
+ navigate: (category, data) => window.navigate?.(category, data),
+ showDetailModal: () => {},
+ openManualEntryForm: () => {},
+ closeModal: () => {},
+};
+
+export function configureMarkerDetailEditing(deps = {}) {
+ Object.assign(markerDetailDeps, deps);
+}
+
+function showDetailModal(id, opts) {
+ return markerDetailDeps.showDetailModal(id, opts);
+}
+
+function openManualEntryForm(id, prefillDate) {
+ return markerDetailDeps.openManualEntryForm(id, prefillDate);
+}
+
+function closeModal() {
+ return markerDetailDeps.closeModal();
+}
+
+// Insulin is stored under hormones.insulin but also surfaced on the diabetes
+// category as diabetes.insulin_d (so the marker shows up in both contexts).
+// Per-value notes need to mirror across both keys regardless of which
+// category the user is editing from. Returns the OTHER key (if any) so the
+// caller can write the same note value to both sides.
+function _insulinMirrorNoteKey(dotKey, date) {
+ if (dotKey === 'hormones.insulin') return 'diabetes.insulin_d:' + date;
+ if (dotKey === 'diabetes.insulin_d') return 'hormones.insulin:' + date;
+ return null;
+}
+
+function _entryHasImportedSource(entry, dotKey) {
+ if (!entry) return false;
+ const markerSource = entry.markerSources?.[dotKey];
+ return !!(markerSource?.file || entry.sourceFile);
+}
+
+function _rememberManualOriginal(dotKey, date, entry) {
+ if (!entry || !dotKey || !date) return;
+ if (!state.importedData.manualValues) state.importedData.manualValues = {};
+ const mvKey = dotKey + ':' + date;
+ const current = entry.markers?.[dotKey];
+ const hasImportedOriginal = current != null && _entryHasImportedSource(entry, dotKey);
+ if (!(mvKey in state.importedData.manualValues)) {
+ state.importedData.manualValues[mvKey] = hasImportedOriginal ? current : true;
+ } else if (state.importedData.manualValues[mvKey] === true && hasImportedOriginal) {
+ state.importedData.manualValues[mvKey] = current;
+ }
+}
+
+export async function saveManualEntry(id, opts = {}) {
+ const { keepOpen = false } = opts;
+ const dateInput = document.getElementById('me-date');
+ const valueInput = document.getElementById('me-value');
+ const noteInput = document.getElementById('me-note');
+ const unitInput = document.getElementById('me-unit');
+ if (!dateInput || !valueInput) return;
+ const date = dateInput.value;
+ const value = parseFloat(valueInput.value);
+ // Cap notes at 500 chars to defend against runaway paste — matches the
+ // wearable-manual.js `_sanitizeNote` ceiling. Notes flow into IDB +
+ // sync payloads + AI context; a few-MB paste would bloat all three.
+ const noteRaw = noteInput ? noteInput.value.trim() : '';
+ const noteText = noteRaw.length > 500 ? noteRaw.slice(0, 500) : noteRaw;
+ if (!date) { showNotification('Please enter a date', 'error'); return; }
+ if (isNaN(value)) { showNotification('Please enter a valid number', 'error'); return; }
+ const dotKey = id.replace('_', '.');
+ // Always re-resolve marker from getActiveData (not state.markerRegistry):
+ // the registry may hold a marker.unit captured under a different unit-system
+ // mode, which would break the unit-picker comparison below.
+ const _meIdx = id.indexOf('_');
+ const marker = _meIdx > 0
+ ? getActiveData().categories[id.slice(0, _meIdx)]?.markers[id.slice(_meIdx + 1)]
+ : null;
+ // Unit-picker integration: if the user selected the alternate unit, the
+ // range sanity check needs alt-unit-space refs (otherwise typing "90 mg/dL"
+ // against an SI ref range of 4–6 mmol/L would always trigger the warning).
+ const inputUnit = unitInput?.value || marker?.unit || '';
+ const usingAltUnit = !!(marker && inputUnit && inputUnit !== marker.unit);
+ let checkRefMin = marker?.refMin, checkRefMax = marker?.refMax, checkUnit = marker?.unit;
+ if (marker && usingAltUnit) {
+ const isUSMode = state.unitSystem === 'US';
+ const altMin = marker.refMin != null ? getAlternateUnit(dotKey, marker.refMin, isUSMode) : null;
+ const altMax = marker.refMax != null ? getAlternateUnit(dotKey, marker.refMax, isUSMode) : null;
+ checkRefMin = altMin?.value ?? null;
+ checkRefMax = altMax?.value ?? null;
+ checkUnit = inputUnit;
+ }
+ // Range sanity check: catches decimal/unit slips (e.g. typing 100 mg/dL when SI ref is 4–6 mmol/L).
+ if (marker) {
+ let warn = null;
+ if (value < 0) warn = `${value} is negative — values are usually 0 or positive.`;
+ else if (checkRefMax != null && checkRefMax > 0 && value > checkRefMax * 10) warn = `${value} is much higher than the reference range (${checkRefMin ?? '?'}–${checkRefMax} ${checkUnit}). Did you enter the right unit?`;
+ else if (checkRefMin != null && checkRefMin > 0 && value < checkRefMin / 10) warn = `${value} is much lower than the reference range (${checkRefMin}–${checkRefMax ?? '?'} ${checkUnit}). Did you enter the right unit?`;
+ if (warn && !await showConfirmDialog(`${warn}\n\nSave anyway?`)) return;
+ }
+ // Duplicate-date check: an existing value for this marker on the same date.
+ const existingEntry = state.importedData.entries?.find(e => e.date === date);
+ if (existingEntry && existingEntry.markers && existingEntry.markers[dotKey] != null) {
+ // Show in display units — find the marker's display value at this date.
+ const data = getActiveData();
+ const dateIdx = data.dates.indexOf(date);
+ const displayVal = (dateIdx >= 0 && marker) ? marker.values[dateIdx] : existingEntry.markers[dotKey];
+ const unit = marker?.unit || '';
+ if (!await showConfirmDialog(`A value of ${displayVal} ${unit} already exists for ${date}. Overwrite?`)) return;
+ }
+ if (!state.importedData.entries) state.importedData.entries = [];
+ clearTombstone(state.importedData, 'entries', date);
+ let entry = state.importedData.entries.find(e => e.date === date);
+ if (!entry) {
+ entry = { date: date, markers: {} };
+ state.importedData.entries.push(entry);
+ }
+ // If the user picked the alternate unit, convert from there directly to SI
+ // (convertUserInputToSI is a no-op when inputUnit is already the SI unit, so
+ // the EU-mode default keeps working unchanged). Otherwise fall through to the
+ // existing display→SI path which handles the US-mode case.
+ const storedValue = usingAltUnit
+ ? convertUserInputToSI(dotKey, value, inputUnit)
+ : convertDisplayToSI(dotKey, value);
+ _rememberManualOriginal(dotKey, date, entry);
+ entry.markers[dotKey] = storedValue;
+ if (!entry.markerSources) entry.markerSources = {};
+ entry.markerSources[dotKey] = { file: null, at: Date.now() };
+ // Per-value note: store on save when non-empty; clear when emptied.
+ if (!state.importedData.markerValueNotes) state.importedData.markerValueNotes = {};
+ const noteKey = dotKey + ':' + date;
+ if (noteText) state.importedData.markerValueNotes[noteKey] = noteText;
+ else delete state.importedData.markerValueNotes[noteKey];
+ if (dotKey === 'hormones.insulin') {
+ _rememberManualOriginal('diabetes.insulin_d', date, entry);
+ entry.markers['diabetes.insulin_d'] = storedValue;
+ entry.markerSources['diabetes.insulin_d'] = entry.markerSources[dotKey];
+ }
+ // Mirror the per-value note across the insulin dual-mapping — same reading,
+ // two views. Bidirectional: user may save via either category page. Without
+ // this, a note added on one side wouldn't show on the other, and orphans
+ // would accumulate over delete cycles.
+ const insulinNoteMirror = _insulinMirrorNoteKey(dotKey, date);
+ if (insulinNoteMirror) {
+ if (noteText) state.importedData.markerValueNotes[insulinNoteMirror] = noteText;
+ else delete state.importedData.markerValueNotes[insulinNoteMirror];
+ }
+ recalculateHOMAIR(entry);
+ await saveImportedData();
+ // Remember the date session-wide so the next manual entry defaults to it.
+ try { sessionStorage.setItem('labcharts-last-manual-date', date); } catch (_) {}
+ window.buildSidebar();
+ updateHeaderDates();
+ const targetCat = id.indexOf('_') !== -1 ? id.slice(0, id.indexOf('_')) : null;
+ const data = getActiveData();
+ const navCat = (targetCat && data.categories?.[targetCat]) ? targetCat : "dashboard";
+ showNotification(`Added ${state.markerRegistry[id]?.name || id}: ${value} on ${date}`, 'success');
+ if (keepOpen) {
+ // Rebuild page underneath, re-open the manual-entry form with the same id + date.
+ // Form re-render is in-place (modal.innerHTML), so no flicker.
+ markerDetailDeps.navigate(navCat);
+ openManualEntryForm(id, date);
+ } else {
+ closeModal();
+ markerDetailDeps.navigate(navCat);
+ // Re-open detail modal so user stays in context (#29)
+ setTimeout(() => showDetailModal(id), 50);
+ }
+}
+
+export function saveAndAddAnotherManualEntry(id) {
+ return saveManualEntry(id, { keepOpen: true });
+}
+
+export async function deleteMarkerValue(id, date) {
+ const dotKey = id.replace('_', '.');
+ if (!state.importedData.entries) return;
+ const entry = state.importedData.entries.find(e => e.date === date);
+ if (!entry || entry.markers[dotKey] === undefined) return;
+ if (await showConfirmDialog(`Delete this value (${date})? This can't be undone.`)) {
+ delete entry.markers[dotKey];
+ // Clean up provenance and manual tracking
+ if (entry.markerSources) delete entry.markerSources[dotKey];
+ if (state.importedData.manualValues) delete state.importedData.manualValues[dotKey + ':' + date];
+ // Drop the per-value note (if any) — value is gone, note is orphaned.
+ if (state.importedData.markerValueNotes) delete state.importedData.markerValueNotes[dotKey + ':' + date];
+ // Clean up insulin dual-mapping (value, provenance, AND the per-value
+ // note for the mirror key — same reading, both views must go together).
+ if (dotKey === 'hormones.insulin') {
+ delete entry.markers['diabetes.insulin_d'];
+ if (entry.markerSources) delete entry.markerSources['diabetes.insulin_d'];
+ recalculateHOMAIR(entry);
+ }
+ // Mirror the note delete in both directions — user may delete via either
+ // category. Forward-only would leave orphans on the other side.
+ const mirrorKey = _insulinMirrorNoteKey(dotKey, date);
+ if (mirrorKey && state.importedData.markerValueNotes) {
+ delete state.importedData.markerValueNotes[mirrorKey];
+ }
+ // Remove entry entirely if no markers left
+ if (Object.keys(entry.markers).length === 0) {
+ state.importedData.entries = state.importedData.entries.filter(e => e.date !== date);
+ }
+ saveImportedData();
+ window.buildSidebar();
+ updateHeaderDates();
+ // Re-open the detail modal to show updated values. buildSidebar
+ // resets .active to Dashboard, so use state.currentView (kept in
+ // sync by navigate) instead of re-reading the DOM.
+ markerDetailDeps.navigate(state.currentView || "dashboard");
+ showDetailModal(id);
+ showNotification(`Removed value from ${date}`, 'info');
+ }
+}
+
+export function editMarkerValue(id, date, currentValue, event) {
+ const el = event.target.closest('.mv-value');
+ if (!el || el.querySelector('input')) return;
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.step = 'any';
+ input.value = currentValue;
+ input.className = 'ref-edit-input';
+ input.style.cssText = 'width:100%;max-width:140px;text-align:center;font-size:inherit;box-sizing:border-box;padding:2px 4px';
+ el.textContent = '';
+ el.appendChild(input);
+ input.focus();
+ input.select();
+ let cancelled = false;
+ let saveStarted = false;
+ const save = async () => {
+ if (cancelled) return;
+ if (saveStarted) return;
+ saveStarted = true;
+ const newValue = parseFloat(input.value);
+ if (isNaN(newValue)) { showDetailModal(id); return; }
+ // No-op if the value didn't change — don't flip provenance to manual.
+ if (newValue === parseFloat(currentValue)) { showDetailModal(id); return; }
+ const dotKey = id.replace('_', '.');
+ const entry = state.importedData.entries?.find(e => e.date === date);
+ if (!entry) return;
+ // Track as manually edited — store original value for revert (true = manual entry with no original)
+ _rememberManualOriginal(dotKey, date, entry);
+ const storedValue = convertDisplayToSI(dotKey, newValue);
+ entry.markers[dotKey] = storedValue;
+ // Update provenance to reflect manual edit
+ if (!entry.markerSources) entry.markerSources = {};
+ entry.markerSources[dotKey] = { file: null, at: Date.now() };
+ if (dotKey === 'hormones.insulin') { entry.markers['diabetes.insulin_d'] = storedValue; if (entry.markerSources) entry.markerSources['diabetes.insulin_d'] = entry.markerSources[dotKey]; recalculateHOMAIR(entry); }
+ await saveImportedData();
+ // Rebuild the underlying view so Table/Heatmap/Chart reflect the edit.
+ markerDetailDeps.navigate(state.currentView || 'dashboard');
+ showDetailModal(id);
+ };
+ input.addEventListener('blur', () => { void save(); });
+ input.addEventListener('keydown', e => {
+ if (e.key === 'Enter') { e.preventDefault(); void save(); }
+ else if (e.key === 'Escape') { cancelled = true; showDetailModal(id); }
+ });
+}
+
+export async function revertMarkerValue(id, date) {
+ const dotKey = id.replace('_', '.');
+ const mvKey = dotKey + ':' + date;
+ const original = state.importedData.manualValues?.[mvKey];
+ if (original == null || original === true) return;
+ const entry = state.importedData.entries?.find(e => e.date === date);
+ if (!entry) return;
+ entry.markers[dotKey] = original;
+ if (entry.markerSources) delete entry.markerSources[dotKey];
+ if (dotKey === 'hormones.insulin') {
+ entry.markers['diabetes.insulin_d'] = original;
+ if (entry.markerSources) delete entry.markerSources['diabetes.insulin_d'];
+ delete state.importedData.manualValues['diabetes.insulin_d:' + date];
+ recalculateHOMAIR(entry);
+ }
+ delete state.importedData.manualValues[mvKey];
+ await saveImportedData();
+ // Rebuild the underlying view so Table/Heatmap/Chart reflect the revert.
+ markerDetailDeps.navigate(state.currentView || 'dashboard');
+ showDetailModal(id);
+}
+
+export async function editValueNote(id, date) {
+ if (!id || !date) return;
+ const dotKey = id.replace('_', '.');
+ const noteKey = dotKey + ':' + date;
+ if (!state.importedData.markerValueNotes) state.importedData.markerValueNotes = {};
+ const current = state.importedData.markerValueNotes[noteKey] || '';
+ const result = await showPromptDialog(
+ current ? `Edit note for ${date}` : `Add note for ${date}`,
+ { defaultValue: current, placeholder: 'e.g. fasted 14h, post-workout, different lab', okLabel: 'Save' }
+ );
+ // showPromptDialog collapses cancel + empty-submit to null. Treat null as
+ // "no change" — explicit deletion is via the dedicated × affordance.
+ if (result === null) return;
+ // Cap to match saveManualEntry — defends against runaway paste flowing
+ // into IDB, sync payloads, and AI context.
+ const capped = result.length > 500 ? result.slice(0, 500) : result;
+ state.importedData.markerValueNotes[noteKey] = capped;
+ // Mirror across the insulin dual-mapping in BOTH directions so a note
+ // edited via diabetes.insulin_d also lands on hormones.insulin and vice
+ // versa.
+ const mirror = _insulinMirrorNoteKey(dotKey, date);
+ if (mirror) state.importedData.markerValueNotes[mirror] = capped;
+ saveImportedData();
+ showDetailModal(id);
+}
+
+export async function deleteValueNote(id, date) {
+ if (!id || !date) return;
+ if (!await showConfirmDialog(`Remove the note for ${date}?`)) return;
+ const dotKey = id.replace('_', '.');
+ const noteKey = dotKey + ':' + date;
+ if (state.importedData.markerValueNotes && state.importedData.markerValueNotes[noteKey]) {
+ delete state.importedData.markerValueNotes[noteKey];
+ // Mirror cleanup in BOTH directions across the insulin dual-mapping.
+ const mirror = _insulinMirrorNoteKey(dotKey, date);
+ if (mirror) delete state.importedData.markerValueNotes[mirror];
+ saveImportedData();
+ showDetailModal(id);
+ }
+}
+
+export function editRefRange(id, type, evt) {
+ const marker = state.markerRegistry[id];
+ if (!marker) return;
+ const isOptimal = type === 'optimal';
+ const curMin = isOptimal ? marker.optimalMin : marker.refMin;
+ const curMax = isOptimal ? marker.optimalMax : marker.refMax;
+ const label = isOptimal ? 'Optimal' : 'Reference';
+
+ const span = evt.target.closest('.ref-editable');
+ if (!span) return;
+
+ // Replace span with inline inputs
+ const form = document.createElement('span');
+ form.className = 'ref-edit-form';
+ form.innerHTML = `${escapeHTML(label)}: \u2013 `;
+ span.replaceWith(form);
+ form.querySelector('#ref-edit-min').focus();
+
+ // Enter to save
+ form.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); saveRefRange(id, type); } });
+ // Escape to cancel
+ form.addEventListener('keydown', e => { if (e.key === 'Escape') showDetailModal(id); });
+}
+
+export function saveRefRange(id, type) {
+ const dotKey = id.replace('_', '.');
+ const minEl = document.getElementById('ref-edit-min');
+ const maxEl = document.getElementById('ref-edit-max');
+ if (!minEl || !maxEl) return;
+ let newMin = minEl.value.trim() !== '' ? parseFloat(minEl.value) : null;
+ let newMax = maxEl.value.trim() !== '' ? parseFloat(maxEl.value) : null;
+ // Treat NaN as null (open-ended)
+ if (newMin != null && isNaN(newMin)) newMin = null;
+ if (newMax != null && isNaN(newMax)) newMax = null;
+
+ // If user is in US mode, convert back to SI for storage (overrides are applied before unit conversion)
+ if (newMin != null) newMin = convertDisplayToSI(dotKey, newMin);
+ if (newMax != null) newMax = convertDisplayToSI(dotKey, newMax);
+
+ if (!state.importedData.refOverrides) state.importedData.refOverrides = {};
+ if (!state.importedData.refOverrides[dotKey]) state.importedData.refOverrides[dotKey] = {};
+
+ const ovr = state.importedData.refOverrides[dotKey];
+ if (type === 'optimal') {
+ // Stash lab values before first manual edit
+ if (ovr.optimalSource !== 'manual' && ('optimalMin' in ovr) && !('labOptimalMin' in ovr)) {
+ ovr.labOptimalMin = ovr.optimalMin;
+ ovr.labOptimalMax = ovr.optimalMax;
+ }
+ ovr.optimalMin = newMin;
+ ovr.optimalMax = newMax;
+ ovr.optimalSource = 'manual';
+ } else {
+ if (ovr.refSource !== 'manual' && ('refMin' in ovr) && !('labRefMin' in ovr)) {
+ ovr.labRefMin = ovr.refMin;
+ ovr.labRefMax = ovr.refMax;
+ }
+ ovr.refMin = newMin;
+ ovr.refMax = newMax;
+ ovr.refSource = 'manual';
+ }
+
+ saveImportedData();
+ // Refresh background view, then re-render modal with new ranges
+ const activeNav = document.querySelector('.nav-item.active');
+ markerDetailDeps.navigate(activeNav ? activeNav.dataset.category : 'dashboard');
+ showDetailModal(id);
+ showNotification('Range updated', 'info');
+}
+
+export function revertRefRange(id, type) {
+ const dotKey = id.replace('_', '.');
+ const ovr = state.importedData?.refOverrides?.[dotKey];
+ if (!ovr) return;
+ let msg = 'Range reverted to default';
+ if (type === 'optimal') {
+ if ('labOptimalMin' in ovr) {
+ // Revert to imported lab range
+ ovr.optimalMin = ovr.labOptimalMin;
+ ovr.optimalMax = ovr.labOptimalMax;
+ ovr.optimalSource = 'import';
+ delete ovr.labOptimalMin; delete ovr.labOptimalMax;
+ msg = 'Range reverted to lab range';
+ } else {
+ delete ovr.optimalMin; delete ovr.optimalMax; delete ovr.optimalSource;
+ }
+ } else {
+ if ('labRefMin' in ovr) {
+ ovr.refMin = ovr.labRefMin;
+ ovr.refMax = ovr.labRefMax;
+ ovr.refSource = 'import';
+ delete ovr.labRefMin; delete ovr.labRefMax;
+ msg = 'Range reverted to lab range';
+ } else {
+ delete ovr.refMin; delete ovr.refMax; delete ovr.refSource;
+ }
+ }
+ // Clean up empty override objects
+ if (Object.keys(ovr).length === 0) delete state.importedData.refOverrides[dotKey];
+ saveImportedData();
+ const activeNav = document.querySelector('.nav-item.active');
+ markerDetailDeps.navigate(activeNav ? activeNav.dataset.category : 'dashboard');
+ showDetailModal(id);
+ showNotification(msg, 'info');
+}
+
+export function toggleMarkerNoteEditor(dotKey) {
+ const editor = document.getElementById('marker-note-editor');
+ if (!editor) return;
+ const isHidden = editor.style.display === 'none';
+ editor.style.display = isHidden ? 'block' : 'none';
+ if (isHidden) {
+ const input = document.getElementById('marker-note-input');
+ if (input) input.focus();
+ }
+}
+
+export function saveMarkerNote(dotKey, id) {
+ const input = document.getElementById('marker-note-input');
+ const text = input?.value?.trim();
+ if (!text) {
+ // Empty text = delete the note
+ if (state.importedData.markerNotes?.[dotKey]) {
+ delete state.importedData.markerNotes[dotKey];
+ saveImportedData();
+ showNotification('Note removed', 'info');
+ showDetailModal(id);
+ }
+ return;
+ }
+ if (!state.importedData.markerNotes) state.importedData.markerNotes = {};
+ state.importedData.markerNotes[dotKey] = text;
+ saveImportedData();
+ showNotification('Note saved', 'success');
+ showDetailModal(id);
+}
+
+export function deleteMarkerNote(dotKey, id) {
+ if (!state.importedData.markerNotes) return;
+ delete state.importedData.markerNotes[dotKey];
+ saveImportedData();
+ showNotification('Note removed', 'info');
+ showDetailModal(id);
+}
diff --git a/js/marker-detail-modal.js b/js/marker-detail-modal.js
index cef9bddd..705cba5e 100644
--- a/js/marker-detail-modal.js
+++ b/js/marker-detail-modal.js
@@ -1,13 +1,44 @@
// marker-detail-modal.js — Marker detail, manual entry, custom marker, and range modal flows
import { state } from './state.js';
-import { trackUsage, UNIT_CONVERSIONS, getAlternateUnit, convertUserInputToSI } from './schema.js';
-import { escapeHTML, escapeAttr, getStatus, formatValue, showNotification, showConfirmDialog, showPromptDialog, safeMarkerId } from './utils.js';
-import { getActiveData, getEffectiveRange, getEffectiveRangeForDate, saveImportedData, recalculateHOMAIR, updateHeaderDates, convertDisplayToSI } from './data.js';
-import { clearTombstone } from './data-merge.js';
+import { trackUsage, UNIT_CONVERSIONS, getAlternateUnit } from './schema.js';
+import { escapeHTML, escapeAttr, getStatus, formatValue, showNotification, showConfirmDialog, safeMarkerId } from './utils.js';
+import { getActiveData, getEffectiveRange, getEffectiveRangeForDate, saveImportedData, updateHeaderDates } from './data.js';
import { createLineChart, getMarkerDescription } from './charts.js';
import { closeSuggestionsOnClickOutside } from './context-cards.js';
import { callClaudeAPI, hasAIProvider, getAIProvider, getActiveModelId } from './api.js';
+import {
+ configureMarkerDetailEditing,
+ editRefRange,
+ saveRefRange,
+ revertRefRange,
+ saveManualEntry,
+ saveAndAddAnotherManualEntry,
+ deleteMarkerValue,
+ editMarkerValue,
+ revertMarkerValue,
+ editValueNote,
+ deleteValueNote,
+ toggleMarkerNoteEditor,
+ saveMarkerNote,
+ deleteMarkerNote,
+} from './marker-detail-editing.js';
+
+export {
+ editRefRange,
+ saveRefRange,
+ revertRefRange,
+ saveManualEntry,
+ saveAndAddAnotherManualEntry,
+ deleteMarkerValue,
+ editMarkerValue,
+ revertMarkerValue,
+ editValueNote,
+ deleteValueNote,
+ toggleMarkerNoteEditor,
+ saveMarkerNote,
+ deleteMarkerNote,
+};
const markerDetailDeps = {
navigate: (category, data) => window.navigate?.(category, data),
@@ -19,6 +50,13 @@ export function configureMarkerDetailModal(deps = {}) {
Object.assign(markerDetailDeps, deps);
}
+configureMarkerDetailEditing({
+ navigate: (...args) => markerDetailDeps.navigate(...args),
+ showDetailModal: (...args) => showDetailModal(...args),
+ openManualEntryForm: (...args) => openManualEntryForm(...args),
+ closeModal: () => closeModal(),
+});
+
// Biological-age component inputs. Keep these in sync with the PhenoAge and
// Bortz Age calculations in data.js so the detail modal can explain exactly
// which panel inputs are present or still missing.
@@ -756,156 +794,6 @@ export function openManualEntryForm(id, prefillDate) {
}, 50);
}
-// Insulin is stored under hormones.insulin but also surfaced on the diabetes
-// category as diabetes.insulin_d (so the marker shows up in both contexts).
-// Per-value notes need to mirror across both keys regardless of which
-// category the user is editing from. Returns the OTHER key (if any) so the
-// caller can write the same note value to both sides.
-function _insulinMirrorNoteKey(dotKey, date) {
- if (dotKey === 'hormones.insulin') return 'diabetes.insulin_d:' + date;
- if (dotKey === 'diabetes.insulin_d') return 'hormones.insulin:' + date;
- return null;
-}
-
-function _entryHasImportedSource(entry, dotKey) {
- if (!entry) return false;
- const markerSource = entry.markerSources?.[dotKey];
- return !!(markerSource?.file || entry.sourceFile);
-}
-
-function _rememberManualOriginal(dotKey, date, entry) {
- if (!entry || !dotKey || !date) return;
- if (!state.importedData.manualValues) state.importedData.manualValues = {};
- const mvKey = dotKey + ':' + date;
- const current = entry.markers?.[dotKey];
- const hasImportedOriginal = current != null && _entryHasImportedSource(entry, dotKey);
- if (!(mvKey in state.importedData.manualValues)) {
- state.importedData.manualValues[mvKey] = hasImportedOriginal ? current : true;
- } else if (state.importedData.manualValues[mvKey] === true && hasImportedOriginal) {
- state.importedData.manualValues[mvKey] = current;
- }
-}
-
-export async function saveManualEntry(id, opts = {}) {
- const { keepOpen = false } = opts;
- const dateInput = document.getElementById('me-date');
- const valueInput = document.getElementById('me-value');
- const noteInput = document.getElementById('me-note');
- const unitInput = document.getElementById('me-unit');
- if (!dateInput || !valueInput) return;
- const date = dateInput.value;
- const value = parseFloat(valueInput.value);
- // Cap notes at 500 chars to defend against runaway paste — matches the
- // wearable-manual.js `_sanitizeNote` ceiling. Notes flow into IDB +
- // sync payloads + AI context; a few-MB paste would bloat all three.
- const noteRaw = noteInput ? noteInput.value.trim() : '';
- const noteText = noteRaw.length > 500 ? noteRaw.slice(0, 500) : noteRaw;
- if (!date) { showNotification('Please enter a date', 'error'); return; }
- if (isNaN(value)) { showNotification('Please enter a valid number', 'error'); return; }
- const dotKey = id.replace('_', '.');
- // Always re-resolve marker from getActiveData (not state.markerRegistry):
- // the registry may hold a marker.unit captured under a different unit-system
- // mode, which would break the unit-picker comparison below.
- const _meIdx = id.indexOf('_');
- const marker = _meIdx > 0
- ? getActiveData().categories[id.slice(0, _meIdx)]?.markers[id.slice(_meIdx + 1)]
- : null;
- // Unit-picker integration: if the user selected the alternate unit, the
- // range sanity check needs alt-unit-space refs (otherwise typing "90 mg/dL"
- // against an SI ref range of 4–6 mmol/L would always trigger the warning).
- const inputUnit = unitInput?.value || marker?.unit || '';
- const usingAltUnit = !!(marker && inputUnit && inputUnit !== marker.unit);
- let checkRefMin = marker?.refMin, checkRefMax = marker?.refMax, checkUnit = marker?.unit;
- if (marker && usingAltUnit) {
- const isUSMode = state.unitSystem === 'US';
- const altMin = marker.refMin != null ? getAlternateUnit(dotKey, marker.refMin, isUSMode) : null;
- const altMax = marker.refMax != null ? getAlternateUnit(dotKey, marker.refMax, isUSMode) : null;
- checkRefMin = altMin?.value ?? null;
- checkRefMax = altMax?.value ?? null;
- checkUnit = inputUnit;
- }
- // Range sanity check: catches decimal/unit slips (e.g. typing 100 mg/dL when SI ref is 4–6 mmol/L).
- if (marker) {
- let warn = null;
- if (value < 0) warn = `${value} is negative — values are usually 0 or positive.`;
- else if (checkRefMax != null && checkRefMax > 0 && value > checkRefMax * 10) warn = `${value} is much higher than the reference range (${checkRefMin ?? '?'}–${checkRefMax} ${checkUnit}). Did you enter the right unit?`;
- else if (checkRefMin != null && checkRefMin > 0 && value < checkRefMin / 10) warn = `${value} is much lower than the reference range (${checkRefMin}–${checkRefMax ?? '?'} ${checkUnit}). Did you enter the right unit?`;
- if (warn && !await showConfirmDialog(`${warn}\n\nSave anyway?`)) return;
- }
- // Duplicate-date check: an existing value for this marker on the same date.
- const existingEntry = state.importedData.entries?.find(e => e.date === date);
- if (existingEntry && existingEntry.markers && existingEntry.markers[dotKey] != null) {
- // Show in display units — find the marker's display value at this date.
- const data = getActiveData();
- const dateIdx = data.dates.indexOf(date);
- const displayVal = (dateIdx >= 0 && marker) ? marker.values[dateIdx] : existingEntry.markers[dotKey];
- const unit = marker?.unit || '';
- if (!await showConfirmDialog(`A value of ${displayVal} ${unit} already exists for ${date}. Overwrite?`)) return;
- }
- if (!state.importedData.entries) state.importedData.entries = [];
- clearTombstone(state.importedData, 'entries', date);
- let entry = state.importedData.entries.find(e => e.date === date);
- if (!entry) {
- entry = { date: date, markers: {} };
- state.importedData.entries.push(entry);
- }
- // If the user picked the alternate unit, convert from there directly to SI
- // (convertUserInputToSI is a no-op when inputUnit is already the SI unit, so
- // the EU-mode default keeps working unchanged). Otherwise fall through to the
- // existing display→SI path which handles the US-mode case.
- const storedValue = usingAltUnit
- ? convertUserInputToSI(dotKey, value, inputUnit)
- : convertDisplayToSI(dotKey, value);
- _rememberManualOriginal(dotKey, date, entry);
- entry.markers[dotKey] = storedValue;
- if (!entry.markerSources) entry.markerSources = {};
- entry.markerSources[dotKey] = { file: null, at: Date.now() };
- // Per-value note: store on save when non-empty; clear when emptied.
- if (!state.importedData.markerValueNotes) state.importedData.markerValueNotes = {};
- const noteKey = dotKey + ':' + date;
- if (noteText) state.importedData.markerValueNotes[noteKey] = noteText;
- else delete state.importedData.markerValueNotes[noteKey];
- if (dotKey === 'hormones.insulin') {
- _rememberManualOriginal('diabetes.insulin_d', date, entry);
- entry.markers['diabetes.insulin_d'] = storedValue;
- entry.markerSources['diabetes.insulin_d'] = entry.markerSources[dotKey];
- }
- // Mirror the per-value note across the insulin dual-mapping — same reading,
- // two views. Bidirectional: user may save via either category page. Without
- // this, a note added on one side wouldn't show on the other, and orphans
- // would accumulate over delete cycles.
- const insulinNoteMirror = _insulinMirrorNoteKey(dotKey, date);
- if (insulinNoteMirror) {
- if (noteText) state.importedData.markerValueNotes[insulinNoteMirror] = noteText;
- else delete state.importedData.markerValueNotes[insulinNoteMirror];
- }
- recalculateHOMAIR(entry);
- await saveImportedData();
- // Remember the date session-wide so the next manual entry defaults to it.
- try { sessionStorage.setItem('labcharts-last-manual-date', date); } catch (_) {}
- window.buildSidebar();
- updateHeaderDates();
- const targetCat = id.indexOf('_') !== -1 ? id.slice(0, id.indexOf('_')) : null;
- const data = getActiveData();
- const navCat = (targetCat && data.categories?.[targetCat]) ? targetCat : "dashboard";
- showNotification(`Added ${state.markerRegistry[id]?.name || id}: ${value} on ${date}`, 'success');
- if (keepOpen) {
- // Rebuild page underneath, re-open the manual-entry form with the same id + date.
- // Form re-render is in-place (modal.innerHTML), so no flicker.
- markerDetailDeps.navigate(navCat);
- openManualEntryForm(id, date);
- } else {
- closeModal();
- markerDetailDeps.navigate(navCat);
- // Re-open detail modal so user stays in context (#29)
- setTimeout(() => showDetailModal(id), 50);
- }
-}
-
-export function saveAndAddAnotherManualEntry(id) {
- return saveManualEntry(id, { keepOpen: true });
-}
-
export function openCreateMarkerModal() {
const modal = setDetailModalShell('gb-form-modal', 'marker-form-modal');
const overlay = document.getElementById("modal-overlay");
@@ -1059,47 +947,6 @@ export function saveCustomMarker() {
setTimeout(() => openManualEntryForm(id), 100);
}
-export async function deleteMarkerValue(id, date) {
- const dotKey = id.replace('_', '.');
- if (!state.importedData.entries) return;
- const entry = state.importedData.entries.find(e => e.date === date);
- if (!entry || entry.markers[dotKey] === undefined) return;
- if (await showConfirmDialog(`Delete this value (${date})? This can't be undone.`)) {
- delete entry.markers[dotKey];
- // Clean up provenance and manual tracking
- if (entry.markerSources) delete entry.markerSources[dotKey];
- if (state.importedData.manualValues) delete state.importedData.manualValues[dotKey + ':' + date];
- // Drop the per-value note (if any) — value is gone, note is orphaned.
- if (state.importedData.markerValueNotes) delete state.importedData.markerValueNotes[dotKey + ':' + date];
- // Clean up insulin dual-mapping (value, provenance, AND the per-value
- // note for the mirror key — same reading, both views must go together).
- if (dotKey === 'hormones.insulin') {
- delete entry.markers['diabetes.insulin_d'];
- if (entry.markerSources) delete entry.markerSources['diabetes.insulin_d'];
- recalculateHOMAIR(entry);
- }
- // Mirror the note delete in both directions — user may delete via either
- // category. Forward-only would leave orphans on the other side.
- const mirrorKey = _insulinMirrorNoteKey(dotKey, date);
- if (mirrorKey && state.importedData.markerValueNotes) {
- delete state.importedData.markerValueNotes[mirrorKey];
- }
- // Remove entry entirely if no markers left
- if (Object.keys(entry.markers).length === 0) {
- state.importedData.entries = state.importedData.entries.filter(e => e.date !== date);
- }
- saveImportedData();
- window.buildSidebar();
- updateHeaderDates();
- // Re-open the detail modal to show updated values. buildSidebar
- // resets .active to Dashboard, so use state.currentView (kept in
- // sync by navigate) instead of re-reading the DOM.
- markerDetailDeps.navigate(state.currentView || "dashboard");
- showDetailModal(id);
- showNotification(`Removed value from ${date}`, 'info');
- }
-}
-
export async function deleteCustomMarker(id) {
const dotKey = id.replace('_', '.');
const catKey = dotKey.split('.')[0];
@@ -1145,115 +992,6 @@ export async function deleteCustomMarker(id) {
}
}
-export function editMarkerValue(id, date, currentValue, event) {
- const el = event.target.closest('.mv-value');
- if (!el || el.querySelector('input')) return;
- const input = document.createElement('input');
- input.type = 'number';
- input.step = 'any';
- input.value = currentValue;
- input.className = 'ref-edit-input';
- input.style.cssText = 'width:100%;max-width:140px;text-align:center;font-size:inherit;box-sizing:border-box;padding:2px 4px';
- el.textContent = '';
- el.appendChild(input);
- input.focus();
- input.select();
- let cancelled = false;
- let saveStarted = false;
- const save = async () => {
- if (cancelled) return;
- if (saveStarted) return;
- saveStarted = true;
- const newValue = parseFloat(input.value);
- if (isNaN(newValue)) { showDetailModal(id); return; }
- // No-op if the value didn't change — don't flip provenance to manual.
- if (newValue === parseFloat(currentValue)) { showDetailModal(id); return; }
- const dotKey = id.replace('_', '.');
- const entry = state.importedData.entries?.find(e => e.date === date);
- if (!entry) return;
- // Track as manually edited — store original value for revert (true = manual entry with no original)
- _rememberManualOriginal(dotKey, date, entry);
- const storedValue = convertDisplayToSI(dotKey, newValue);
- entry.markers[dotKey] = storedValue;
- // Update provenance to reflect manual edit
- if (!entry.markerSources) entry.markerSources = {};
- entry.markerSources[dotKey] = { file: null, at: Date.now() };
- if (dotKey === 'hormones.insulin') { entry.markers['diabetes.insulin_d'] = storedValue; if (entry.markerSources) entry.markerSources['diabetes.insulin_d'] = entry.markerSources[dotKey]; recalculateHOMAIR(entry); }
- await saveImportedData();
- // Rebuild the underlying view so Table/Heatmap/Chart reflect the edit.
- markerDetailDeps.navigate(state.currentView || 'dashboard');
- showDetailModal(id);
- };
- input.addEventListener('blur', () => { void save(); });
- input.addEventListener('keydown', e => {
- if (e.key === 'Enter') { e.preventDefault(); void save(); }
- else if (e.key === 'Escape') { cancelled = true; showDetailModal(id); }
- });
-}
-
-export async function revertMarkerValue(id, date) {
- const dotKey = id.replace('_', '.');
- const mvKey = dotKey + ':' + date;
- const original = state.importedData.manualValues?.[mvKey];
- if (original == null || original === true) return;
- const entry = state.importedData.entries?.find(e => e.date === date);
- if (!entry) return;
- entry.markers[dotKey] = original;
- if (entry.markerSources) delete entry.markerSources[dotKey];
- if (dotKey === 'hormones.insulin') {
- entry.markers['diabetes.insulin_d'] = original;
- if (entry.markerSources) delete entry.markerSources['diabetes.insulin_d'];
- delete state.importedData.manualValues['diabetes.insulin_d:' + date];
- recalculateHOMAIR(entry);
- }
- delete state.importedData.manualValues[mvKey];
- await saveImportedData();
- // Rebuild the underlying view so Table/Heatmap/Chart reflect the revert.
- markerDetailDeps.navigate(state.currentView || 'dashboard');
- showDetailModal(id);
-}
-
-export async function editValueNote(id, date) {
- if (!id || !date) return;
- const dotKey = id.replace('_', '.');
- const noteKey = dotKey + ':' + date;
- if (!state.importedData.markerValueNotes) state.importedData.markerValueNotes = {};
- const current = state.importedData.markerValueNotes[noteKey] || '';
- const result = await showPromptDialog(
- current ? `Edit note for ${date}` : `Add note for ${date}`,
- { defaultValue: current, placeholder: 'e.g. fasted 14h, post-workout, different lab', okLabel: 'Save' }
- );
- // showPromptDialog collapses cancel + empty-submit to null. Treat null as
- // "no change" — explicit deletion is via the dedicated × affordance.
- if (result === null) return;
- // Cap to match saveManualEntry — defends against runaway paste flowing
- // into IDB, sync payloads, and AI context.
- const capped = result.length > 500 ? result.slice(0, 500) : result;
- state.importedData.markerValueNotes[noteKey] = capped;
- // Mirror across the insulin dual-mapping in BOTH directions so a note
- // edited via diabetes.insulin_d also lands on hormones.insulin and vice
- // versa.
- const mirror = _insulinMirrorNoteKey(dotKey, date);
- if (mirror) state.importedData.markerValueNotes[mirror] = capped;
- saveImportedData();
- showDetailModal(id);
-}
-
-export async function deleteValueNote(id, date) {
- if (!id || !date) return;
- if (!await showConfirmDialog(`Remove the note for ${date}?`)) return;
- const dotKey = id.replace('_', '.');
- const noteKey = dotKey + ':' + date;
- if (state.importedData.markerValueNotes && state.importedData.markerValueNotes[noteKey]) {
- delete state.importedData.markerValueNotes[noteKey];
- // Mirror cleanup in BOTH directions across the insulin dual-mapping.
- const mirror = _insulinMirrorNoteKey(dotKey, date);
- if (mirror) delete state.importedData.markerValueNotes[mirror];
- saveImportedData();
- showDetailModal(id);
- }
-}
-
export function closeModal() {
document.getElementById("modal-overlay").classList.remove("show");
const detailModal = document.getElementById("detail-modal");
@@ -1269,157 +1007,3 @@ export function closeModal() {
state._activeDetailMarkerId = null;
restoreModalTrigger();
}
-
-
-// ═══════════════════════════════════════════════
-// EDITABLE REFERENCE RANGES
-// ═══════════════════════════════════════════════
-
-export function editRefRange(id, type, evt) {
- const marker = state.markerRegistry[id];
- if (!marker) return;
- const isOptimal = type === 'optimal';
- const curMin = isOptimal ? marker.optimalMin : marker.refMin;
- const curMax = isOptimal ? marker.optimalMax : marker.refMax;
- const label = isOptimal ? 'Optimal' : 'Reference';
-
- const span = evt.target.closest('.ref-editable');
- if (!span) return;
-
- // Replace span with inline inputs
- const form = document.createElement('span');
- form.className = 'ref-edit-form';
- form.innerHTML = `${escapeHTML(label)}: \u2013 `;
- span.replaceWith(form);
- form.querySelector('#ref-edit-min').focus();
-
- // Enter to save
- form.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); saveRefRange(id, type); } });
- // Escape to cancel
- form.addEventListener('keydown', e => { if (e.key === 'Escape') showDetailModal(id); });
-}
-
-export function saveRefRange(id, type) {
- const dotKey = id.replace('_', '.');
- const minEl = document.getElementById('ref-edit-min');
- const maxEl = document.getElementById('ref-edit-max');
- if (!minEl || !maxEl) return;
- let newMin = minEl.value.trim() !== '' ? parseFloat(minEl.value) : null;
- let newMax = maxEl.value.trim() !== '' ? parseFloat(maxEl.value) : null;
- // Treat NaN as null (open-ended)
- if (newMin != null && isNaN(newMin)) newMin = null;
- if (newMax != null && isNaN(newMax)) newMax = null;
-
- // If user is in US mode, convert back to SI for storage (overrides are applied before unit conversion)
- if (newMin != null) newMin = convertDisplayToSI(dotKey, newMin);
- if (newMax != null) newMax = convertDisplayToSI(dotKey, newMax);
-
- if (!state.importedData.refOverrides) state.importedData.refOverrides = {};
- if (!state.importedData.refOverrides[dotKey]) state.importedData.refOverrides[dotKey] = {};
-
- const ovr = state.importedData.refOverrides[dotKey];
- if (type === 'optimal') {
- // Stash lab values before first manual edit
- if (ovr.optimalSource !== 'manual' && ('optimalMin' in ovr) && !('labOptimalMin' in ovr)) {
- ovr.labOptimalMin = ovr.optimalMin;
- ovr.labOptimalMax = ovr.optimalMax;
- }
- ovr.optimalMin = newMin;
- ovr.optimalMax = newMax;
- ovr.optimalSource = 'manual';
- } else {
- if (ovr.refSource !== 'manual' && ('refMin' in ovr) && !('labRefMin' in ovr)) {
- ovr.labRefMin = ovr.refMin;
- ovr.labRefMax = ovr.refMax;
- }
- ovr.refMin = newMin;
- ovr.refMax = newMax;
- ovr.refSource = 'manual';
- }
-
- saveImportedData();
- // Refresh background view, then re-render modal with new ranges
- const activeNav = document.querySelector('.nav-item.active');
- markerDetailDeps.navigate(activeNav ? activeNav.dataset.category : 'dashboard');
- showDetailModal(id);
- showNotification('Range updated', 'info');
-}
-
-export function revertRefRange(id, type) {
- const dotKey = id.replace('_', '.');
- const ovr = state.importedData?.refOverrides?.[dotKey];
- if (!ovr) return;
- let msg = 'Range reverted to default';
- if (type === 'optimal') {
- if ('labOptimalMin' in ovr) {
- // Revert to imported lab range
- ovr.optimalMin = ovr.labOptimalMin;
- ovr.optimalMax = ovr.labOptimalMax;
- ovr.optimalSource = 'import';
- delete ovr.labOptimalMin; delete ovr.labOptimalMax;
- msg = 'Range reverted to lab range';
- } else {
- delete ovr.optimalMin; delete ovr.optimalMax; delete ovr.optimalSource;
- }
- } else {
- if ('labRefMin' in ovr) {
- ovr.refMin = ovr.labRefMin;
- ovr.refMax = ovr.labRefMax;
- ovr.refSource = 'import';
- delete ovr.labRefMin; delete ovr.labRefMax;
- msg = 'Range reverted to lab range';
- } else {
- delete ovr.refMin; delete ovr.refMax; delete ovr.refSource;
- }
- }
- // Clean up empty override objects
- if (Object.keys(ovr).length === 0) delete state.importedData.refOverrides[dotKey];
- saveImportedData();
- const activeNav = document.querySelector('.nav-item.active');
- markerDetailDeps.navigate(activeNav ? activeNav.dataset.category : 'dashboard');
- showDetailModal(id);
- showNotification(msg, 'info');
-}
-
-// ═══════════════════════════════════════════════
-// MARKER NOTES
-// ═══════════════════════════════════════════════
-
-export function toggleMarkerNoteEditor(dotKey) {
- const editor = document.getElementById('marker-note-editor');
- if (!editor) return;
- const isHidden = editor.style.display === 'none';
- editor.style.display = isHidden ? 'block' : 'none';
- if (isHidden) {
- const input = document.getElementById('marker-note-input');
- if (input) input.focus();
- }
-}
-
-export function saveMarkerNote(dotKey, id) {
- const input = document.getElementById('marker-note-input');
- const text = input?.value?.trim();
- if (!text) {
- // Empty text = delete the note
- if (state.importedData.markerNotes?.[dotKey]) {
- delete state.importedData.markerNotes[dotKey];
- saveImportedData();
- showNotification('Note removed', 'info');
- showDetailModal(id);
- }
- return;
- }
- if (!state.importedData.markerNotes) state.importedData.markerNotes = {};
- state.importedData.markerNotes[dotKey] = text;
- saveImportedData();
- showNotification('Note saved', 'success');
- showDetailModal(id);
-}
-
-export function deleteMarkerNote(dotKey, id) {
- if (!state.importedData.markerNotes) return;
- delete state.importedData.markerNotes[dotKey];
- saveImportedData();
- showNotification('Note removed', 'info');
- showDetailModal(id);
-}
diff --git a/service-worker.js b/service-worker.js
index a5ad9187..54102b86 100755
--- a/service-worker.js
+++ b/service-worker.js
@@ -173,6 +173,7 @@ const APP_SHELL = [
'/js/category-customization.js',
'/js/commit-hash.js',
'/js/marker-detail-modal.js',
+ '/js/marker-detail-editing.js',
'/js/light-conditions-now.js',
'/js/light-page-view.js',
'/js/light-channel-view.js',
diff --git a/tests/test-audit.js b/tests/test-audit.js
index aacae719..603d7b16 100644
--- a/tests/test-audit.js
+++ b/tests/test-audit.js
@@ -299,7 +299,7 @@ const _SAFE_HELPERS = new Set([
// is the markdown.js sanitized full renderer)
'escapeHTML', 'renderMarkdown',
]);
-const _SWEEP_FILES = ['views.js', 'dashboard-page-view.js', 'category-page-view.js', 'category-view-renderers.js', 'category-customization.js', 'focus-card.js', 'marker-detail-modal.js', 'dashboard-widget-renderers.js', 'light-conditions-now.js', 'light-page-view.js', 'light-channel-view.js', 'light-sessions-view.js', 'light-device-setup-modal.js', 'sun-session-ui.js', 'compare-correlations.js', 'mobile-dashboard.js', 'context-card-editor-ui.js', 'context-card-medical-history-editor.js', 'chat.js', 'charts.js'];
+const _SWEEP_FILES = ['views.js', 'dashboard-page-view.js', 'category-page-view.js', 'category-view-renderers.js', 'category-customization.js', 'focus-card.js', 'marker-detail-modal.js', 'marker-detail-editing.js', 'dashboard-widget-renderers.js', 'light-conditions-now.js', 'light-page-view.js', 'light-channel-view.js', 'light-sessions-view.js', 'light-device-setup-modal.js', 'sun-session-ui.js', 'compare-correlations.js', 'mobile-dashboard.js', 'context-card-editor-ui.js', 'context-card-medical-history-editor.js', 'chat.js', 'charts.js'];
function _sweepInnerHTML(filename, src) {
const lines = src.split('\n');
diff --git a/tests/test-manual-entry-flow.js b/tests/test-manual-entry-flow.js
index e8a7490a..f53e953a 100644
--- a/tests/test-manual-entry-flow.js
+++ b/tests/test-manual-entry-flow.js
@@ -21,6 +21,7 @@ console.log('=== Manual Entry Flow Tests ===\n');
const viewsSrc = read('js/views.js');
const markerDetailSrc = read('js/marker-detail-modal.js');
+ const markerDetailEditingSrc = read('js/marker-detail-editing.js');
// ═══════════════════════════════════════
// 1. saveManualEntry is async (we await dialogs)
@@ -28,9 +29,9 @@ console.log('=== Manual Entry Flow Tests ===\n');
console.log('%c 1. Async save flow ', 'font-weight:bold;color:#f59e0b');
assert('saveManualEntry signature is async with opts arg',
- /export async function saveManualEntry\(id, opts = \{\}\)/.test(markerDetailSrc));
+ /export async function saveManualEntry\(id, opts = \{\}\)/.test(markerDetailEditingSrc));
assert('saveAndAddAnotherManualEntry wraps saveManualEntry with keepOpen: true',
- /saveAndAddAnotherManualEntry\(id\)[\s\S]{0,200}saveManualEntry\(id, \{ keepOpen: true \}\)/.test(markerDetailSrc));
+ /saveAndAddAnotherManualEntry\(id\)[\s\S]{0,200}saveManualEntry\(id, \{ keepOpen: true \}\)/.test(markerDetailEditingSrc));
assert('saveAndAddAnotherManualEntry bound to window',
viewsSrc.includes('saveAndAddAnotherManualEntry,'));
@@ -43,21 +44,21 @@ console.log('=== Manual Entry Flow Tests ===\n');
// feature (which introduces checkRefMax/checkRefMin to shadow with alt-unit
// ranges) doesn't break pattern pins. Intent is unchanged: a > 10x guard.
assert('Sanity check triggers when value > refMax * 10',
- /value > \w*[Rr]ef[Mm]ax \* 10/.test(markerDetailSrc));
+ /value > \w*[Rr]ef[Mm]ax \* 10/.test(markerDetailEditingSrc));
// Greptile P2: without `> 0` guard, `refMax === 0` makes the multiplication
// zero and every positive value triggers the warning.
assert('Sanity check is guarded against refMax === 0 (no spurious warn)',
- /(\w*[Rr]ef[Mm]ax) != null && \1 > 0 && value > \1 \* 10/.test(markerDetailSrc));
+ /(\w*[Rr]ef[Mm]ax) != null && \1 > 0 && value > \1 \* 10/.test(markerDetailEditingSrc));
assert('Sanity check triggers when value < refMin / 10 (and refMin > 0)',
- /(\w*[Rr]ef[Mm]in) > 0 && value < \1 \/ 10/.test(markerDetailSrc));
+ /(\w*[Rr]ef[Mm]in) > 0 && value < \1 \/ 10/.test(markerDetailEditingSrc));
assert('Sanity check rejects negative values',
- /value < 0\)\s*warn\s*=/.test(markerDetailSrc) || /if \(value < 0\)/.test(markerDetailSrc));
+ /value < 0\)\s*warn\s*=/.test(markerDetailEditingSrc) || /if \(value < 0\)/.test(markerDetailEditingSrc));
assert('Sanity-warn message mentions unit confusion',
- /Did you enter the right unit\?/.test(markerDetailSrc));
+ /Did you enter the right unit\?/.test(markerDetailEditingSrc));
assert('Sanity check awaits showConfirmDialog and bails on cancel',
- /if \(warn && !await showConfirmDialog\(`\$\{warn\}/.test(markerDetailSrc));
+ /if \(warn && !await showConfirmDialog\(`\$\{warn\}/.test(markerDetailEditingSrc));
assert('Sanity check uses marker.refMin/refMax (with optional alt-unit overlay)',
- /marker\.refMin[\s\S]{0,200}marker\.refMax/.test(markerDetailSrc));
+ /marker\.refMin[\s\S]{0,200}marker\.refMax/.test(markerDetailEditingSrc));
// ═══════════════════════════════════════
// 3. Duplicate-date confirm
@@ -65,15 +66,15 @@ console.log('=== Manual Entry Flow Tests ===\n');
console.log('%c 3. Duplicate-date confirm ', 'font-weight:bold;color:#f59e0b');
assert('Duplicate check inspects existing entry for the chosen date',
- /existingEntry\s*=\s*state\.importedData\.entries\?\.find\(e => e\.date === date\)/.test(markerDetailSrc));
+ /existingEntry\s*=\s*state\.importedData\.entries\?\.find\(e => e\.date === date\)/.test(markerDetailEditingSrc));
assert('Confirm dialog message includes existing value + unit + date',
- /already exists for \$\{date\}\. Overwrite\?/.test(markerDetailSrc));
+ /already exists for \$\{date\}\. Overwrite\?/.test(markerDetailEditingSrc));
assert('Duplicate check uses display-unit value (marker.values[dateIdx]) not raw SI',
- /const dateIdx = data\.dates\.indexOf\(date\)[\s\S]{0,300}marker\.values\[dateIdx\]/.test(markerDetailSrc));
+ /const dateIdx = data\.dates\.indexOf\(date\)[\s\S]{0,300}marker\.values\[dateIdx\]/.test(markerDetailEditingSrc));
assert('Manual overwrite remembers imported original for revert',
- /function _rememberManualOriginal\(dotKey, date, entry\)/.test(markerDetailSrc) &&
- /state\.importedData\.manualValues\[mvKey\] = hasImportedOriginal \? current : true/.test(markerDetailSrc) &&
- /saveManualEntry[\s\S]{0,3500}_rememberManualOriginal\(dotKey, date, entry\)/.test(markerDetailSrc));
+ /function _rememberManualOriginal\(dotKey, date, entry\)/.test(markerDetailEditingSrc) &&
+ /state\.importedData\.manualValues\[mvKey\] = hasImportedOriginal \? current : true/.test(markerDetailEditingSrc) &&
+ /saveManualEntry[\s\S]{0,3500}_rememberManualOriginal\(dotKey, date, entry\)/.test(markerDetailEditingSrc));
assert('Clickable manual badge reverts to imported value when original exists',
/manual \\u00d7/.test(markerDetailSrc) &&
/Revert manual value to imported value/.test(markerDetailSrc) &&
@@ -85,9 +86,9 @@ console.log('=== Manual Entry Flow Tests ===\n');
console.log('%c 4. Save & Add Another ', 'font-weight:bold;color:#f59e0b');
assert('keepOpen branch re-opens the manual-entry form with same id + date',
- /if \(keepOpen\)\s*\{[\s\S]{0,300}openManualEntryForm\(id, date\)/.test(markerDetailSrc));
+ /if \(keepOpen\)\s*\{[\s\S]{0,300}openManualEntryForm\(id, date\)/.test(markerDetailEditingSrc));
assert('keepOpen branch still navigate()s to refresh the underlying page',
- /if \(keepOpen\)\s*\{[\s\S]{0,200}markerDetailDeps\.navigate\(navCat\)/.test(markerDetailSrc));
+ /if \(keepOpen\)\s*\{[\s\S]{0,200}markerDetailDeps\.navigate\(navCat\)/.test(markerDetailEditingSrc));
assert('Save & Add Another button rendered in form actions',
/Save\s*&\s*Add Another|Save & Add Another/.test(markerDetailSrc));
assert('Save & Add Another button onclick calls saveAndAddAnotherManualEntry',
@@ -99,9 +100,9 @@ console.log('=== Manual Entry Flow Tests ===\n');
console.log('%c 5. Session last-date ', 'font-weight:bold;color:#f59e0b');
assert('saveManualEntry writes the chosen date to sessionStorage',
- /sessionStorage\.setItem\('labcharts-last-manual-date', date\)/.test(markerDetailSrc));
+ /sessionStorage\.setItem\('labcharts-last-manual-date', date\)/.test(markerDetailEditingSrc));
assert('Write wrapped in try/catch (private-mode browsers)',
- /try \{ sessionStorage\.setItem\('labcharts-last-manual-date'/.test(markerDetailSrc));
+ /try \{ sessionStorage\.setItem\('labcharts-last-manual-date'/.test(markerDetailEditingSrc));
assert('openManualEntryForm reads sessionLast and validates the format',
/sessionStorage\.getItem\('labcharts-last-manual-date'\)/.test(markerDetailSrc) &&
/\/\^\\d\{4\}-\\d\{2\}-\\d\{2\}\$\/\.test\(raw\)/.test(markerDetailSrc));
@@ -124,27 +125,27 @@ console.log('=== Manual Entry Flow Tests ===\n');
console.log('%c 6. Inline edit cancel + no-change ', 'font-weight:bold;color:#f59e0b');
assert('editMarkerValue declares a cancelled flag',
- /let cancelled = false/.test(markerDetailSrc) ||
- /editMarkerValue[\s\S]{0,1500}cancelled\s*=\s*false/.test(markerDetailSrc));
+ /let cancelled = false/.test(markerDetailEditingSrc) ||
+ /editMarkerValue[\s\S]{0,1500}cancelled\s*=\s*false/.test(markerDetailEditingSrc));
assert('save() short-circuits when cancelled is true',
- /const save = async \(\) => \{[\s\S]{0,200}if \(cancelled\) return/.test(markerDetailSrc));
+ /const save = async \(\) => \{[\s\S]{0,200}if \(cancelled\) return/.test(markerDetailEditingSrc));
assert('Escape handler sets cancelled = true before re-rendering',
- /else if \(e\.key === 'Escape'\) \{ cancelled = true; showDetailModal/.test(markerDetailSrc));
+ /else if \(e\.key === 'Escape'\) \{ cancelled = true; showDetailModal/.test(markerDetailEditingSrc));
assert("No-change save short-circuits (no manual flip on a same-value edit)",
- /newValue === parseFloat\(currentValue\)\)/.test(markerDetailSrc));
+ /newValue === parseFloat\(currentValue\)\)/.test(markerDetailEditingSrc));
assert('Enter saves inline edits directly instead of relying on blur',
- /if \(e\.key === 'Enter'\) \{ e\.preventDefault\(\); void save\(\); \}/.test(markerDetailSrc));
+ /if \(e\.key === 'Enter'\) \{ e\.preventDefault\(\); void save\(\); \}/.test(markerDetailEditingSrc));
assert('Inline edit guards against double saves from Enter + blur',
- /let saveStarted = false/.test(markerDetailSrc) &&
- /if \(saveStarted\) return;[\s\S]{0,80}saveStarted = true/.test(markerDetailSrc));
+ /let saveStarted = false/.test(markerDetailEditingSrc) &&
+ /if \(saveStarted\) return;[\s\S]{0,80}saveStarted = true/.test(markerDetailEditingSrc));
assert('Inline edit awaits persistence before refreshing the modal',
- /await saveImportedData\(\);[\s\S]{0,160}markerDetailDeps\.navigate/.test(markerDetailSrc));
+ /await saveImportedData\(\);[\s\S]{0,160}markerDetailDeps\.navigate/.test(markerDetailEditingSrc));
assert('revertMarkerValue awaits persistence before refreshing the modal',
- /export async function revertMarkerValue\(id, date\)[\s\S]{0,900}await saveImportedData\(\);[\s\S]{0,160}markerDetailDeps\.navigate/.test(markerDetailSrc));
+ /export async function revertMarkerValue\(id, date\)[\s\S]{0,900}await saveImportedData\(\);[\s\S]{0,160}markerDetailDeps\.navigate/.test(markerDetailEditingSrc));
assert('editMarkerValue calls injected navigate() to rebuild Table/Heatmap after save',
- /editMarkerValue[\s\S]{0,2500}markerDetailDeps\.navigate\(state\.currentView \|\| 'dashboard'\)/.test(markerDetailSrc));
+ /editMarkerValue[\s\S]{0,2500}markerDetailDeps\.navigate\(state\.currentView \|\| 'dashboard'\)/.test(markerDetailEditingSrc));
assert('revertMarkerValue also calls injected navigate() to rebuild the underlying view',
- /revertMarkerValue[\s\S]{0,1200}markerDetailDeps\.navigate\(state\.currentView \|\| 'dashboard'\)/.test(markerDetailSrc));
+ /revertMarkerValue[\s\S]{0,1200}markerDetailDeps\.navigate\(state\.currentView \|\| 'dashboard'\)/.test(markerDetailEditingSrc));
// ═══════════════════════════════════════
// 7. Input width fix
@@ -152,9 +153,9 @@ console.log('=== Manual Entry Flow Tests ===\n');
console.log('%c 7. Input width fix ', 'font-weight:bold;color:#f59e0b');
assert('Edit input uses width:100% with max-width:140px (replaces width:80px)',
- /editMarkerValue[\s\S]{0,1500}width:100%;max-width:140px/.test(markerDetailSrc));
+ /editMarkerValue[\s\S]{0,1500}width:100%;max-width:140px/.test(markerDetailEditingSrc));
assert('Old width:80px input style removed from editMarkerValue',
- !/editMarkerValue[\s\S]{0,1500}width:80px/.test(markerDetailSrc));
+ !/editMarkerValue[\s\S]{0,1500}width:80px/.test(markerDetailEditingSrc));
// ═══════════════════════════════════════
// 8. Add Value Manually placement (above Note) + rename
diff --git a/tests/test-marker-value-notes.js b/tests/test-marker-value-notes.js
index 38ff6e7b..636e35ef 100644
--- a/tests/test-marker-value-notes.js
+++ b/tests/test-marker-value-notes.js
@@ -91,12 +91,13 @@ const state = (await import('../js/state.js')).state;
const viewsSrc = read('js/views.js');
const categoryViewRenderersSrc = read('js/category-view-renderers.js');
const markerDetailSrc = read('js/marker-detail-modal.js');
+ const markerDetailEditingSrc = read('js/marker-detail-editing.js');
assert('saveManualEntry reads me-note from the form',
- /const\s+noteInput\s*=\s*document\.getElementById\('me-note'\)/.test(markerDetailSrc));
+ /const\s+noteInput\s*=\s*document\.getElementById\('me-note'\)/.test(markerDetailEditingSrc));
assert('saveManualEntry stores noteText in markerValueNotes when non-empty',
- /if \(noteText\) state\.importedData\.markerValueNotes\[noteKey\] = noteText/.test(markerDetailSrc));
+ /if \(noteText\) state\.importedData\.markerValueNotes\[noteKey\] = noteText/.test(markerDetailEditingSrc));
assert('saveManualEntry clears the entry when noteText is empty (idempotent edit-to-blank)',
- /else delete state\.importedData\.markerValueNotes\[noteKey\]/.test(markerDetailSrc));
+ /else delete state\.importedData\.markerValueNotes\[noteKey\]/.test(markerDetailEditingSrc));
assert('manual-entry form HTML includes the me-note textarea',
markerDetailSrc.includes('id="me-note"') && /placeholder=".*fasted/i.test(markerDetailSrc));
@@ -106,17 +107,17 @@ const state = (await import('../js/state.js')).state;
console.log('%c 5. Value-note CRUD handlers ', 'font-weight:bold;color:#f59e0b');
assert('editValueNote handler exported',
- /export async function editValueNote\(id, date\)/.test(markerDetailSrc));
+ /export async function editValueNote\(id, date\)/.test(markerDetailEditingSrc));
assert('deleteValueNote handler exported',
- /export async function deleteValueNote\(id, date\)/.test(markerDetailSrc));
+ /export async function deleteValueNote\(id, date\)/.test(markerDetailEditingSrc));
assert('editValueNote bound to window for inline onclicks',
/editValueNote,\s*$/m.test(viewsSrc) || viewsSrc.includes('editValueNote,'));
assert('deleteValueNote bound to window for inline onclicks',
viewsSrc.includes('deleteValueNote,'));
assert('editValueNote re-renders the detail modal on save',
- /editValueNote[\s\S]{0,1500}showDetailModal\(id\)/.test(markerDetailSrc));
+ /editValueNote[\s\S]{0,1500}showDetailModal\(id\)/.test(markerDetailEditingSrc));
assert('deleteValueNote confirms before removing',
- /deleteValueNote[\s\S]{0,400}showConfirmDialog\(/.test(markerDetailSrc));
+ /deleteValueNote[\s\S]{0,400}showConfirmDialog\(/.test(markerDetailEditingSrc));
// Direct state manipulation — verify the data model is what render code expects.
state.importedData = state.importedData || {};
@@ -133,7 +134,7 @@ const state = (await import('../js/state.js')).state;
console.log('%c 6. Orphan cleanup ', 'font-weight:bold;color:#f59e0b');
assert('deleteMarkerValue drops the per-value note for the same (date, marker)',
- /deleteMarkerValue[\s\S]{0,2000}delete state\.importedData\.markerValueNotes\[dotKey \+ ':' \+ date\]/.test(markerDetailSrc));
+ /deleteMarkerValue[\s\S]{0,2000}delete state\.importedData\.markerValueNotes\[dotKey \+ ':' \+ date\]/.test(markerDetailEditingSrc));
// Insulin dual-mapping parity: the value mirrors hormones.insulin ↔
// diabetes.insulin_d, so the per-value note must mirror too. Bidirectional
@@ -142,27 +143,27 @@ const state = (await import('../js/state.js')).state;
// 500-char cap defends against runaway paste (matches the wearable note cap).
assert('saveManualEntry caps the note at 500 chars before storing',
- /noteRaw\.length > 500 \? noteRaw\.slice\(0, 500\) : noteRaw/.test(markerDetailSrc));
+ /noteRaw\.length > 500 \? noteRaw\.slice\(0, 500\) : noteRaw/.test(markerDetailEditingSrc));
assert('editValueNote caps the note at 500 chars before storing',
- /editValueNote[\s\S]{0,1200}result\.length > 500 \? result\.slice\(0, 500\) : result/.test(markerDetailSrc));
+ /editValueNote[\s\S]{0,1200}result\.length > 500 \? result\.slice\(0, 500\) : result/.test(markerDetailEditingSrc));
// editValueNote + deleteValueNote also route through _insulinMirrorNoteKey
// — see the bidirectional helper asserts below.
assert('deleteValueNote cleans the mirror note for insulin',
- /deleteValueNote[\s\S]{0,800}_insulinMirrorNoteKey\(dotKey, date\)/.test(markerDetailSrc));
+ /deleteValueNote[\s\S]{0,800}_insulinMirrorNoteKey\(dotKey, date\)/.test(markerDetailEditingSrc));
// Greptile P1: insulin note mirror must be BIDIRECTIONAL — user may
// edit/delete via the hormones panel OR the diabetes panel.
assert('_insulinMirrorNoteKey helper defined and bidirectional',
- /_insulinMirrorNoteKey\(dotKey, date\)/.test(markerDetailSrc) &&
- /if \(dotKey === 'hormones\.insulin'\) return 'diabetes\.insulin_d:' \+ date/.test(markerDetailSrc) &&
- /if \(dotKey === 'diabetes\.insulin_d'\) return 'hormones\.insulin:' \+ date/.test(markerDetailSrc));
+ /_insulinMirrorNoteKey\(dotKey, date\)/.test(markerDetailEditingSrc) &&
+ /if \(dotKey === 'hormones\.insulin'\) return 'diabetes\.insulin_d:' \+ date/.test(markerDetailEditingSrc) &&
+ /if \(dotKey === 'diabetes\.insulin_d'\) return 'hormones\.insulin:' \+ date/.test(markerDetailEditingSrc));
assert('saveManualEntry uses bidirectional mirror helper',
- /saveManualEntry[\s\S]{0,2500}_insulinMirrorNoteKey\(dotKey, date\)/.test(markerDetailSrc));
+ /saveManualEntry[\s\S]{0,2500}_insulinMirrorNoteKey\(dotKey, date\)/.test(markerDetailEditingSrc));
assert('deleteMarkerValue uses bidirectional mirror helper',
- /deleteMarkerValue[\s\S]{0,2500}_insulinMirrorNoteKey\(dotKey, date\)/.test(markerDetailSrc));
+ /deleteMarkerValue[\s\S]{0,2500}_insulinMirrorNoteKey\(dotKey, date\)/.test(markerDetailEditingSrc));
assert('editValueNote uses bidirectional mirror helper',
- /editValueNote[\s\S]{0,1500}_insulinMirrorNoteKey\(dotKey, date\)/.test(markerDetailSrc));
+ /editValueNote[\s\S]{0,1500}_insulinMirrorNoteKey\(dotKey, date\)/.test(markerDetailEditingSrc));
// CodeQL js/xss-through-dom: empty-cell onclick must use JSON.stringify
// so interpolated id/date survive the HTML-attr → JS-string round-trip.
@@ -181,7 +182,7 @@ const state = (await import('../js/state.js')).state;
assert('Populated card has × delete button',
/mv-value-note-delete[\s\S]{0,400}deleteValueNote\('/.test(markerDetailSrc));
assert('Inline onclicks stopPropagation so cell-edit doesn\'t fire',
- /editValueNote[\s\S]{0,200}event\.stopPropagation\(\)/.test(markerDetailSrc));
+ /event\.stopPropagation\(\);editValueNote\('/.test(markerDetailSrc));
// ═══════════════════════════════════════
// 8. AI context emission — section:markerValueNotes
diff --git a/tests/test-multi-unit.js b/tests/test-multi-unit.js
index 68f90930..df304a34 100644
--- a/tests/test-multi-unit.js
+++ b/tests/test-multi-unit.js
@@ -220,6 +220,7 @@ console.log('\n-- source-shape pins (UI wiring) --');
{
const fs = await import('node:fs');
const markerDetail = fs.readFileSync(new URL('../js/marker-detail-modal.js', import.meta.url), 'utf8');
+ const markerDetailEditing = fs.readFileSync(new URL('../js/marker-detail-editing.js', import.meta.url), 'utf8');
const settings = fs.readFileSync(new URL('../js/settings.js', import.meta.url), 'utf8');
const data = fs.readFileSync(new URL('../js/data.js', import.meta.url), 'utf8');
const state = fs.readFileSync(new URL('../js/state.js', import.meta.url), 'utf8');
@@ -234,8 +235,8 @@ console.log('\n-- source-shape pins (UI wiring) --');
assert('marker-detail-modal.js manual-entry form renders #me-unit select when conversion exists',
/