diff --git a/js/marker-detail-editing.js b/js/marker-detail-editing.js new file mode 100644 index 00000000..ffcc0574 --- /dev/null +++ b/js/marker-detail-editing.js @@ -0,0 +1,476 @@ +// 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']; + if (state.importedData.manualValues) delete state.importedData.manualValues['diabetes.insulin_d:' + date]; + 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..b6ca1a73 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,9 @@ 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)); + assert('deleteMarkerValue drops mirrored insulin manualValues state', + /deleteMarkerValue[\s\S]{0,1200}delete state\.importedData\.manualValues\['diabetes\.insulin_d:' \+ 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 +145,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 +184,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', /