From c30897ba0a7e573d65c8d85fbca91ae6721b6743 Mon Sep 17 00:00:00 2001 From: elkimek <36666630+elkimek@users.noreply.github.com> Date: Sat, 30 May 2026 18:29:04 +0200 Subject: [PATCH 1/2] Extract light environment model --- js/light-env-model.js | 298 ++++++++++++++++++++++++++ js/light-env.js | 346 ++++--------------------------- service-worker.js | 1 + tests/test-correctness-phase2.js | 1 + tests/test-light-env.js | 49 +++++ version.js | 2 +- 6 files changed, 387 insertions(+), 310 deletions(-) create mode 100644 js/light-env-model.js diff --git a/js/light-env-model.js b/js/light-env-model.js new file mode 100644 index 00000000..2b54a4ad --- /dev/null +++ b/js/light-env-model.js @@ -0,0 +1,298 @@ +// light-env-model.js — deterministic Light Environment scoring and picker model. +// +// Keep this module free of app state and persistence. light-env.js owns storage, +// "today" overrides, and rendering; this file owns canonical option lists and +// pure scoring math so tests and future AI/context code can use one source. + +import { + getRoomEveningHoursAfterSunset, + hasRoomEveningAnswer, + roomUsesEveningAfterSunset, +} from './light-env-evening.js'; + +export const PRIMARY_SOURCES = [ + { key: 'led-cool', label: 'LED — cool/daylight (4000K+)' }, + { key: 'led-warm', label: 'LED — warm white (2700–3000K)' }, + { key: 'led-tunable', label: 'LED — tunable / colour-changing' }, + { key: 'fluorescent', label: 'Fluorescent / CFL' }, + { key: 'incandescent', label: 'Incandescent (filament)' }, + { key: 'halogen', label: 'Halogen' }, + { key: 'candle', label: 'Candle / firelight' }, + { key: 'mixed', label: 'Mixed (multiple sources)' }, + { key: 'natural-only', label: 'Daylight only (no artificial)' }, + { key: 'unknown', label: "I don't know" }, +]; + +export const SCREEN_DEVICES = [ + { key: 'phone', label: 'Phone' }, + { key: 'laptop', label: 'Laptop' }, + { key: 'monitor', label: 'External monitor' }, + { key: 'tablet', label: 'Tablet' }, + { key: 'tv', label: 'TV' }, +]; + +// 4 archetypes the user can pick from a glance, mapped to canonical +// schema values. Power users hit "More options…" to drill down into +// the 10-option dropdown. +export const SOURCE_ARCHETYPES = [ + { key: 'warm', emoji: '🌅', label: 'Warm yellow', storeAs: 'led-warm', matches: ['led-warm', 'incandescent', 'halogen', 'candle'] }, + { key: 'cool', emoji: '☀️', label: 'Cool white', storeAs: 'led-cool', matches: ['led-cool', 'led-tunable'] }, + { key: 'fluorescent', emoji: '🌫️', label: 'Fluorescent tube', storeAs: 'fluorescent', matches: ['fluorescent'] }, + { key: 'mixed', emoji: '❓', label: 'Mixed / unsure', storeAs: 'mixed', matches: ['mixed', 'unknown'] }, +]; + +export function activeSourceArchetype(primarySource) { + if (!primarySource) return null; + for (const a of SOURCE_ARCHETYPES) { + if (a.matches.includes(primarySource)) return a.key; + } + return null; // covers natural-only — power-user-only +} + +// Hours buckets — store the bucket midpoint so downstream tiering math +// (currently "≥ 2 hr / ≥ 4 hr" thresholds) keeps working unchanged. +export const HOURS_BUCKETS = [ + { key: 'short', label: '< 1 hr', min: 0, max: 1, midpoint: 0.5 }, + { key: 'some', label: '1–3 hr', min: 1, max: 3, midpoint: 2 }, + { key: 'lots', label: '3–6 hr', min: 3, max: 6, midpoint: 4.5 }, + { key: 'most', label: '6+ hr', min: 6, max: 24, midpoint: 8 }, +]; + +export function activeHoursBucket(hours) { + if (hours == null || hours === '' || isNaN(+hours)) return null; + const h = +hours; + for (const b of HOURS_BUCKETS) { + if (h >= b.min && h < b.max) return b.key; + } + return 'most'; +} + +// Evening-hours buckets. Stored as numeric `eveningHoursAfterSunset`; +// legacy boolean rows are normalized before rendering. +export const EVENING_BUCKETS = [ + { key: 'none', label: 'None', midpoint: 0 }, + { key: 'lt1', label: '< 1 hr', midpoint: 0.5 }, + { key: 'mid', label: '1–3 hr', midpoint: 2 }, + { key: 'gt3', label: '3+ hr', midpoint: 4 }, +]; + +export function activeEveningBucket(room) { + if (!hasRoomEveningAnswer(room)) return null; + const h = getRoomEveningHoursAfterSunset(room); + if (h <= 0) return 'none'; + if (h < 1) return 'lt1'; + if (h < 3) return 'mid'; + return 'gt3'; +} + +// Default occupancy hours seeded by room name on first add. User can +// adjust immediately via the chip row — this just keeps them out of +// the lonely-empty-number-field cold start. +export function defaultHoursForName(name) { + const n = (name || '').toLowerCase(); + if (/bedroom|sleep/.test(n)) return 8; + if (/office|study|work/.test(n)) return 8; + if (/living|family|den|lounge/.test(n)) return 4; + if (/kitchen/.test(n)) return 2; + if (/bath/.test(n)) return 1; + return 4; +} + +// True when the room has nothing graders can use — no source picked +// (or "I don't know"), no occupancy answer, no measurements, no +// evening-hours answer. The severity helper returns an "incomplete" +// gray-dot in that case so users don't read the default green dot +// as "we verified you're good" when really it means "we know nothing +// about this room yet." +function _hasAnyRoomSignal(room, measurements) { + if (!room) return false; + const hasSource = room.primarySource && room.primarySource !== 'unknown'; + const hasHours = (+room.hoursOccupiedPerDay) > 0; + const hasEvening = hasRoomEveningAnswer(room); + const hasMeas = (measurements || []).length > 0; + return hasSource || hasHours || hasEvening || hasMeas; +} + +export function computeRoomSeverity(room, measurements = [], options = {}) { + if (!room) return { tier: 0, color: 'green', label: 'Unknown', reason: 'No data yet' }; + + // Gray-dot incomplete state for empty rooms — distinct from "Good". + if (!_hasAnyRoomSignal(room, measurements)) { + return { tier: 0, color: 'incomplete', label: 'Needs setup', reason: 'No signals yet — pick a light source, hours, or run a measurement.' }; + } + + let tier = 0; + const reasons = []; + + // Source-based bias + const src = room.primarySource; + if (src === 'fluorescent') { + tier = Math.max(tier, 2); + reasons.push('fluorescent / CFL primary'); + } else if (src === 'led-cool' || src === 'led-tunable') { + tier = Math.max(tier, 1); + reasons.push('cool LED primary'); + } else if (src === 'natural-only' || src === 'incandescent' || src === 'halogen' || src === 'candle') { + // friendly sources stay at 0 unless other signals pull them up + } + + // After-sunset blue-light contamination. + if (roomUsesEveningAfterSunset(room) && (src === 'led-cool' || src === 'led-tunable' || src === 'fluorescent')) { + tier = Math.max(tier, 2); + reasons.push('blue light after sunset'); + } + + // Latest flicker measurement (use most recent — flicker doesn't decay) + const flickers = measurements.filter(m => m.tool === 'flicker').sort((a, b) => b.capturedAt - a.capturedAt); + if (flickers.length) { + const score = flickers[0].value; + // saveMeasurement stores 0–3 for { Pristine, Mild, Moderate, Severe } + if (score >= 3) { tier = Math.max(tier, 4); reasons.push('severe flicker measured'); } + else if (score >= 2) { tier = Math.max(tier, 3); reasons.push('moderate flicker measured'); } + else if (score >= 1) { tier = Math.max(tier, 1); reasons.push('mild flicker measured'); } + } + + // Daytime lux (low → yellow). Treat any reading < 100 lux as low-indoor. + const luxes = measurements.filter(m => m.tool === 'lux').sort((a, b) => b.capturedAt - a.capturedAt); + if (luxes.length) { + const lux = luxes[0].value; + if (lux < 50 && (room.hoursOccupiedPerDay || 0) >= 2) { + tier = Math.max(tier, 2); + reasons.push('very low daytime lux for hours occupied'); + } else if (lux < 200 && (room.hoursOccupiedPerDay || 0) >= 4) { + tier = Math.max(tier, 1); + reasons.push('lower than office-bright for prolonged hours'); + } + } + + // Bedroom-specific: any sleep-darkness reading tells a story + const dark = measurements.filter(m => m.tool === 'darkness').sort((a, b) => b.capturedAt - a.capturedAt); + if (dark.length && /bedroom|sleep/i.test(room.name || '')) { + const lux = dark[0].value; + if (lux > 1) { tier = Math.max(tier, 3); reasons.push('bedroom not dark enough for melatonin'); } + else if (lux > 0.1) { tier = Math.max(tier, 2); reasons.push('measurable light leak in bedroom'); } + } + + // Screens-in-this-room contribution: heavy evening blue exposure from + // a screen in this room rolls into the room's severity. Compounds + // multiplicatively with after-sunset use of cool-LED room lighting — + // a bedroom with cool LED + a phone for 3 evening hours is worse + // than either signal alone. Screens skipped today don't count. + const isActiveToday = options.isActiveToday || (() => true); + const screensHere = (options.screens || []).filter(s => s && isActiveToday(s)); + let unblockedEveHours = 0; + for (const s of screensHere) { + if (!s.blueBlockerEnabled && (s.eveningUseAfterSunset || 0) > 0) { + unblockedEveHours += s.eveningUseAfterSunset; + } + } + if (unblockedEveHours >= 3) { + tier = Math.max(tier, 3); + reasons.push(`${unblockedEveHours.toFixed(1)} hr/day evening screen exposure here`); + } else if (unblockedEveHours >= 1) { + tier = Math.max(tier, 2); + reasons.push(`${unblockedEveHours.toFixed(1)} hr/day evening screen exposure here`); + } + + const colorMap = ['green', 'yellow', 'orange', 'red', 'red']; + const labelMap = ['Sleep-friendly', 'Mild', 'Moderate', 'Concerning', 'Severe']; + return { + tier, + color: colorMap[Math.min(tier, 4)], + label: labelMap[Math.min(tier, 4)], + reason: reasons.length ? reasons.join(' · ') : 'No issues detected', + }; +} + +// Evening blue exposure is the dominant junk-light vector for screens. +// Blocking the blue end (via blue-blocker glasses, software like +// f.lux/Night Shift, or amber-tinted filters) effectively zeroes the +// circadian penalty even at long evening hours. Without that, exposure +// scales with hours after sunset. +export function computeScreenStatus(screen) { + if (!screen) return { tier: 0, color: 'green', label: 'Unknown', reason: 'no data' }; + const eveHours = screen.eveningUseAfterSunset || 0; + const blocker = !!screen.blueBlockerEnabled; + if (blocker) return { tier: 0, color: 'green', label: 'Mitigated', reason: 'blue blocker enabled' }; + if (eveHours <= 0) return { tier: 0, color: 'green', label: 'Daytime only', reason: 'no evening exposure' }; + if (eveHours < 1) return { tier: 1, color: 'yellow', label: 'Mild', reason: '< 1 evening hour' }; + if (eveHours < 3) return { tier: 2, color: 'orange', label: 'Moderate', reason: `${eveHours} evening hours without blocker` }; + return { tier: 3, color: 'red', label: 'Heavy', reason: `${eveHours}+ evening hours without blocker` }; +} + +// Returns { d2: hours, d3: hours, junkLightHours } +// d2: estimated daytime indoor-light deficit (low-lux hours during the solar day) +// d3: junk-light contamination (LED-only / blue-after-sunset hours) +export function computeDeficitAxesForEnvironment(env, options = {}) { + if (!env) return { d2: 0, d3: 0 }; + const isActiveToday = options.isActiveToday || (() => true); + let d2 = 0, d3 = 0; + for (const r of env.rooms || []) { + if (!r || !isActiveToday(r)) continue; + const hours = r.hoursOccupiedPerDay || 0; + if (hours <= 0) continue; + // d2: any indoor hour without daylight contribution counts toward deficit + d2 += hours; + // d3: LED/fluorescent contamination + if (['led-cool', 'led-warm', 'led-tunable', 'fluorescent'].includes(r.primarySource)) { + d3 += hours * 0.6; + } + if (roomUsesEveningAfterSunset(r) && ['led-cool', 'led-tunable', 'fluorescent'].includes(r.primarySource)) { + d3 += 1; // bonus penalty for blue-after-sunset + } + } + for (const s of env.screens || []) { + if (!s || !isActiveToday(s)) continue; + const eveningHours = s.eveningUseAfterSunset || 0; + if (eveningHours > 0 && !s.blueBlockerEnabled) d3 += eveningHours * 0.5; + } + return { d2, d3 }; +} + +// Aggregate the deficit numbers into a plain-English burden tier. +// Used by the summary line at the bottom of the section so the user +// doesn't have to interpret raw "8.2 hr/day" numbers themselves. +// +// Interpretation copy follows three rules: +// - Verdict in 1 short sentence (what's heaviest right now). +// - Concrete action in 1 short sentence (the single thing that would +// move the needle most given the tier + d2/d3 ratio). +// - Avoid "junk-light" jargon — say "evening blue exposure" instead, +// which most users already understand. +export function computeIndoorBurdenForEnvironment(env, options = {}) { + const isActiveToday = options.isActiveToday || (() => true); + const { d2, d3 } = options.axes || computeDeficitAxesForEnvironment(env, { isActiveToday }); + // Tiers: 0 light, 1 moderate, 2 heavy + let tier = 0, parts = []; + // Round to integers — these are estimates, sub-hour precision is + // false confidence ("8.2 hr/day" reads more rigorous than it is). + if (d2 > 8) { tier = Math.max(tier, 2); parts.push(`${Math.round(d2)} hr indoors`); } + else if (d2 > 4) { tier = Math.max(tier, 1); parts.push(`${Math.round(d2)} hr indoors`); } + else if (d2 > 0) parts.push(`${Math.round(d2)} hr indoors`); + if (d3 > 4) { tier = Math.max(tier, 2); parts.push(`${Math.round(d3)} hr blue-after-sunset`); } + else if (d3 > 2) { tier = Math.max(tier, 1); parts.push(`${Math.round(d3)} hr blue-after-sunset`); } + else if (d3 > 0) parts.push(`${Math.round(d3)} hr blue-after-sunset`); + const labelMap = ['Light load', 'Moderate load', 'Heavy load']; + const colorMap = ['green', 'orange', 'red']; + let interp = ''; + if (d2 + d3 === 0) { + // Distinguish "nothing mapped yet" from "everything skipped today." + const totalItems = (env?.rooms?.length || 0) + (env?.screens?.length || 0); + interp = totalItems === 0 + ? 'No mapped exposure yet — add a room or screen to start.' + : 'Everything is skipped today — looks like a mostly-outdoor day.'; + } + else if (tier === 0) interp = 'Mostly daylight-aligned with friendly indoor sources. Keep doing what you\'re doing.'; + else if (tier === 1 && d3 > d2 / 2) interp = 'Evening blue exposure is the bigger pull right now. Warmer bulbs after sunset or a blue blocker on screens would move the needle most.'; + else if (tier === 1) interp = 'Plenty of indoor daytime hours. More outdoor light — especially before 10am — is the highest-leverage fix.'; + else if (tier === 2 && d3 >= d2) interp = 'Long indoor hours AND heavy evening blue. Evening sources are dragging melatonin — fix those first, then add outdoor morning light.'; + else interp = 'Long daytime hours indoors plus meaningful evening contamination. Outdoor morning light + warmer evening bulbs would help.'; + return { + tier, + color: colorMap[tier], + label: labelMap[tier], + parts, + interp, + d2, d3, + }; +} diff --git a/js/light-env.js b/js/light-env.js index 51314967..66d7cf93 100644 --- a/js/light-env.js +++ b/js/light-env.js @@ -17,13 +17,26 @@ import { escapeHTML, escapeAttr, showNotification, showPromptDialog, showConfirm import { saveImportedData } from './data.js'; import { recordTombstone } from './data-merge.js'; import { - getRoomEveningHoursAfterSunset, - hasRoomEveningAnswer, normalizeLightEnvironmentEveningFields, normalizeRoomEveningFields, normalizeRoomEveningPatch, roomUsesEveningAfterSunset, } from './light-env-evening.js'; +import { + PRIMARY_SOURCES, + SCREEN_DEVICES, + SOURCE_ARCHETYPES, + HOURS_BUCKETS, + EVENING_BUCKETS, + activeSourceArchetype, + activeHoursBucket, + activeEveningBucket, + defaultHoursForName, + computeRoomSeverity as computeRoomSeverityModel, + computeScreenStatus, + computeDeficitAxesForEnvironment, + computeIndoorBurdenForEnvironment, +} from './light-env-model.js'; import { configureLightEnvAudits, getLightAudits, @@ -31,33 +44,24 @@ import { } from './light-env-audits.js'; export { getLightAudits, saveLightAudit, updateLightAudit, deleteLightAudit } from './light-env-audits.js'; +export { + PRIMARY_SOURCES, + SCREEN_DEVICES, + SOURCE_ARCHETYPES, + HOURS_BUCKETS, + EVENING_BUCKETS, + activeSourceArchetype, + activeHoursBucket, + activeEveningBucket, + defaultHoursForName, + computeScreenStatus, +} from './light-env-model.js'; export { getRoomEveningHoursAfterSunset, hasRoomEveningAnswer, roomUsesEveningAfterSunset, } from './light-env-evening.js'; -export const PRIMARY_SOURCES = [ - { key: 'led-cool', label: 'LED — cool/daylight (4000K+)' }, - { key: 'led-warm', label: 'LED — warm white (2700–3000K)' }, - { key: 'led-tunable', label: 'LED — tunable / colour-changing' }, - { key: 'fluorescent', label: 'Fluorescent / CFL' }, - { key: 'incandescent', label: 'Incandescent (filament)' }, - { key: 'halogen', label: 'Halogen' }, - { key: 'candle', label: 'Candle / firelight' }, - { key: 'mixed', label: 'Mixed (multiple sources)' }, - { key: 'natural-only', label: 'Daylight only (no artificial)' }, - { key: 'unknown', label: "I don't know" }, -]; - -export const SCREEN_DEVICES = [ - { key: 'phone', label: 'Phone' }, - { key: 'laptop', label: 'Laptop' }, - { key: 'monitor', label: 'External monitor' }, - { key: 'tablet', label: 'Tablet' }, - { key: 'tv', label: 'TV' }, -]; - // ─── Public API ──────────────────────────────────────────────────────── export function getEnvironment() { @@ -222,190 +226,6 @@ export async function deleteScreen(id) { await saveImportedData(); } -// ─── Per-room severity (Baubiologie-style at-a-glance dot) ─────────── -// -// Mirrors the EMF Assessment severity-dot pattern: each room earns a 0–4 -// tier from green (good) to red (concerning) based on what we know about -// it. Inputs: -// • primarySource — fluorescent + cool LED bias the score upward -// • after-sunset use of cool/tunable LED → blue-evening contamination -// • flicker measurement (latest, if any) — the strongest signal we have -// because IEEE PAR1789 thresholds are well-defined -// • lux measurement (latest) — too-low daytime lux drags toward yellow, -// too-bright bedroom evenings drag toward orange -// Returns { tier, color, label, reason } so the dot + tooltip can render -// from one source. -// -// Tier → CSS color token mapping intentionally matches EMF's so the two -// surfaces feel like one design system. - -// True when the room has nothing graders can use — no source picked -// (or "I don't know"), no occupancy answer, no measurements, no -// evening-hours answer. The severity helper returns an "incomplete" -// gray-dot in that case so users don't read the default green dot -// as "we verified you're good" when really it means "we know nothing -// about this room yet." -function _hasAnyRoomSignal(room, measurements) { - if (!room) return false; - const hasSource = room.primarySource && room.primarySource !== 'unknown'; - const hasHours = (+room.hoursOccupiedPerDay) > 0; - const hasEvening = hasRoomEveningAnswer(room); - const hasMeas = (measurements || []).length > 0; - return hasSource || hasHours || hasEvening || hasMeas; -} - -export function computeRoomSeverity(room, measurements = []) { - if (!room) return { tier: 0, color: 'green', label: 'Unknown', reason: 'No data yet' }; - - // Gray-dot incomplete state for empty rooms — distinct from "Good". - if (!_hasAnyRoomSignal(room, measurements)) { - return { tier: 0, color: 'incomplete', label: 'Needs setup', reason: 'No signals yet — pick a light source, hours, or run a measurement.' }; - } - - let tier = 0; - const reasons = []; - - // Source-based bias - const src = room.primarySource; - if (src === 'fluorescent') { - tier = Math.max(tier, 2); - reasons.push('fluorescent / CFL primary'); - } else if (src === 'led-cool' || src === 'led-tunable') { - tier = Math.max(tier, 1); - reasons.push('cool LED primary'); - } else if (src === 'natural-only' || src === 'incandescent' || src === 'halogen' || src === 'candle') { - // friendly sources stay at 0 unless other signals pull them up - } - - // After-sunset blue-light contamination. - if (roomUsesEveningAfterSunset(room) && (src === 'led-cool' || src === 'led-tunable' || src === 'fluorescent')) { - tier = Math.max(tier, 2); - reasons.push('blue light after sunset'); - } - - // Latest flicker measurement (use most recent — flicker doesn't decay) - const flickers = measurements.filter(m => m.tool === 'flicker').sort((a, b) => b.capturedAt - a.capturedAt); - if (flickers.length) { - const score = flickers[0].value; - // saveMeasurement stores 0–3 for { Pristine, Mild, Moderate, Severe } - if (score >= 3) { tier = Math.max(tier, 4); reasons.push('severe flicker measured'); } - else if (score >= 2) { tier = Math.max(tier, 3); reasons.push('moderate flicker measured'); } - else if (score >= 1) { tier = Math.max(tier, 1); reasons.push('mild flicker measured'); } - } - - // Daytime lux (low → yellow). Treat any reading < 100 lux as low-indoor. - const luxes = measurements.filter(m => m.tool === 'lux').sort((a, b) => b.capturedAt - a.capturedAt); - if (luxes.length) { - const lux = luxes[0].value; - if (lux < 50 && (room.hoursOccupiedPerDay || 0) >= 2) { - tier = Math.max(tier, 2); - reasons.push('very low daytime lux for hours occupied'); - } else if (lux < 200 && (room.hoursOccupiedPerDay || 0) >= 4) { - tier = Math.max(tier, 1); - reasons.push('lower than office-bright for prolonged hours'); - } - } - - // Bedroom-specific: any sleep-darkness reading tells a story - const dark = measurements.filter(m => m.tool === 'darkness').sort((a, b) => b.capturedAt - a.capturedAt); - if (dark.length && /bedroom|sleep/i.test(room.name || '')) { - const lux = dark[0].value; - if (lux > 1) { tier = Math.max(tier, 3); reasons.push('bedroom not dark enough for melatonin'); } - else if (lux > 0.1) { tier = Math.max(tier, 2); reasons.push('measurable light leak in bedroom'); } - } - - // Screens-in-this-room contribution: heavy evening blue exposure from - // a screen in this room rolls into the room's severity. Compounds - // multiplicatively with after-sunset use of cool-LED room lighting — - // a bedroom with cool LED + a phone for 3 evening hours is worse - // than either signal alone. Screens skipped today don't count. - const screensHere = getScreensForRoom(room.id).filter(isActiveToday); - let unblockedEveHours = 0; - for (const s of screensHere) { - if (!s.blueBlockerEnabled && (s.eveningUseAfterSunset || 0) > 0) { - unblockedEveHours += s.eveningUseAfterSunset; - } - } - if (unblockedEveHours >= 3) { - tier = Math.max(tier, 3); - reasons.push(`${unblockedEveHours.toFixed(1)} hr/day evening screen exposure here`); - } else if (unblockedEveHours >= 1) { - tier = Math.max(tier, 2); - reasons.push(`${unblockedEveHours.toFixed(1)} hr/day evening screen exposure here`); - } - - const colorMap = ['green', 'yellow', 'orange', 'red', 'red']; - const labelMap = ['Sleep-friendly', 'Mild', 'Moderate', 'Concerning', 'Severe']; - return { - tier, - color: colorMap[Math.min(tier, 4)], - label: labelMap[Math.min(tier, 4)], - reason: reasons.length ? reasons.join(' · ') : 'No issues detected', - }; -} - -// ─── Step 1 helpers — chip-picker mappings ───────────────────────────── -// -// The Step 1 form was a wall of dropdowns + lonely number fields. These -// helpers translate the underlying schema (10 source options, free-form -// numbers, boolean flag) into 4-archetype source chips, hours buckets, -// and evening-hours buckets — answers the user can give by *looking* -// at a bulb / their day. - -// 4 archetypes the user can pick from a glance, mapped to canonical -// schema values. Power users hit "More options…" to drill down into -// the 10-option dropdown. -const SOURCE_ARCHETYPES = [ - { key: 'warm', emoji: '🌅', label: 'Warm yellow', storeAs: 'led-warm', matches: ['led-warm', 'incandescent', 'halogen', 'candle'] }, - { key: 'cool', emoji: '☀️', label: 'Cool white', storeAs: 'led-cool', matches: ['led-cool', 'led-tunable'] }, - { key: 'fluorescent', emoji: '🌫️', label: 'Fluorescent tube', storeAs: 'fluorescent', matches: ['fluorescent'] }, - { key: 'mixed', emoji: '❓', label: 'Mixed / unsure', storeAs: 'mixed', matches: ['mixed', 'unknown'] }, -]; - -function activeSourceArchetype(primarySource) { - if (!primarySource) return null; - for (const a of SOURCE_ARCHETYPES) { - if (a.matches.includes(primarySource)) return a.key; - } - return null; // covers natural-only — power-user-only -} - -// Hours buckets — store the bucket midpoint so downstream tiering math -// (currently "≥ 2 hr / ≥ 4 hr" thresholds) keeps working unchanged. -const HOURS_BUCKETS = [ - { key: 'short', label: '< 1 hr', min: 0, max: 1, midpoint: 0.5 }, - { key: 'some', label: '1–3 hr', min: 1, max: 3, midpoint: 2 }, - { key: 'lots', label: '3–6 hr', min: 3, max: 6, midpoint: 4.5 }, - { key: 'most', label: '6+ hr', min: 6, max: 24, midpoint: 8 }, -]; - -function activeHoursBucket(hours) { - if (hours == null || hours === '' || isNaN(+hours)) return null; - const h = +hours; - for (const b of HOURS_BUCKETS) { - if (h >= b.min && h < b.max) return b.key; - } - return 'most'; -} - -// Evening-hours buckets. Stored as numeric `eveningHoursAfterSunset`; -// legacy boolean rows are normalized before rendering. -const EVENING_BUCKETS = [ - { key: 'none', label: 'None', midpoint: 0 }, - { key: 'lt1', label: '< 1 hr', midpoint: 0.5 }, - { key: 'mid', label: '1–3 hr', midpoint: 2 }, - { key: 'gt3', label: '3+ hr', midpoint: 4 }, -]; - -function activeEveningBucket(room) { - if (!hasRoomEveningAnswer(room)) return null; - const h = getRoomEveningHoursAfterSunset(room); - if (h <= 0) return 'none'; - if (h < 1) return 'lt1'; - if (h < 3) return 'mid'; - return 'gt3'; -} - // Step 1 chip-picker render helpers — produce the inline chip rows // for source / hours / evening, plus the "More options" reveal that // drops back to the full 10-option dropdown for power users. @@ -459,113 +279,21 @@ function renderEveningPicker(r) { `; } -// Default occupancy hours seeded by room name on first add. User can -// adjust immediately via the chip row — this just keeps them out of -// the lonely-empty-number-field cold start. -function defaultHoursForName(name) { - const n = (name || '').toLowerCase(); - if (/bedroom|sleep/.test(n)) return 8; - if (/office|study|work/.test(n)) return 8; - if (/living|family|den|lounge/.test(n)) return 4; - if (/kitchen/.test(n)) return 2; - if (/bath/.test(n)) return 1; - return 4; +// Environment-aware wrappers around the deterministic model. The model stays +// state-free; this module supplies today's skip toggles and room-linked screens. +export function computeRoomSeverity(room, measurements = []) { + return computeRoomSeverityModel(room, measurements, { + screens: room?.id ? getScreensForRoom(room.id) : [], + isActiveToday, + }); } -// ─── Per-screen status (mirror of computeRoomSeverity) ───────────────── -// Evening blue exposure is the dominant junk-light vector for screens. -// Blocking the blue end (via blue-blocker glasses, software like -// f.lux/Night Shift, or amber-tinted filters) effectively zeroes the -// circadian penalty even at long evening hours. Without that, exposure -// scales with hours after sunset. -export function computeScreenStatus(screen) { - if (!screen) return { tier: 0, color: 'green', label: 'Unknown', reason: 'no data' }; - const eveHours = screen.eveningUseAfterSunset || 0; - const blocker = !!screen.blueBlockerEnabled; - if (blocker) return { tier: 0, color: 'green', label: 'Mitigated', reason: 'blue blocker enabled' }; - if (eveHours <= 0) return { tier: 0, color: 'green', label: 'Daytime only', reason: 'no evening exposure' }; - if (eveHours < 1) return { tier: 1, color: 'yellow', label: 'Mild', reason: '< 1 evening hour' }; - if (eveHours < 3) return { tier: 2, color: 'orange', label: 'Moderate', reason: `${eveHours} evening hours without blocker` }; - return { tier: 3, color: 'red', label: 'Heavy', reason: `${eveHours}+ evening hours without blocker` }; +export function computeDeficitAxes() { + return computeDeficitAxesForEnvironment(getEnvironment(), { isActiveToday }); } -// Aggregate the deficit numbers into a plain-English burden tier. -// Used by the summary line at the bottom of the section so the user -// doesn't have to interpret raw "8.2 hr/day" numbers themselves. -// -// Interpretation copy follows three rules: -// - Verdict in 1 short sentence (what's heaviest right now). -// - Concrete action in 1 short sentence (the single thing that would -// move the needle most given the tier + d2/d3 ratio). -// - Avoid "junk-light" jargon — say "evening blue exposure" instead, -// which most users already understand. export function computeIndoorBurden() { - const { d2, d3 } = computeDeficitAxes(); - // Tiers: 0 light, 1 moderate, 2 heavy - let tier = 0, parts = []; - // Round to integers — these are estimates, sub-hour precision is - // false confidence ("8.2 hr/day" reads more rigorous than it is). - if (d2 > 8) { tier = Math.max(tier, 2); parts.push(`${Math.round(d2)} hr indoors`); } - else if (d2 > 4) { tier = Math.max(tier, 1); parts.push(`${Math.round(d2)} hr indoors`); } - else if (d2 > 0) parts.push(`${Math.round(d2)} hr indoors`); - if (d3 > 4) { tier = Math.max(tier, 2); parts.push(`${Math.round(d3)} hr blue-after-sunset`); } - else if (d3 > 2) { tier = Math.max(tier, 1); parts.push(`${Math.round(d3)} hr blue-after-sunset`); } - else if (d3 > 0) parts.push(`${Math.round(d3)} hr blue-after-sunset`); - const labelMap = ['Light load', 'Moderate load', 'Heavy load']; - const colorMap = ['green', 'orange', 'red']; - let interp = ''; - if (d2 + d3 === 0) { - // Distinguish "nothing mapped yet" from "everything skipped today." - const env = getEnvironment(); - const totalItems = (env?.rooms?.length || 0) + (env?.screens?.length || 0); - interp = totalItems === 0 - ? 'No mapped exposure yet — add a room or screen to start.' - : 'Everything is skipped today — looks like a mostly-outdoor day.'; - } - else if (tier === 0) interp = 'Mostly daylight-aligned with friendly indoor sources. Keep doing what you\'re doing.'; - else if (tier === 1 && d3 > d2 / 2) interp = 'Evening blue exposure is the bigger pull right now. Warmer bulbs after sunset or a blue blocker on screens would move the needle most.'; - else if (tier === 1) interp = 'Plenty of indoor daytime hours. More outdoor light — especially before 10am — is the highest-leverage fix.'; - else if (tier === 2 && d3 >= d2) interp = 'Long indoor hours AND heavy evening blue. Evening sources are dragging melatonin — fix those first, then add outdoor morning light.'; - else interp = 'Long daytime hours indoors plus meaningful evening contamination. Outdoor morning light + warmer evening bulbs would help.'; - return { - tier, - color: colorMap[tier], - label: labelMap[tier], - parts, - interp, - d2, d3, - }; -} - -// ─── Derived deficit signals ────────────────────────────────────────── - -// Returns { d2: hours, d3: hours, junkLightHours } -// d2: estimated daytime indoor-light deficit (low-lux hours during the solar day) -// d3: junk-light contamination (LED-only / blue-after-sunset hours) -export function computeDeficitAxes() { - const env = getEnvironment(); - if (!env) return { d2: 0, d3: 0 }; - let d2 = 0, d3 = 0; - for (const r of env.rooms || []) { - if (!isActiveToday(r)) continue; - const hours = r.hoursOccupiedPerDay || 0; - if (hours <= 0) continue; - // d2: any indoor hour without daylight contribution counts toward deficit - d2 += hours; - // d3: LED/fluorescent contamination - if (['led-cool', 'led-warm', 'led-tunable', 'fluorescent'].includes(r.primarySource)) { - d3 += hours * 0.6; - } - if (roomUsesEveningAfterSunset(r) && ['led-cool', 'led-tunable', 'fluorescent'].includes(r.primarySource)) { - d3 += 1; // bonus penalty for blue-after-sunset - } - } - for (const s of env.screens || []) { - if (!isActiveToday(s)) continue; - const eveningHours = s.eveningUseAfterSunset || 0; - if (eveningHours > 0 && !s.blueBlockerEnabled) d3 += eveningHours * 0.5; - } - return { d2, d3 }; + return computeIndoorBurdenForEnvironment(getEnvironment(), { isActiveToday }); } // ─── UI: Light Environment page (lives at /light-environment route) ─── diff --git a/service-worker.js b/service-worker.js index 54102b86..0af84e16 100755 --- a/service-worker.js +++ b/service-worker.js @@ -317,6 +317,7 @@ const APP_SHELL = [ '/js/light-env-audits.js', '/js/light-env-ai-analysis.js', '/js/light-env-evening.js', + '/js/light-env-model.js', '/js/light-screen-ai-analysis.js', '/js/light-today-ai.js', '/js/light-tool-camera.js', diff --git a/tests/test-correctness-phase2.js b/tests/test-correctness-phase2.js index 0c544e8b..2032ca86 100644 --- a/tests/test-correctness-phase2.js +++ b/tests/test-correctness-phase2.js @@ -142,6 +142,7 @@ const pwaAppShellAssets = [ '/js/light-env.js', '/js/light-env-audits.js', '/js/light-env-evening.js', + '/js/light-env-model.js', '/js/light-tool-camera.js', '/js/light-tool-camera-modals.js', '/js/light-tools.js', diff --git a/tests/test-light-env.js b/tests/test-light-env.js index 0fe1be2f..a3eb1541 100644 --- a/tests/test-light-env.js +++ b/tests/test-light-env.js @@ -21,6 +21,7 @@ await import('../js/state.js'); // this import they'd silently skip in Node. await import('../js/sun-context.js'); const env = await import('../js/light-env.js'); +const model = await import('../js/light-env-model.js'); const { PRIMARY_SOURCES, SCREEN_DEVICES, getEnvironment, @@ -33,6 +34,10 @@ const { getLightAudits, saveLightAudit, updateLightAudit, deleteLightAudit, renderEnvironmentAssessmentSummary, renderEnvironmentSection, } = env; +const { + computeDeficitAxesForEnvironment, + computeIndoorBurdenForEnvironment, +} = model; const orig = window._labState.importedData; function reset(seed = {}) { @@ -51,6 +56,9 @@ const { PRIMARY_SOURCES.some(s => s.key === 'led-cool') && PRIMARY_SOURCES.some(s => s.key === 'led-warm') && PRIMARY_SOURCES.some(s => s.key === 'fluorescent')); + assert('light-env re-exports option lists from the model module', + model.PRIMARY_SOURCES === PRIMARY_SOURCES && + model.SCREEN_DEVICES === SCREEN_DEVICES); // Loosened: at least 5 entries, canonical keys present. Adding e-reader // / wearable display would be safe and shouldn't break this test. @@ -212,6 +220,20 @@ const { assert('computeRoomSeverity(null) returns safe default', noInput.tier === 0 && typeof noInput.label === 'string'); + reset({ + lightEnvironment: { + rooms: [{ id: 'r-screen', name: 'Bedroom', primarySource: 'led-warm', hoursOccupiedPerDay: 8 }], + screens: [{ id: 's-screen', roomId: 'r-screen', device: 'phone', eveningUseAfterSunset: 3, blueBlockerEnabled: false }], + }, + }); + const screenHeavyRoom = computeRoomSeverity(getEnvironment().rooms[0], []); + assert('Room severity wrapper includes active screens assigned to that room', + screenHeavyRoom.tier >= 3 && /evening screen exposure/.test(screenHeavyRoom.reason)); + await setTodayActive('screen', 's-screen', false); + const skippedScreenRoom = computeRoomSeverity(getEnvironment().rooms[0], []); + assert('Room severity wrapper ignores screens skipped today', + skippedScreenRoom.tier < 3 && !/evening screen exposure/.test(skippedScreenRoom.reason)); + // ─── 6. computeScreenStatus ────────────────────────────────────────── console.log('%c 6. computeScreenStatus ', 'font-weight:bold;color:#f59e0b'); @@ -276,6 +298,18 @@ const { axes = computeDeficitAxes(); assert('Skipped-today room contributes nothing to d2/d3', axes.d2 === 0 && axes.d3 === 0); + const modelAxes = computeDeficitAxesForEnvironment({ + rooms: [ + { id: 'active-room', primarySource: 'led-cool', hoursOccupiedPerDay: 10, eveningHoursAfterSunset: 2 }, + { id: 'skipped-room', primarySource: 'led-cool', hoursOccupiedPerDay: 10, eveningHoursAfterSunset: 2 }, + ], + screens: [{ id: 'active-screen', eveningUseAfterSunset: 2, blueBlockerEnabled: false }], + }, { + isActiveToday: item => item.id !== 'skipped-room', + }); + assert('Model deficit axes are state-free and accept today filtering', + modelAxes.d2 === 10 && Math.abs(modelAxes.d3 - 8) < 1e-9, + `got d2=${modelAxes.d2}, d3=${modelAxes.d3}`); // ─── 9. computeIndoorBurden ────────────────────────────────────────── console.log('%c 9. computeIndoorBurden ', 'font-weight:bold;color:#f59e0b'); @@ -321,6 +355,10 @@ const { assert('Burden parts list mentions both indoor + blue-after-sunset', burden.parts.some(p => /indoors/.test(p)) && burden.parts.some(p => /blue-after-sunset/.test(p))); + const modelBurden = computeIndoorBurdenForEnvironment({ rooms: [], screens: [] }); + assert('Model indoor burden distinguishes empty mapped exposure', + modelBurden.tier === 0 && + /add a room|add a screen/i.test(modelBurden.interp)); // ─── 10. Light Audits ──────────────────────────────────────────────── console.log('%c 10. Light audits CRUD ', 'font-weight:bold;color:#f59e0b'); @@ -414,10 +452,18 @@ const { typeof window.openLightEnvironmentAssessment === 'function' && typeof window.closeLightEnvironmentAssessment === 'function'); const envSrc = await (await import('node:fs/promises')).readFile(new URL('../js/light-env.js', import.meta.url), 'utf8'); + const modelSrc = await (await import('node:fs/promises')).readFile(new URL('../js/light-env-model.js', import.meta.url), 'utf8'); assert('Assessment modal uses user-facing indoor assessment copy', envSrc.includes('Indoor Light Assessment') && envSrc.includes('Save audit snapshots before and after changes') && !envSrc.includes('The Light page keeps the summary')); + assert('Light environment deterministic model is isolated from rendering/storage', + envSrc.includes("from './light-env-model.js'") && + modelSrc.includes('export function computeDeficitAxesForEnvironment') && + modelSrc.includes('export function computeIndoorBurdenForEnvironment') && + !modelSrc.includes('saveImportedData') && + !modelSrc.includes('renderEnvironmentSection') && + !envSrc.includes('function _hasAnyRoomSignal')); const auditSrc = await (await import('node:fs/promises')).readFile(new URL('../js/light-env-audits.js', import.meta.url), 'utf8'); assert('Light audit storage/rendering lives in its own module', auditSrc.includes('configureLightEnvAudits') && @@ -433,10 +479,13 @@ const { !envSrc.includes('function renderLightAuditCompare')); const navSrc = await (await import('node:fs/promises')).readFile(new URL('../js/nav.js', import.meta.url), 'utf8'); const fs = await import('node:fs/promises'); + const swSrc = await fs.readFile(new URL('../service-worker.js', import.meta.url), 'utf8'); const cssSrc = [ await fs.readFile(new URL('../css/light-sun.css', import.meta.url), 'utf8'), await fs.readFile(new URL('../css/light-env.css', import.meta.url), 'utf8'), ].join('\n'); + assert('Service worker precaches the light environment model module', + swSrc.includes("'/js/light-env-model.js'")); assert('Light assessment is linked from sidebar Analysis tools', navSrc.includes("label: 'Light assessment'") && navSrc.includes("key: 'light-env-assessment'") && diff --git a/version.js b/version.js index 80075b3f..def1cb1f 100644 --- a/version.js +++ b/version.js @@ -2,4 +2,4 @@ // Classic script (not ES module) so it works in both browser and service worker. // Browser: