@@ -446,6 +446,12 @@ function _clShowLat(element, latitude, suffix) {
element.textContent = '\u2713 ' + Math.abs(Math.round(latitude)) + '\u00b0' + (latitude >= 0 ? 'N' : 'S') + ' \u2014 ' + LATITUDE_BANDS[band] + (suffix || '');
}
+function _clCachedLatitude(value) {
+ if (Number.isFinite(value)) return Number(value);
+ const latitude = Number(value?.lat ?? value?.latitude);
+ return Number.isFinite(latitude) ? latitude : null;
+}
+
function _clUpdateLat() {
const country = (_clInput('cl-country')?.value || '').trim();
const zip = (_clInput('cl-zip')?.value || '').trim();
@@ -459,27 +465,27 @@ function _clUpdateLat() {
const cache = getLocationCache();
const cacheKey = (country + '|' + zip).toLowerCase();
const cached = cache[cacheKey];
- const hasAIProvider = hasClientListAIProvider();
+ const cachedLatitude = _clCachedLatitude(cached);
- if (cached !== undefined) {
- const countryLatitude = zip ? cache[(country + '|').toLowerCase()] : undefined;
+ if (cachedLatitude !== null) {
+ const countryLatitude = zip ? _clCachedLatitude(cache[(country + '|').toLowerCase()]) : null;
let zipSuffix = '';
- if (zip && countryLatitude !== undefined) zipSuffix = Math.round(cached) !== Math.round(countryLatitude) ? ' (ZIP-refined)' : ' (ZIP \u2014 same area)';
- _clShowLat(element, cached, zipSuffix);
+ if (zip) zipSuffix = countryLatitude !== null && Math.round(cachedLatitude) === Math.round(countryLatitude) ? ' (home area)' : ' (postal area)';
+ _clShowLat(element, cachedLatitude, zipSuffix);
return;
}
- const countryOnly = zip ? cache[(country + '|').toLowerCase()] : undefined;
- if (countryOnly !== undefined) {
+ const countryOnly = zip ? _clCachedLatitude(cache[(country + '|').toLowerCase()]) : null;
+ if (countryOnly !== null) {
_clShowLat(element, countryOnly, ' \u2014 refining with ZIP\u2026');
} else {
const bandLabel = getLatitudeFromLocation(country, zip);
if (bandLabel) {
element.style.color = 'var(--green)';
- element.textContent = '\u2713 ' + bandLabel + (hasAIProvider ? ' \u2014 refining\u2026' : '');
- } else if (hasAIProvider) {
+ element.textContent = '\u2713 ' + bandLabel + (zip ? ' \u2014 resolving postal area\u2026' : '');
+ } else if (zip) {
element.style.color = 'var(--text-muted)';
- element.textContent = 'Detecting\u2026';
+ element.textContent = 'Resolving postal area\u2026';
} else {
element.style.color = 'var(--text-muted)';
element.textContent = 'Country not recognized \u2014 try the full name';
@@ -488,16 +494,13 @@ function _clUpdateLat() {
if (latitudeTimer) clearTimeout(latitudeTimer);
latitudeTimer = setTimeout(() => {
- if (!hasClientListAIProvider()) return;
+ if (!zip) return;
detectLatitudeWithAI(country, zip).then(() => {
const freshCache = getLocationCache();
- const updated = freshCache[(country + '|' + zip).toLowerCase()];
- if (updated === undefined) return;
- const countryLatitude = zip ? freshCache[(country + '|').toLowerCase()] : undefined;
- let zipSuffix = '';
- if (zip && countryLatitude !== undefined) zipSuffix = Math.round(updated) !== Math.round(countryLatitude) ? ' (ZIP-refined)' : ' (ZIP \u2014 same area)';
+ const updated = _clCachedLatitude(freshCache[(country + '|' + zip).toLowerCase()]);
+ if (updated === null) return;
const display = document.getElementById('cl-lat-display');
- if (display) _clShowLat(display, updated, zipSuffix);
+ if (display) _clShowLat(display, updated, ' (postal area)');
});
}, 1500);
}
diff --git a/js/dashboard-view-composition.js b/js/dashboard-view-composition.js
index f1ddba47..ec78189a 100644
--- a/js/dashboard-view-composition.js
+++ b/js/dashboard-view-composition.js
@@ -25,6 +25,7 @@ import {
loadLightSunUI,
renderLoadedDashboardLightChannelPills,
renderLoadedLightConditionsWidgetBody,
+ renderLoadedLightLiveSession,
renderLoadedLightSessionLogActions,
renderLoadedLightTodayHero,
resumeLoadedActiveSunTickerIfNeeded,
@@ -62,6 +63,7 @@ export function createDashboardViewComposition({
markerHasData,
renderDashboardLightChannelPills: renderLoadedDashboardLightChannelPills,
renderLightConditionsWidgetBody: renderLoadedLightConditionsWidgetBody,
+ renderLightLiveSession: renderLoadedLightLiveSession,
renderLightSessionLogActions: renderLoadedLightSessionLogActions,
getMobileDashboardMarkers,
getMobileDashboardInsights,
@@ -97,6 +99,7 @@ export function createDashboardViewComposition({
renderDashboardCorrelationWidget,
renderDashboardLightTodayWidget,
renderDashboardLightConditionsWidget,
+ renderDashboardLightLiveSessionWidget,
renderDashboardLightSessionLogWidget,
renderDashboardLightChannelsWidget,
renderDashboardKeyTrendsWidget,
@@ -159,6 +162,7 @@ export function createDashboardViewComposition({
renderDashboardCorrelationWidget,
renderDashboardLightTodayWidget,
renderDashboardLightConditionsWidget,
+ renderDashboardLightLiveSessionWidget,
renderDashboardLightSessionLogWidget,
renderDashboardLightChannelsWidget,
renderDashboardKeyTrendsWidget,
diff --git a/js/dashboard-widget-renderers.js b/js/dashboard-widget-renderers.js
index 7324dbbe..96d527cb 100644
--- a/js/dashboard-widget-renderers.js
+++ b/js/dashboard-widget-renderers.js
@@ -37,6 +37,7 @@ export function createDashboardWidgetRenderers(deps) {
markerHasData,
renderDashboardLightChannelPills,
renderLightConditionsWidgetBody,
+ renderLightLiveSession = () => '',
renderLightSessionLogActions,
getMobileDashboardMarkers,
getMobileDashboardInsights,
@@ -126,14 +127,19 @@ export function createDashboardWidgetRenderers(deps) {
return renderLightSessionLogActions();
}
+ function renderDashboardLightLiveSessionWidget() {
+ if (!lightSunModulesReady()) return renderLightSunLoadingState();
+ return renderLightLiveSession({ includeEmptyState: true });
+ }
+
function renderDashboardLightChannelsWidget() {
if (!lightSunModulesReady()) return renderLightSunLoadingState();
const sessions = getDashboardLightSessions();
const deviceSessionsAll = getDashboardDeviceSessions();
const totalSessions = sessions.length + deviceSessionsAll.length;
const lead = totalSessions === 0
- ? 'No light sessions yet. Start logging sun or device exposure to fill your channel rhythm.'
- : 'Seven-day channel rhythm from outdoor sun and therapy devices.';
+ ? 'No light sessions yet. Log sunlight or a device session to see which pathways may have received a signal.'
+ : 'Seven-day light rhythm, with sunlight and device signals kept separate.';
return `
${lead}
${renderDashboardLightChannelPills()}
@@ -628,6 +634,7 @@ export function createDashboardWidgetRenderers(deps) {
renderDashboardCorrelationWidget,
renderDashboardLightTodayWidget,
renderDashboardLightConditionsWidget,
+ renderDashboardLightLiveSessionWidget,
renderDashboardLightSessionLogWidget,
renderDashboardLightChannelsWidget,
renderDashboardKeyTrendsWidget,
diff --git a/js/dashboard-widgets.js b/js/dashboard-widgets.js
index d43c35d1..f2b41d66 100644
--- a/js/dashboard-widgets.js
+++ b/js/dashboard-widgets.js
@@ -65,6 +65,7 @@ export function createDashboardWidgetRegistry(renderers, opts = {}) {
{ id: 'correlation', source: 'Tools', title: 'Correlations', description: 'Highest linked marker pairs', size: 'half', render: renderers.renderDashboardCorrelationWidget },
{ id: 'light-today', source: 'Light', title: 'Light Today', description: "Today's light synthesis across sun, devices, and environment", render: renderers.renderDashboardLightTodayWidget },
{ id: 'light-conditions-now', source: 'Light', title: 'Conditions Now', description: 'Current outdoor UV, atmosphere, and air quality', size: 'full', render: renderers.renderDashboardLightConditionsWidget },
+ { id: 'light-live-session', source: 'Light', title: 'Live Light Session', description: 'Running sun or therapy session with live estimates and controls', size: 'full', render: renderers.renderDashboardLightLiveSessionWidget },
{ id: 'light-session-log', source: 'Light', title: 'Log Sessions', description: 'Start sun or therapy sessions quickly', size: 'third', render: renderers.renderDashboardLightSessionLogWidget },
{ id: 'light-channels', source: 'Light', title: 'Light Channels', description: 'Seven-day rhythm across light biology channels', size: 'half', render: renderers.renderDashboardLightChannelsWidget },
{ id: 'profile-context', source: 'Insight', title: 'Profile Context', description: 'Goals, history, lifestyle, and context cards', render: () => renderProfileContextCards() },
diff --git a/js/light-audit-ai-analysis.js b/js/light-audit-ai-analysis.js
index e9721770..66dfe635 100644
--- a/js/light-audit-ai-analysis.js
+++ b/js/light-audit-ai-analysis.js
@@ -18,6 +18,7 @@ import { hasAIProvider } from './api.js';
import { createAIVerdict, hashString, dotPrefix } from './ai-verdict-engine.js';
import { LIGHTING_HARDWARE_CAVEATS } from './lighting-hardware-caveats.js';
import { getRoomEveningHoursAfterSunset } from './light-env-evening.js';
+import { isQuantitativeDarknessMeasurement, isQuantitativeLuxMeasurement } from './light-env-model.js';
import { formatHealthGoalsText } from './health-goals-utils.js';
import { aiActionAttrs, registerAIActionHandler } from './ai-action-delegates.js';
@@ -43,7 +44,7 @@ const _SOURCE_LABELS = {
// Bumped 2026-05-08: synthesis priorities now use Brown 2022 melanopic-
// EDI thresholds; older cached verdicts used a 100-lux daytime / >1
// photopic-lux night anchor and need to refresh.
-const _auditFingerprintSalt = 'v2-brown2022-medi';
+const _auditFingerprintSalt = 'v3-measurement-quality';
export function getAuditFingerprint(a) {
if (!a) return '';
const parts = [
@@ -59,10 +60,10 @@ export function getAuditFingerprint(a) {
// detect a labelled-edit scenario where the user updated a room
// pre-snapshot.
for (const r of (a.rooms || [])) {
- parts.push(`r:${r.id}:${r.primarySource || ''}:${r.hoursOccupiedPerDay || 0}:${getRoomEveningHoursAfterSunset(r)}`);
+ parts.push(`r:${r.id}:${r.primarySource || ''}:${r.daylightLevel || ''}:${r.hoursOccupiedPerDay || 0}:${getRoomEveningHoursAfterSunset(r)}`);
}
for (const m of (a.measurements || [])) {
- parts.push(`m:${m.tool}:${typeof m.value === 'number' ? Math.round(m.value * 100) / 100 : m.value}`);
+ parts.push(`m:${m.tool}:${typeof m.value === 'number' ? Math.round(m.value * 100) / 100 : m.value}:${m.extra?.method || m.extra?.source || ''}`);
}
return hashString(parts.join('|'));
}
@@ -103,6 +104,7 @@ export function buildAuditContext(a) {
for (const r of rooms) {
const roomLines = [`- ${_safeText(r.name) || '(unnamed)'}`];
if (r.primarySource) roomLines.push(` Primary source: ${_SOURCE_LABELS[r.primarySource] || r.primarySource}`);
+ if (r.daylightLevel && r.daylightLevel !== 'unknown') roomLines.push(` Stated daylight reaching room: ${r.daylightLevel}`);
if (r.hoursOccupiedPerDay != null) roomLines.push(` Hours occupied: ${r.hoursOccupiedPerDay}/day`);
const eveHrs = getRoomEveningHoursAfterSunset(r);
if (eveHrs > 0) roomLines.push(` Evening use after sunset: ${eveHrs} hr/day`);
@@ -112,18 +114,23 @@ export function buildAuditContext(a) {
const m = _latestInAudit(a, t, r.id);
if (!m) continue;
switch (t) {
- case 'lux': roomLines.push(` Lux: ${Math.round(m.value)} lux`); break;
+ case 'lux': roomLines.push(` Lux: ${Math.round(m.value)} photopic lux (${isQuantitativeLuxMeasurement(m) ? 'usable spot-check' : 'unverified camera estimate — do not threshold'})`); break;
case 'flicker': {
const score = Math.round(m.value || 0);
const sLabel = ['pristine', 'mild', 'moderate', 'severe'][score] || 'unknown';
- roomLines.push(` Flicker: ${score}/3 (${sLabel})${m.extra?.stripes ? `, ${m.extra.stripes} PWM stripes` : ''}`);
+ roomLines.push(` Camera banding: ${score}/3 (${sLabel})${m.extra?.stripes ? `, ${m.extra.stripes} rolling-shutter stripe groups` : ''}`);
break;
}
case 'darkness':
- roomLines.push(` Sleep darkness: mean ${_formatNumber(m.extra?.meanLux ?? m.value, 2)} lux${m.extra?.peakLux != null ? `, peak ${_formatNumber(m.extra.peakLux, 2)}` : ''}`);
+ roomLines.push(isQuantitativeDarknessMeasurement(m)
+ ? ` Sleep-time meter entry: ${_formatNumber(m.value, 2)} photopic lux (not melanopic EDI)`
+ : ` Sleep-light camera check: ${m.extra?.levelLabel || 'qualitative'} (not lux)`);
break;
case 'cct':
- roomLines.push(` CCT: ${Math.round(m.value)} K${m.extra?.melanopic != null ? `, melanopic ${_formatNumber(m.extra.melanopic, 2)}` : ''}`);
+ {
+ const blueRatio = m.extra?.cameraBlueRatioProxy ?? m.extra?.melanopic;
+ roomLines.push(` Approximate camera CCT: ~${Math.round(m.value / 100) * 100} K${blueRatio != null ? `, camera RGB blue-ratio proxy ${_formatNumber(blueRatio, 2)} (not melanopic EDI)` : ''}`);
+ }
break;
case 'spectrum':
roomLines.push(` Spectrum: ${m.value || m.extra?.label}`);
@@ -155,7 +162,7 @@ export function buildAuditContext(a) {
lines.push('### Portable screens');
for (const s of portable) {
const ev = s.eveningUseAfterSunset != null ? Number(s.eveningUseAfterSunset) : 0;
- lines.push(`- ${_SCREEN_LABELS[s.device] || s.device}: ${s.hoursPerDay || 0} hr/day${ev > 0 ? ', ' + ev + ' hr after sunset' : ''}${s.blueBlockerEnabled ? ', blue blocker on' : ''}`);
+ lines.push(`- ${_SCREEN_LABELS[s.device] || s.device}: ${s.hoursPerDay || 0} hr/day${ev > 0 ? ', ' + ev + ' hr after sunset' : ''}${s.blueBlockerEnabled ? ', blue reduction noted (not zero exposure)' : ''}`);
}
}
@@ -178,27 +185,27 @@ const SYSTEM_PROMPT = [
'Return ONLY valid JSON: {"dot":"green|yellow|red|gray","tip":"string","detail":"string"}.',
'',
'dot:',
- ' green = the environment is broadly circadian-aligned (daytime rooms bright + cool, evening rooms dim + warm, sleep rooms dark, no significant flicker, screens managed)',
- ' yellow = mostly OK with one or two specific systemic issues (one room\'s evening setup, screens unmanaged, single high-flicker fixture)',
- ' red = circadian-hostile environment overall (multiple rooms hostile, sleep room not dark, phone-in-bed, severe flicker stacking)',
- ' gray = not enough data (snapshot has no measurements)',
+ ' green = entered timing and trustworthy measurements flag no clear concern',
+ ' yellow = one actionable signal is present or the snapshot relies heavily on proxies',
+ ' red = multiple strong entered or measured signals stack; never from camera proxies alone',
+ ' gray = not enough data or no trustworthy measurement/context',
'',
'Synthesis priorities (rank issues by these when picking the verdict + tip):',
- ' 1. Sleep-room contamination: any sleep-room reading meaningfully above the Brown 2022 melanopic-EDI thresholds (<1 m-EDI lux during sleep, <10 in the hour before bed; >1 photopic lux at night is a useful working proxy). Cool CCT in evening hours, phone bound to a sleep room. This dominates everything else for most users.',
- ' 2. Severe flicker (score 2+) anywhere the user spends >2 evening hours.',
- ' 3. Daytime rooms below ~250 m-EDI lux at the eye (Brown 2022 consensus; ≈ 500 photopic lux for typical mixed-spectrum sources, easier in daylit rooms) — under-lit entrainment is a slow-burn issue but real.',
- ' 4. Evening cool LED (>4000K) + high evening occupancy in living spaces.',
- ' 5. Phone-in-bed without blue blocker — single largest junk-light vector for most users.',
+ ' 1. Measurement quality. Brown 2022 uses eye-level melanopic EDI (≥250 lx daytime, ≤10 lx evening, ≤1 lx during sleep). Ordinary photopic lux and camera RGB/CCT are not equivalent.',
+ ' 2. Repeated after-sunset timing under bright/close/cool sources, while noting brightness is not measured by a room-source answer.',
+ ' 3. Trustworthy low daytime photopic-lux spot-checks or explicitly little daylight in frequently used rooms.',
+ ' 4. Strong rolling-shutter banding where the user spends substantial time; no-band result does not prove flicker-free output.',
+ ' 5. Screens near bedtime. Blue-reduction settings may help but never erase brightness and duration.',
'',
'Specific patterns to flag:',
- ' • Bedroom dark but living room overhead is cool LED + 4 hr evening = melatonin onset is being suppressed BEFORE the user reaches the dark bedroom — the bedroom dark doesn\'t save you.',
- ' • Office is properly bright daytime + dark sleep room + bedroom phone = the daytime / sleep envelope is good but the in-between hour is leaking blue light.',
- ' • All rooms low-lux and warm = the user is in a "cave" environment, sleep may be fine but daytime entrainment is failing.',
+ ' • A qualitative camera darkness result cannot support melatonin percentages or sleep-safety claims.',
+ ' • Approximate CCT cannot establish a complete or natural spectrum.',
+ ' • A window camera ratio cannot establish visible, UV, or infrared transmission.',
'',
...LIGHTING_HARDWARE_CAVEATS,
'',
'tip: one sentence, max 18 words. The single highest-leverage fix for THIS environment overall.',
- 'detail: 3–4 sentences. Acknowledge what\'s working, name the 1–2 highest-priority issues with specific room + reading citations, and the most-leveraged fix. Concrete, observational.',
+ 'detail: 3–4 sentences. Separate entered context, trustworthy measurements, and camera proxies. Never diagnose circadian disruption or estimate hormones from these data.',
'',
'No "you should" — be observational. No emoji.',
].join('\n');
diff --git a/js/light-burden-ai-analysis.js b/js/light-burden-ai-analysis.js
index 09d57fd8..ef0062ed 100644
--- a/js/light-burden-ai-analysis.js
+++ b/js/light-burden-ai-analysis.js
@@ -49,13 +49,18 @@ export function getBurdenFingerprint() {
if (!env) return '';
const burden = computeIndoorBurden();
const parts = /** @type {Array} */ ([
+ 'v2-screening-not-dose',
burden.tier,
Math.round(burden.d2 * 10) / 10,
Math.round(burden.d3 * 10) / 10,
]);
for (const r of env.rooms || []) {
if (!isActiveToday(r)) continue;
- parts.push(`r:${r.id}:${r.primarySource || ''}:${r.hoursOccupiedPerDay || 0}:${getRoomEveningHoursAfterSunset(r)}`);
+ parts.push(`r:${r.id}:${r.primarySource || ''}:${r.daylightLevel || ''}:${r.hoursOccupiedPerDay || 0}:${getRoomEveningHoursAfterSunset(r)}`);
+ const latestLux = (state.importedData?.lightMeasurements || [])
+ .filter(m => m?.roomId === r.id && m.tool === 'lux')
+ .sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0))[0];
+ if (latestLux) parts.push(`lux:${r.id}:${Math.round(Number(latestLux.value) || 0)}:${latestLux.extra?.source || ''}:${latestLux.extra?.calibrationConfirmed ? 1 : 0}`);
}
for (const s of env.screens || []) {
if (!isActiveToday(s)) continue;
@@ -70,9 +75,10 @@ export function buildBurdenContext() {
const burden = computeIndoorBurden();
const lines = [];
lines.push('### Indoor light burden — live snapshot of the user\'s active environment');
- lines.push(`Tier: ${burden.label} (0=light / 1=moderate / 2=heavy)`);
- lines.push(`Daytime indoor hours (d2): ${burden.d2.toFixed(1)}`);
- lines.push(`Junk-light hours (d3 — LED-only / blue-after-sunset weighted): ${burden.d3.toFixed(1)}`);
+ lines.push(`Tier: ${burden.label} (0=generally aligned / 1=mixed signals / 2=needs attention)`);
+ lines.push(`Daytime opportunity screening score (d2, 0–10; not hours or dose): ${burden.d2.toFixed(1)}`);
+ lines.push(`After-sunset screening score (d3, 0–10; not hours or dose): ${burden.d3.toFixed(1)}`);
+ lines.push(`Evidence coverage: ${burden.daylightKnown} daylight signal(s), ${burden.eveningKnown} evening timing signal(s), ${burden.missingDaylightRooms} room daylight answer(s) missing.`);
lines.push(`Hardcoded heuristic interp this user is ABOUT to see: "${burden.interp}"`);
lines.push('Your job: write something more specific that references their actual rooms / screens, not just the tier label.');
@@ -83,7 +89,7 @@ export function buildBurdenContext() {
for (const r of rooms) {
const ev = getRoomEveningHoursAfterSunset(r);
const safeName = String(r.name || '').replace(/\s+/g, ' ').trim().slice(0, 80);
- lines.push(`- ${safeName}: source=${_SOURCE_LABELS[r.primarySource] || r.primarySource || 'unknown'}, occupied ${r.hoursOccupiedPerDay || 0} hr/day${ev > 0 ? `, ${ev} hr after sunset` : ''}`);
+ lines.push(`- ${safeName}: source=${_SOURCE_LABELS[r.primarySource] || r.primarySource || 'unknown'}, daylight=${r.daylightLevel || 'unknown'}, occupied ${r.hoursOccupiedPerDay || 0} hr/day${ev > 0 ? `, ${ev} hr after sunset` : ''}`);
}
}
@@ -94,7 +100,7 @@ export function buildBurdenContext() {
for (const s of screens) {
const ev = s.eveningUseAfterSunset != null ? Number(s.eveningUseAfterSunset) : 0;
const room = s.roomId ? (env.rooms.find(r => r.id === s.roomId)?.name || 'a room') : 'portable';
- lines.push(`- ${_SCREEN_LABELS[s.device] || s.device} (${room}): ${s.hoursPerDay || 0} hr/day${ev > 0 ? `, ${ev} hr after sunset` : ''}${s.blueBlockerEnabled ? ', blue blocker on' : ''}`);
+ lines.push(`- ${_SCREEN_LABELS[s.device] || s.device} (${room}): ${s.hoursPerDay || 0} hr/day${ev > 0 ? `, ${ev} hr after sunset` : ''}${s.blueBlockerEnabled ? ', blue reduction noted (not zero exposure)' : ''}`);
}
}
@@ -113,23 +119,23 @@ export function buildBurdenContext() {
}
const SYSTEM_PROMPT = [
- 'You evaluate a user\'s LIVE indoor-light burden — the right-now snapshot of which rooms + screens they actively use, weighted by hours and source spectrum.',
+ 'You evaluate a user\'s LIVE indoor-light screening picture: rooms, stated daylight, after-sunset timing, screens, and measurement quality.',
'Return ONLY valid JSON: {"dot":"green|yellow|red|gray","tip":"string","detail":"string"}.',
'',
'dot:',
- ' green = burden is light (d2 ≤ 4 AND d3 ≤ 2 AND no sleep-room contamination)',
- ' yellow = moderate burden in one axis (long indoor hours OR meaningful evening blue, not both)',
- ' red = heavy burden in both axes OR sleep-room contamination present',
- ' gray = no rooms / screens mapped yet',
+ ' green = entered information flags no clear concern and evidence coverage is adequate',
+ ' yellow = a meaningful screening signal exists or evidence is incomplete',
+ ' red = several strong entered signals stack; never assign red from uncalibrated camera proxies alone',
+ ' gray = no mapped exposure or insufficient evidence',
'',
- 'You\'re replacing a hardcoded 5-branch heuristic that says generic things like "Plenty of indoor daytime hours. More outdoor light — especially before 10am — is the highest-leverage fix." Your job is to do better than that by NAMING the specific rooms / screens that are driving the burden, and picking a fix that is genuinely the highest-leverage move for THIS user, not a generic talking point.',
+ 'The d2/d3 values are bounded heuristic screening scores. They are not hours, photon dose, melanopic EDI, or hormone effects. Name the specific entered room/screen signals and identify missing evidence before recommending a change.',
'',
'Concrete patterns to call out when present:',
- ' • A specific room dominating d2 (e.g. "Office at 8 hr/day under cool LED is the bulk of d2")',
- ' • A specific screen dominating d3 (e.g. "TV at 4 hr after sunset accounts for most of the evening blue load")',
+ ' • A room with little stated daylight or a trustworthy low daytime lux spot-check',
+ ' • A specific screen or room with long after-sunset use',
' • Phone-in-bed if a phone is bound to a sleep-coded room',
- ' • Daytime cave: all daytime rooms low-lux + warm = sleep is OK but daytime entrainment is failing',
- ' • Mismatch: light load but a single hostile evening fixture undoes the rest',
+ ' • Missing daylight answers or only camera proxies: recommend a better measurement before a biological conclusion',
+ ' • Screen tint/blue reduction: acknowledge it may help, but never subtract the exposure to zero',
'',
...LIGHTING_HARDWARE_CAVEATS,
'',
@@ -191,7 +197,7 @@ export function renderBurdenInterp(burden) {
// otherwise fall back to the static heuristic interp text.
if (!hasAIProvider()) {
const cached = env?.burdenAI;
- if (cached?.status === 'ok' && cached?.dot && cached?.tip) {
+ if (cached?.status === 'ok' && cached?.dot && cached?.tip && cached.fingerprint === getBurdenFingerprint()) {
const dot = cached.dot;
return `
diff --git a/js/light-channel-view.js b/js/light-channel-view.js
index 9ac2c7fe..deab294a 100644
--- a/js/light-channel-view.js
+++ b/js/light-channel-view.js
@@ -42,101 +42,81 @@ export function mergeTotals(a, b) {
return out;
}
-// Mini 7-day sparkline rendered as inline SVG; sub-meaningful days get a faint stub.
+const _hasSignal = value => Number.isFinite(value) && value > 0.0001;
+
+function _sourceSignalLabel(sun, device) {
+ const hasSun = _hasSignal(sun);
+ const hasDevice = _hasSignal(device);
+ if (hasSun && hasDevice) return 'Sunlight + device logged';
+ if (hasSun) return 'Sunlight logged';
+ if (hasDevice) return 'Device logged';
+ return 'Not logged';
+}
+
+// Mini 7-day sparkline rendered as inline SVG. Height shows the rhythm of
+// modeled exposure, while solid/faded segments keep sunlight and devices
+// visibly separate. There is no target or completion color.
export function _channelSparkline(channelKey) {
const breakdown = lightChannelDeps.dailyChannelBreakdown;
if (!breakdown) return '';
const days = breakdown(channelKey, 7);
- const meta = getChannelDisplay()[channelKey] || {};
- const dailyTarget = meta.dailyTarget || 0;
- const observedMax = Math.max(0, ...days.map(d => d.sun + d.device));
- const max = Math.max(observedMax, dailyTarget * 1.05, 0.001);
- const W = 47, H = 14, barW = 5, gap = 2;
- const colorFor = (total) => {
- if (dailyTarget <= 0 || total < dailyTarget * 0.05) return null; // faint stub
- if (total >= dailyTarget) return 'var(--green)';
- if (total >= dailyTarget * 0.30) return 'var(--channel-accent, var(--accent))';
- return 'var(--channel-accent, var(--accent))';
- };
- const opacityFor = (total) => {
- if (dailyTarget <= 0 || total < dailyTarget * 0.05) return 0.35;
- if (total >= dailyTarget) return 1.0;
- if (total >= dailyTarget * 0.30) return 0.85;
- return 0.55;
- };
+ const observedMax = Math.max(0, ...days.flatMap(d => [d.sun, d.device]));
+ const max = Math.max(observedMax, 0.001);
+ const W = 47, H = 14, barW = 2, pairGap = 1, gap = 2;
const bars = days.map((d, i) => {
- const x = i * (barW + gap);
+ const x = i * (barW * 2 + pairGap + gap);
const total = d.sun + d.device;
- const isStub = !colorFor(total);
- const barH = isStub ? 1.5 : Math.max(1.5, (total / max) * H);
- const y = H - barH;
- const fill = colorFor(total) || 'var(--text-muted)';
- return ``;
+ if (!_hasSignal(total)) {
+ return ``;
+ }
+ const sunH = Math.max(0, (d.sun / max) * H);
+ const devH = Math.max(0, (d.device / max) * H);
+ return `${sunH > 0 ? `` : ''}${devH > 0 ? `` : ''}`;
}).join('');
return ``;
}
-// "X days" label for the pill — count of days that hit the meaningful-dose threshold.
+// Count days on which this channel received any modeled signal. This is a log
+// summary, not a sufficiency threshold or a biological streak.
export function _channelDayCount(channelKey) {
const breakdown = lightChannelDeps.dailyChannelBreakdown;
- if (!breakdown) return { txt: '—', n: 0 };
+ if (!breakdown) return { txt: 'Not logged', n: 0, sun: 0, device: 0 };
const days = breakdown(channelKey, 7);
- const meta = getChannelDisplay()[channelKey] || {};
- const target = meta.dailyTarget || 0;
- const threshold = (typeof _CHANNEL_DAY_THRESHOLD !== 'undefined' && _CHANNEL_DAY_THRESHOLD[channelKey]) || 0.30;
- const floor = target * threshold;
- let n = 0;
- for (const d of days) if ((d.sun + d.device) >= floor) n++;
- // "4/7" reads as a fraction at a glance — much clearer than "4d",
- // which users were parsing as "4 days ago" instead of "4 of 7 days
- // this week hit target". Tooltip + sr-only label still say it the
- // long way for accessibility. Zero-hit channels show "0/7" too so
- // the format stays consistent across pills instead of an em-dash
- // (which read as "no data" instead of "zero days hit").
- return { txt: `${n}/7`, n };
+ let n = 0, sun = 0, device = 0;
+ for (const d of days) {
+ if (_hasSignal(d.sun + d.device)) n++;
+ if (_hasSignal(d.sun)) sun++;
+ if (_hasSignal(d.device)) device++;
+ }
+ return { txt: n ? `${n} day${n === 1 ? '' : 's'}` : 'Not logged', n, sun, device };
}
// Unified channel pill row — same vocabulary as the dashboard strip,
// reused on the Light page where each pill is a click-to-expand entry into
-// a per-channel drill-down panel (full science, 7d/30d tier comparison,
-// suggestion). Empty state renders the same row with all-empty
+// a per-channel drill-down panel (source, rhythm, plain-language meaning,
+// and research). Empty state renders the same row with all-empty
// sparklines; bars fill in as data accumulates. One renderer for both
// states.
-export function renderChannelPills(totals7d, totals30d) {
+export function renderChannelPills(sunTotals7d, deviceTotals7d = {}) {
const ch = getChannelDisplay();
- // Tier classifiers: weekly for v7 and 30-day equivalent for v30 by scaling the threshold band to the longer span. Mixing daily-target classification on a multi-day total
- // double-counts and wrecks the trend arrow (t30 ALWAYS scored higher
- // than t7 because totals scale with window even when the daily rate is
- // identical, so the trend read "down" on every flat pattern).
- const tlabel = lightChannelDeps.tierLabel;
- const tier7 = lightChannelDeps.weeklyChannelTier;
- const tier30 = (v, k) => {
- const target = ((ch[k] && ch[k].dailyTarget) || 1000) * 30;
- if (!Number.isFinite(v) || v <= 0) return 0;
- const r = v / target;
- if (r < 0.20) return 1;
- if (r < 0.55) return 2;
- if (r < 1.00) return 3;
- return 4;
- };
const order = ['vitamin_d', 'circadian', 'nir_solar', 'no_cv', 'pomc', 'violet_eye'];
let html = `
`;
@@ -161,13 +141,13 @@ const CHANNEL_CITATIONS = {
refs: [
{ cite: 'Webb AR & Engelsen O (2006). "Calculated ultraviolet exposure levels for a healthy vitamin D status." Photochem Photobiol 82:1697',
href: 'https://pubmed.ncbi.nlm.nih.gov/16958558/',
- why: 'Dose-response calculations that justify the UVI ≥ 2-3 threshold the engine uses' },
+ why: 'Shows how vitamin-D-effective UV varies by place and season; the engine uses its spectral integral rather than a universal UVI cliff' },
{ cite: 'Holick MF (2007). "Vitamin D Deficiency." NEJM 357:266',
href: 'https://www.nejm.org/doi/full/10.1056/NEJMra070553',
why: 'Most-cited modern clinical review of the vitamin D pathway, including the per-session photoisomerization plateau (skin converts excess previtamin-D to inert tachysterol/lumisterol at high doses)' },
{ cite: 'Bogh MK & Wulf HC (2010). "Vitamin D production after UVB exposure depends on baseline 25(OH)D and total cholesterol." J Invest Dermatol 130:546',
href: 'https://pubmed.ncbi.nlm.nih.gov/19812604/',
- why: 'Per-session IU yield variability — why the model bands at ±20-45% per zenith and biological response adds another 2-3×' },
+ why: 'Shows large per-session response variability and why the IU-equivalent band must remain broad' },
],
},
circadian: {
@@ -185,17 +165,20 @@ const CHANNEL_CITATIONS = {
],
},
nir_solar: {
- spectrum: 'Cytochrome-c-oxidase absorption (660-850 nm windows). Solar NIR and narrowband PBM share the same chromophore — sunlight just delivers a broadband version of what panels do.',
+ spectrum: 'Red and near-infrared light, roughly 600–1400 nm. Sunlight and targeted devices are shown separately because their spectra and delivery are different.',
refs: [
+ { cite: '"Longer wavelengths in sunlight pass through the human body and have a systemic impact which improves vision." Scientific Reports 15:24435 (2025)',
+ href: 'https://pubmed.ncbi.nlm.nih.gov/40628952/',
+ why: 'Human work measuring long-wave sunlight through the body and testing a separate 850 nm body exposure' },
+ { cite: '"A Controlled Trial to Determine the Efficacy of Red and Near-Infrared Light Treatment." Photomed Laser Surg 32:93 (2014)',
+ href: 'https://doi.org/10.1089/pho.2013.3616',
+ why: 'A controlled study of broad red and near-infrared light for the specific skin outcomes tested' },
+ { cite: '"Melatonin and the Optics of the Human Body." Melatonin Research 2:138 (2019)',
+ href: 'https://doi.org/10.32794/MR11250016',
+ why: 'Introduces the proposed link between near-infrared light, body optics, and melatonin inside cells' },
{ cite: 'Hamblin MR (2018). "Mechanisms and Mitochondrial Redox Signaling in Photobiomodulation." Photochem Photobiol 94:199',
href: 'https://pubmed.ncbi.nlm.nih.gov/29164625/',
- why: 'Comprehensive review of how 600–1000 nm light reaches mitochondrial cytochrome c oxidase and triggers redox signaling — the same pathway whether the photons come from sunlight or a panel' },
- { cite: 'Hamblin MR (2017). "Mechanisms and applications of the anti-inflammatory effects of photobiomodulation." AIMS Biophys 4:337',
- href: 'https://pubmed.ncbi.nlm.nih.gov/28748217/',
- why: 'Mechanism review focused on the anti-inflammatory effects — applies equally to narrowband panels and the NIR component of broadband solar' },
- { cite: 'Karu TI (2010). "Multiple roles of cytochrome c oxidase in mammalian cells under action of red and IR-A radiation." IUBMB Life 62:607',
- href: 'https://pubmed.ncbi.nlm.nih.gov/20681024/',
- why: 'Cytochrome c oxidase as the primary photoacceptor — the molecular target underlying every NIR effect' },
+ why: 'Reviews several ways red and near-infrared light may interact with cell energy and signaling' },
],
},
no_cv: {
@@ -206,7 +189,7 @@ const CHANNEL_CITATIONS = {
why: 'Controlled mechanistic crossover trial showing UVA on skin lowers BP via photo-released NO from skin stores (NOT via vit-D)' },
{ cite: 'Lindqvist PG et al. (2016). "Avoidance of sun exposure as a risk factor for major causes of death." J Intern Med 280:375',
href: 'https://pubmed.ncbi.nlm.nih.gov/26992108/',
- why: '20-year Swedish cohort: sun-avoidance carries all-cause mortality risk comparable to smoking' },
+ why: 'Observational association over 20 years; it does not prove causality or make intentional UV exposure a treatment' },
{ cite: 'Feelisch M et al. (2010). "Is sunlight good for our heart?" Eur Heart J 31:1041',
href: 'https://pubmed.ncbi.nlm.nih.gov/20215123/',
why: 'Foundational hypothesis paper laying out the UVA→NO→cardiovascular mechanism' },
@@ -227,11 +210,11 @@ const CHANNEL_CITATIONS = {
],
},
violet_eye: {
- spectrum: 'Violet 360-400 nm at the eye → OPN5/neuropsin + retinal dopamine release (cone-mediated). Distinct from the ipRGC/melanopic 490-nm circadian pathway.',
+ spectrum: 'Outdoor violet-light hypothesis, roughly 360–400 nm at the eye. Human evidence is stronger for time outdoors than for a wavelength-specific dose, and this is not a reason to expose unprotected eyes to UV.',
refs: [
{ cite: 'Torii H et al. (2017). "Violet light exposure can be a preventive strategy against myopia progression." EBioMedicine 15:210',
href: 'https://pubmed.ncbi.nlm.nih.gov/28063778/',
- why: 'Foundational paper linking 360-400 nm violet light at the eye to slowed myopia progression in children' },
+ why: 'Early human and experimental evidence for a violet-light hypothesis; it does not establish a safe eye-exposure dose' },
{ cite: 'Rose KA et al. (2008). "Outdoor activity reduces the prevalence of myopia in children." Ophthalmology 115:1279',
href: 'https://pubmed.ncbi.nlm.nih.gov/18294691/',
why: 'Cohort of >4000 kids (1,765 six-year-olds + 2,367 twelve-year-olds): time outdoors (not near-work) is the protective factor against myopia' },
@@ -267,40 +250,25 @@ function _renderChannelCitations(channelKey) {
);
const suggestLink = `
${suggestLink}
`;
}
-// 7-day stacked bar chart: per-day sun + device totals for one channel.
-// Always renders (even all-zero days) so the user has a baseline visual
-// reference. Includes a dashed target line at (dailyTarget / 7) so the
-// per-day chart shows what "hitting your weekly target evenly" looks
-// like. Numeric labels above each bar surface the actual numbers when
-// non-zero.
+// Seven-day source-aware history. Bars show when modeled light reached the
+// channel; they are deliberately scaled to the user's own week and have no
+// target line, completion mark, or good/bad color.
function _renderChannelWeekChart(channelKey) {
const breakdown = lightChannelDeps.dailyChannelBreakdown;
if (!breakdown) return '';
const days = breakdown(channelKey, 7);
- // For vit-D, pull a per-day IU breakdown that uses the same per-session
- // math as rollingVitaminDIU (real Fitz/UVI/rotation/genetics/body-frac
- // cap). Bar height + tier color still use channel-au from `days` for
- // continuity with the sparkline; only the numeric label switches to
- // per-session-accurate IU so it agrees with the session-row IU readout.
const iuDays = (channelKey === 'vitamin_d' && lightChannelDeps.dailyVitaminDIUBreakdown)
? lightChannelDeps.dailyVitaminDIUBreakdown(7)
: null;
- const ch = getChannelDisplay();
- const meta = ch[channelKey] || {};
- const dailyTarget = meta.dailyTarget || 0;
- const dailyTargetSlice = dailyTarget; // chart is per-day, so target IS the daily target
- const observedMax = Math.max(0, ...days.map(d => d.sun + d.device));
- // Anchor the chart to whichever is bigger — the highest day or the
- // target-per-day line. Without this, very-low-dose weeks compress
- // the target off the top of the chart and lose context.
- const max = Math.max(observedMax, dailyTargetSlice * 1.2, 0.001);
+ const observedMax = Math.max(0, ...days.flatMap(d => [d.sun, d.device]));
+ const max = Math.max(observedMax, 0.001);
const W = 280, H = 96, padX = 18, padTop = 14, padBottom = 16;
const innerH = H - padTop - padBottom;
@@ -309,20 +277,12 @@ function _renderChannelWeekChart(channelKey) {
const dayLetter = (date) => 'SMTWTFS'[date.getDay()];
const today = new Date(); today.setHours(0,0,0,0);
- // Per-day number formatter — converts channel-au into the channel's
- // natural unit so the chart labels match the hero's unit. Channel-au
- // by itself is dimensionless ("576K of what?"); always show something
- // human-readable.
- //
- // Returns "" for zero/sub-meaningful values so the chart doesn't get
- // peppered with "0%" labels on empty days.
+ // Keep real-unit labels only where the app already exposes a defensible
+ // estimate. Other channels use the shape of the bars without inventing a
+ // percentage of biological sufficiency.
const fmt = (n, dayIdx) => {
if (!Number.isFinite(n) || n < 0.5) return '';
if (channelKey === 'vitamin_d') {
- // Use the per-session IU breakdown (same math as the session row
- // and the rollingVitaminDIU hero) rather than the old Fitz-III /
- // uvi-7 / no-genetics approximation that diverged 20-50% from the
- // session-row IU on real sessions.
const iu = iuDays && dayIdx != null
? (iuDays[dayIdx]?.sun || 0) + (iuDays[dayIdx]?.device || 0)
: 0;
@@ -338,302 +298,141 @@ function _renderChannelWeekChart(channelKey) {
if (j >= 1) return j.toFixed(1);
return j.toFixed(2);
}
- // Unitless channels — show percent-of-daily-target so the day-vs-day
- // comparison reads as % of typical day, not raw channel-au.
- if (dailyTarget > 0) {
- const pct = Math.round(100 * n / dailyTarget);
- if (pct === 0) return '';
- return `${pct}%`;
- }
return '';
};
// Empty-day placeholder bar so the chart never reads as a giant blank.
const placeholderH = 3;
- // Color bar by how the day's dose stacks up against the daily target.
- // Visual at-a-glance: green = hit/exceeded daily, accent = meaningful,
- // muted = marginal. Encourages reading the chart as "did I check this
- // box today?" instead of "what big number did I rack up?".
- const dayThreshold = _CHANNEL_DAY_THRESHOLD[channelKey] ?? 0.30;
- const colorForDay = (total) => {
- if (dailyTarget <= 0 || total < dailyTarget * 0.05) return { fill: 'var(--text-muted)', op: 0.40 };
- if (total >= dailyTarget) return { fill: 'var(--green)', op: 1.0 };
- if (total >= dailyTarget * dayThreshold) return { fill: 'var(--channel-accent, var(--accent))', op: 0.85 };
- return { fill: 'var(--channel-accent, var(--accent))', op: 0.45 };
- };
-
const bars = days.map((d, i) => {
const x = padX + i * barW + (barW - barInner) / 2;
const total = d.sun + d.device;
- const h = total > 0 ? (total / max) * innerH : placeholderH;
const sunH = total > 0 ? (d.sun / max) * innerH : 0;
const devH = total > 0 ? (d.device / max) * innerH : 0;
- const y = padTop + innerH - h;
+ const sourceGap = 2;
+ const sourceBarW = (barInner - sourceGap) / 2;
const isToday = d.date.getTime() === today.getTime();
- const labelTxt = total > 0 ? fmt(total, i) : '';
- const { fill: barFill, op: barOp } = colorForDay(total);
- // Hit-target check mark — greener visual cue when the day cleared the
- // daily target line. Reduces the urge to chase higher percentages
- // ("more is better") past the saturation point.
- const checkMark = (dailyTarget > 0 && total >= dailyTarget) ? `✓` : '';
+ // A single numeric label is shown only when one source is present. When
+ // both are logged, adding them into one label would undo the source split.
+ const labelTxt = d.sun > 0 && d.device <= 0
+ ? fmt(d.sun, i)
+ : d.device > 0 && d.sun <= 0 ? fmt(d.device, i) : '';
+ const labelY = padTop + innerH - Math.max(sunH, devH) - 2;
return `
${total > 0 ? '' : ``}
- ${devH > 0 ? `` : ''}
- ${sunH > 0 ? `` : ''}
- ${checkMark}
- ${labelTxt && !checkMark ? `${labelTxt}` : ''}
+ ${sunH > 0 ? `` : ''}
+ ${devH > 0 ? `` : ''}
+ ${labelTxt ? `${labelTxt}` : ''}
${dayLetter(d.date)}`;
}).join('');
- // Target line — dashed accent, drawn under the bars so the bar fills sit
- // on top of it visually. Surfaces the "what hitting the weekly target
- // evenly looks like" reference. Only meaningful when target > 0.
- const targetLine = dailyTargetSlice > 0
- ? `
- target`
- : '';
-
// SR readable summary
const dayName = (date) => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][date.getDay()];
const srRows = days.map(d => {
const total = d.sun + d.device;
if (total < 0.0001) return `${dayName(d.date)}: no exposure`;
- if (d.device > 0 && d.sun > 0) return `${dayName(d.date)}: sun ${fmt(d.sun)}, device ${fmt(d.device)}`;
- if (d.sun > 0) return `${dayName(d.date)}: sun ${fmt(d.sun)}`;
- return `${dayName(d.date)}: device ${fmt(d.device)}`;
+ if (d.device > 0 && d.sun > 0) return `${dayName(d.date)}: sunlight and device signal logged`;
+ if (d.sun > 0) return `${dayName(d.date)}: sunlight signal logged`;
+ return `${dayName(d.date)}: device signal logged`;
}).join('. ');
- return `
-
7-day rhythm sun · device · target
+ return `
+
7-day rhythm sunlight · device
`;
}
-// Threshold (fraction of daily target) above which a day counts as
-// "meaningful exposure" toward this channel. Stricter for the eye-bound
-// circadian/violet channels because the biological response requires
-// real entrainment-strength dose, not a brief glance.
-const _CHANNEL_DAY_THRESHOLD = {
- vitamin_d: 0.30,
- nir_solar: 0.30,
- no_cv: 0.30,
- pomc: 0.30,
- circadian: 0.50,
- violet_eye: 0.50,
-};
-
-// Count days in the breakdown where the day's combined dose hit at least
-// `threshold × dailyTarget`. Sub-meaningful days don't count — partial
-// glance light isn't biologically equivalent to a real dose.
-function _meaningfulDayCount(days, dailyTarget, threshold) {
- if (!Array.isArray(days) || dailyTarget <= 0) return 0;
- const floor = threshold * dailyTarget;
- let n = 0;
- for (const d of days) {
- if ((d.sun + d.device) >= floor) n++;
+function _signalDays(days) {
+ if (!Array.isArray(days)) return { any: 0, sun: 0, device: 0 };
+ let any = 0, sun = 0, device = 0;
+ for (const day of days) {
+ if (_hasSignal(day.sun + day.device)) any++;
+ if (_hasSignal(day.sun)) sun++;
+ if (_hasSignal(day.device)) device++;
}
- return n;
+ return { any, sun, device };
}
-// Hero stat for a channel — leads with DAILY CONSISTENCY ("3 of 7
-// days") instead of weekly cumulative. Health-wise, daily exposure
-// matters more than banking one big day for every channel here:
-// circadian needs daily entrainment, vit-D plateaus per session
-// around 20k IU, NO release dissipates, NIR benefit is dose-per-
-// exposure not banked. The "X of 7 days" framing matches the biology;
-// the cumulative real-unit (IU / J/cm²) when defensible is shown as
-// a sub-line for completeness.
-function _channelHero(channelKey, totalCurrent, days7, daysPrev7, weeklyTier = 0) {
- const meta = getChannelDisplay()[channelKey] || {};
- const target = meta.dailyTarget || 0;
- const threshold = _CHANNEL_DAY_THRESHOLD[channelKey] ?? 0.30;
- const tierLabelFor = lightChannelDeps.tierLabel;
- const tierColors = ['muted', 'tier1', 'tier2', 'tier3', 'tier4'];
- const tierPill = `${escapeHTML(tierLabelFor(weeklyTier))} this week`;
- const fmtIntK = (n) => {
- if (n < 10) return n.toFixed(1);
- if (n < 1000) return String(Math.round(n));
- if (n < 10000) return (n / 1000).toFixed(1) + 'k';
- return (n / 1000).toFixed(0) + 'k';
- };
-
- const dayCountCur = _meaningfulDayCount(days7, target, threshold);
- const dayCountPrev = _meaningfulDayCount(daysPrev7, target, threshold);
-
- // Cumulative real-unit summary (always computed; only shown if defensible).
- let cumulative = '';
- if (channelKey === 'vitamin_d' && lightChannelDeps.rollingVitaminDIU) {
- const iu = lightChannelDeps.rollingVitaminDIU(7);
- if (iu >= 30) cumulative = `· ~${fmtIntK(iu)} IU total`;
- } else if (channelKey === 'nir_solar' && lightChannelDeps.pbmJoulesPerCm2) {
- const j = lightChannelDeps.pbmJoulesPerCm2(totalCurrent);
- if (j >= 0.1) cumulative = `· ${j >= 10 ? Math.round(j) : j.toFixed(1)} J/cm² total`;
- }
-
- let primary = '';
- let primarySub = '';
- if (totalCurrent < 0.5 && dayCountCur === 0) {
- primary = '—';
- primarySub = 'no exposure logged this week';
- } else {
- primary = `${dayCountCur} of 7 days`;
- // Channel-aware sub-label — what counts as "meaningful exposure"
- // varies per channel, but the framing stays consistent.
- const SUB_LABELS = {
- vitamin_d: 'with meaningful UVB synthesis',
- nir_solar: 'with meaningful NIR exposure',
- circadian: 'with strong morning/midday daylight in your eyes',
- no_cv: 'with meaningful UVA on bare skin',
- pomc: 'with meaningful sun on bare skin',
- violet_eye: 'with strong outdoor light reaching your eyes',
- };
- const subBase = SUB_LABELS[channelKey] || 'with meaningful exposure';
- primarySub = `${subBase} ${cumulative}`.trim();
- }
-
- // Trend = day-count delta vs last week. Same unit (days) so comparison
- // reads naturally without conversion gymnastics.
- let trend = '';
- if (dayCountCur > 0 || dayCountPrev > 0) {
- const delta = dayCountCur - dayCountPrev;
- if (delta >= 1) {
- trend = `↑ ${delta} more day${delta === 1 ? '' : 's'} than last week`;
- } else if (delta <= -1) {
- trend = `↓ ${-delta} fewer day${delta === -1 ? '' : 's'} than last week`;
- } else if (dayCountCur > 0) {
- trend = `~ same day count as last week`;
- } else {
- trend = `↓ no qualifying days this week (had ${dayCountPrev} last week)`;
- }
- }
-
+function _channelHero(sunCurrent, deviceCurrent, days7) {
+ const counts = _signalDays(days7);
+ const primary = _sourceSignalLabel(sunCurrent, deviceCurrent);
+ const sourceDays = [counts.sun ? `sunlight on ${counts.sun} day${counts.sun === 1 ? '' : 's'}` : '', counts.device ? `device on ${counts.device} day${counts.device === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ');
+ const sub = counts.any
+ ? `${sourceDays}. This records exposure, not biological completion.`
+ : 'No matching exposure was recorded this week. That is a logging result, not a deficiency.';
return `
${escapeHTML(primary)}
- ${tierPill}
-
${escapeHTML(primarySub)}
- ${trend}
+
${escapeHTML(sub)}
`;
}
-// Caption explaining why daily exposure beats banking one big day.
-// Channel-specific so the reason is biologically grounded, not generic.
-function _renderDailyBeatsBankingNote(channelKey) {
+function _renderHowToReadNote(channelKey) {
const NOTES = {
- vitamin_d: 'Skin photoisomerizes excess back to inactive isomers around 20k IU per session — daily 10-min sessions outperform one big day (Holick 2007, Webb 2018).',
- nir_solar: 'Mitochondrial benefit is dose-dependent per exposure, not banked — daily 20-min walks deliver more cumulative cellular signal than one long session.',
- circadian: 'Body clock entrainment depends on daily timing of morning light — one banked day doesn\'t prevent the next day\'s drift toward later sleep onset.',
- no_cv: 'UVA-driven nitric oxide release happens during exposure and dissipates over hours — daily refreshes the vasodilatory + BP-lowering signal.',
- pomc: 'POMC pathway tone resets between sessions — daily sun maintains α-MSH (tan signal) and β-endorphin (mood) baseline rather than spiking and crashing.',
- violet_eye: 'Violet-eye dopamine release is per-exposure — daily outdoor minutes accumulate the myopia-protective + alertness signal that one long day can\'t bank.',
+ vitamin_d: 'This shows when vitamin-D-effective UVB reached uncovered skin. It is not a reason to stay outside longer.',
+ nir_solar: 'This records red and near-infrared exposure. Sunlight and a targeted device are not treated as the same experience.',
+ circadian: 'For the body clock, timing matters most. Morning light and evening light can send different signals.',
+ no_cv: 'This is a modeled skin-light signal, not a blood-pressure reading or a reason to seek more UVA.',
+ pomc: 'This shows light that may start the skin pathway. It does not measure hormones or mood.',
+ violet_eye: 'This is an early-stage pathway model. Use normal ambient outdoor light and never stare at the sun.',
};
const txt = NOTES[channelKey];
if (!txt) return '';
- return `
Daily beats banking. ${escapeHTML(txt)}
`;
+ return `
How to read it. ${escapeHTML(txt)}
`;
}
-// Source-mix mini bar — what fraction of the week's dose came from sun
-// vs from devices. Surfaces hidden context (e.g. "your circadian channel
-// is 90% from your dawn simulator, 10% from outdoor sun").
-function _renderChannelSourceMix(sun, dev) {
- const total = sun + dev;
- if (total < 0.5) return '';
- const sunPct = Math.round(100 * sun / total);
- const devPct = 100 - sunPct;
- // Hide when one source is essentially zero — no useful "mix" to show.
- if (sunPct >= 99 || sunPct <= 1) return '';
- return `
+ Sunlight: ${hasSun ? 'logged' : 'not logged'}
+ Device: ${hasDevice ? 'logged separately' : 'not logged'}
+ ${hasSun && hasDevice ? 'Both reached this pathway, but they are not combined into one score.' : ''}
`;
}
-// Channel-specific "next move" — a concrete recipe the user can act on
-// right now. Picks the best CTA based on channel + tier + available
-// devices + current sun conditions.
-function _channelNextMove(channelKey, t7, devices, atm) {
+// One plain-language takeaway. It explains the logged signal without asking
+// the user to fill a channel or exposing an artificial sufficiency tier.
+function _channelNextMove(channelKey, hasSun, hasDevice, devices, atm) {
const matchingDevice = (devices || []).find(d => Array.isArray(d.channels) && d.channels.includes(channelKey));
- const dev = matchingDevice ? escapeHTML(`${matchingDevice.brand} ${matchingDevice.model}`) : '';
const peakTime = atm?.daily?.peakAt || null;
const peakUVI = atm?.daily?.uvIndexMax ?? null;
const peakHHMM = peakTime ? new Date(peakTime).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : null;
- // Per-channel recipes — each returns a concrete, time-aware suggestion.
const recipes = {
- vitamin_d: {
- empty: `UVB on bare skin makes vitamin D — needs UVI ≥ 3 and no glass. ${peakHHMM && peakUVI >= 3 ? `Today's UV peaks at ${peakHHMM} (UVI ${peakUVI.toFixed(1)}). 15-20 min in shorts at peak ≈ 1,000-2,000 IU.` : 'Glass blocks UVB; window-side sun yields zero.'}${matchingDevice ? ` Or a session on your ${dev}.` : ''}`,
- low: `${peakHHMM && peakUVI >= 3 ? `UV peaks at ${peakHHMM} (UVI ${peakUVI.toFixed(1)}). One more 15-20 min midday session this week tips you to good range.` : 'A midday session on a clear day would tip you up.'}${matchingDevice ? ` Or a longer session on your ${dev}.` : ''}`,
- mod: `Solid weekly base. ${peakHHMM ? `Today's peak: ${peakHHMM} (UVI ${peakUVI?.toFixed(1) || '?'}).` : 'Keep your current rhythm.'} One more session this week reaches strong.`,
- good: `Strong week. Consistency matters more than intensity from here — same rhythm next week maintains 25(OH)D.`,
- strong:`Above typical-week target. Pull back if you're seeing pinkness; otherwise this is a solid trajectory for serum 25(OH)D.`,
- },
- circadian: {
- empty: `Get morning daylight in your eyes — ideally outdoors before work, no sunglasses, no glass. 10-30 min in the first 2 hours after sunrise = strongest entrainment.${matchingDevice ? ` Or 30 min on your ${dev} on overcast days.` : ''}`,
- low: `Add a 15-20 min outdoor walk in your morning routine. Even cloudy mornings deliver 10-50× more melanopic light than indoor lighting.${matchingDevice ? ` Or a session on your ${dev}.` : ''}`,
- mod: `Healthy entrainment dose. Mornings have the biggest effect on sleep onset that night — keep prioritizing AM over midday.`,
- good: `Strong circadian signal. Consistent daily timing matters more than total dose at this point.`,
- strong:`Strong consistent entrainment. Watch for evening light contamination (cool LEDs after sunset) which can blunt melatonin even with strong AM exposure.`,
- },
- nir_solar: {
- empty: `Solar NIR is half of sunlight (600-1400 nm). 30-60 min outdoors at any time of day delivers a meaningful dose; window glass blocks ~70% of long NIR.${matchingDevice ? ` Or a 10-20 min session on your ${dev}.` : ''}`,
- low: `Add an outdoor walk this week — sunrise/sunset light is NIR-rich and won't push burn dose.${matchingDevice ? ` Or 15 min on your ${dev}.` : ''}`,
- mod: `Solid base. NIR doesn't need to be midday — golden-hour light delivers comparable dose without UVB burn risk.`,
- good: `Strong weekly NIR. Mitochondrial repair signal is well-saturated for this week.`,
- strong:`Above typical-week NIR. No upper safety concern from broadband NIR — this is a maintenance pattern.`,
- },
- no_cv: {
- empty: `UVA on bare skin (320-400 nm) photo-releases nitric oxide from skin stores. ${peakHHMM && peakUVI >= 3 ? `15-30 min outdoors anytime UV is up — today peaks at ${peakHHMM} (UVI ${peakUVI.toFixed(1)}).` : 'Open-sky exposure during daylight hours.'} Sunscreen partially blocks UVA — bare-skin sessions count more.`,
- low: `Add a 20-30 min outdoor session this week with face + arms uncovered. UVA accumulates throughout the day, not just at solar noon.`,
- mod: `Healthy weekly UVA dose — sustained NO release supports BP + arterial function (Liu 2014).`,
- good: `Strong NO/cardiovascular signal. Good aggregate UVA exposure for vasodilatory benefit.`,
- strong:`Above typical-week UVA. Be mindful of cumulative photoaging if this is a daily pattern; UVA is the long-wavelength culprit.`,
- },
- pomc: {
- empty: `UVA + UVB on skin keratinocytes triggers POMC → α-MSH (tan signal) + β-endorphin (the "feels good in the sun" effect). Same recipe as cardiovascular — open-sky daylight on bare skin.`,
- low: `Same path as vit-D and NO/CV: midday outdoor sessions on bare skin. One more session this week tips you up.`,
- mod: `Healthy weekly POMC pathway activation.`,
- good: `Solid mood-hormone weekly signal.`,
- strong:`Above typical-week POMC stimulus.`,
- },
- violet_eye: {
- empty: `Outdoor violet 360-440 nm hits ipRGC sensors in the eye — different from "bright window light," which window glass attenuates. 15-30 min outdoors with eyes uncovered (no sunglasses, no glass) builds the dopamine signal linked to myopia control + alertness.`,
- low: `Add an outdoor walk with eyes uncovered (no sunglasses) this week. Even 10 min counts — this channel saturates quickly.`,
- mod: `Healthy weekly outdoor-violet dose.`,
- good: `Solid violet-eye signal — keep eyes uncovered during morning outdoor time for the strongest effect.`,
- strong:`Above typical-week. Sunglasses are still appropriate at high UVI for eye safety; the violet signal banks well below sunburn risk levels.`,
- },
+ vitamin_d: hasSun
+ ? 'Vitamin-D-effective UVB reached uncovered skin in your sunlight log. Keep following the burn guide; more is not automatically better.'
+ : `${peakHHMM && peakUVI >= 3 ? `UV is expected to peak near ${peakHHMM} today. ` : ''}This signal only appears when vitamin-D-effective UVB reaches uncovered skin. Its absence is not a prompt to extend exposure.`,
+ circadian: hasSun
+ ? 'Outdoor light reached the eyes in your recent logs. Keep the timing steady; morning and evening light do not send the same message.'
+ : 'Normal ambient outdoor light in the morning can provide a clear day signal. There is no need to look at the sun.',
+ nir_solar: hasSun
+ ? `Your sunlight log included red and near-infrared light.${hasDevice ? ' The device signal is shown separately because it is more targeted.' : ''}`
+ : 'An outdoor session naturally includes red and near-infrared light as part of the wider daylight spectrum.',
+ no_cv: hasSun
+ ? 'A UVA-related skin signal was modeled in your sunlight log. This does not measure blood pressure or make extra UVA advisable.'
+ : 'This signal appears when UVA reaches logged skin. No signal means it was not modeled, not that your body is deficient.',
+ pomc: hasSun
+ ? 'Sunlight reached a skin pathway linked with pigment and neuroendocrine signaling. The app does not measure hormones or mood.'
+ : 'This pathway appears when the relevant sunlight reaches logged skin. It is something to observe, not a UV goal.',
+ violet_eye: hasSun
+ ? 'Ambient outdoor light reached this modeled eye pathway. Keep normal eye protection when needed and never stare at the sun.'
+ : 'A normal outdoor walk can provide ambient violet light. The human biology is still being studied.',
};
- const r = recipes[channelKey] || {};
- let txt = '';
- if (t7 === 0) txt = r.empty || '';
- else if (t7 === 1) txt = r.low || '';
- else if (t7 === 2) txt = r.mod || '';
- else if (t7 === 3) txt = r.good || '';
- else txt = r.strong || '';
+ const txt = recipes[channelKey] || '';
if (!txt) return '';
- // Action button — channel-keyed; sun channels lead with "Log a sun
- // session", device-only channels lead with the device dialog. Mixed
- // channels surface both.
- const showSun = true; // every channel can be filled with sun
const showDev = !!matchingDevice;
const buttons = `
- ${showSun ? `☀ Log a sun session` : ''}
+ ☀ Log a sun session
${showDev ? `🔴 Log device session` : ''}`;
return `
-
Next move
+
Simple takeaway
${txt}
${buttons}
`;
@@ -643,36 +442,26 @@ function _channelNextMove(channelKey, t7, devices, atm) {
// `[data-channel-detail-slot]` container when the user taps a pill.
//
// Layout (top → bottom):
-// 1. Header: icon + title + tier pill + close
-// 2. Hero stat: real-unit aggregate this week (or empty-state)
+// 1. Header: icon + title + close
+// 2. Plain source-aware status
// 3. What it does: one-sentence description
-// 4. Source mix bar: sun vs device split (when both contribute)
-// 5. 7-day chart with target line + numeric labels
-// 6. Next move: channel-specific concrete recipe + action button
-// 7. Action spectrum + paper citations (expandable)
+// 4. Sunlight/device source labels
+// 5. 7-day rhythm without a target line
+// 6. Simple takeaway + action buttons
+// 7. Research citations (expandable)
function _renderChannelDetailPanel(channelKey) {
const ch = getChannelDisplay();
const meta = ch[channelKey] || {};
- // Drill-down hero stat is a 7-day total — classify against the weekly
- // target so the badge agrees with the pill (and the AI rollup).
- const tier = lightChannelDeps.weeklyChannelTier;
const sunTot7 = (typeof lightChannelDeps.rollingChannelTotals === 'function' ? lightChannelDeps.rollingChannelTotals(7) : null) || {};
const devTot7 = (typeof lightChannelDeps.rollingDeviceTotals === 'function' ? lightChannelDeps.rollingDeviceTotals(7) : null) || {};
const sun7 = sunTot7[channelKey] || 0;
const dev7 = devTot7[channelKey] || 0;
- const totalCurrent = sun7 + dev7;
- const t7 = tier(totalCurrent, channelKey);
- // Previous-week total via 14-day breakdown (first 7 days = the
- // preceding week, last 7 days = current week). Lets the hero show
- // a real "vs last week" delta instead of a vague tier-vs-tier arrow.
let days7 = [];
- let daysPrev7 = [];
try {
const breakdown = lightChannelDeps.dailyChannelBreakdown;
if (breakdown) {
const days14 = breakdown(channelKey, 14);
- daysPrev7 = days14.slice(0, 7);
days7 = days14.slice(7);
}
} catch (e) {}
@@ -690,17 +479,17 @@ function _renderChannelDetailPanel(channelKey) {
×
- ${_channelHero(channelKey, totalCurrent, days7, daysPrev7, t7)}
+ ${_channelHero(sun7, dev7, days7)}
`;
@@ -713,8 +502,8 @@ function _renderChannelDetailPanel(channelKey) {
// after navigation. Already on Light? Just toggle in place.
export function _openChannelOnLightPage(channelKey) {
// Helper: scroll the expanded panel into view + briefly flash so the
- // user notices when they're already on the Light page (no navigation
- // landing-on-target cue) and the panel may be far below the fold.
+ // user notices when they're already on the Light page and the panel may
+ // be far below the fold.
const flashPanel = () => {
const panel = document.getElementById(`light-pill-detail-${channelKey}`);
if (!panel) return;
@@ -769,26 +558,103 @@ export function _toggleChannelDetail(channelKey) {
}
}
-// One-line action suggestion based on the lowest-tier channel.
-export function renderSuggestion(totals7d) {
- // Suggestion picks the lowest-tier channel from a 7-day total, so use
- // the weekly classifier — otherwise it nudges every channel as "low"
- // because each one is being compared to a daily target.
- const tier = lightChannelDeps.weeklyChannelTier;
- const order = ['vitamin_d', 'circadian', 'nir_solar', 'no_cv', 'pomc', 'violet_eye'];
- const SUGGESTIONS = {
- vitamin_d: 'Get 10–15 minutes of midday sun on bare skin if your latitude allows — UVB drops sharply after 2 pm.',
- circadian: '10 minutes of outdoor light before 9 am tends to be the highest-leverage move for your sleep.',
- nir_solar: 'Solar near-infrared is highest mid-morning to late afternoon. A walk outside catches the half of sunlight that windows block.',
- no_cv: 'Afternoon UVA-rich daylight on uncovered skin supports blood-vessel health and circulation.',
- pomc: 'A few minutes more uncovered daylight on skin engages the mood-hormone cascade.',
- violet_eye: 'Outdoor 360–400 nm light reaches your eyes only outside — even a few extra minutes helps.',
- };
- let worstKey = null, worstTier = 5;
- for (const k of order) {
- const t = tier(totals7d[k] || 0, k);
- if (t < worstTier) { worstTier = t; worstKey = k; }
+/**
+ * @param {Array> | null | undefined} sessions
+ * @param {number} start
+ * @param {number} end
+ * @returns {{ sessions: number, days: number }}
+ */
+function _weeklySessionSummary(sessions, start, end) {
+ const completed = (Array.isArray(sessions) ? sessions : []).filter(session => {
+ const timestamp = Number(session?.endedAt || 0);
+ return timestamp >= start && timestamp < end;
+ });
+ const days = new Set(completed.map(session => {
+ const date = new Date(Number(session.endedAt));
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+ }));
+ return { sessions: completed.length, days: days.size };
+}
+
+function _sessionCountText(count, source) {
+ return `${count} ${source} session${count === 1 ? '' : 's'}`;
+}
+
+// Calm seven-day summary for users without AI, and the grounding content
+// shown before an AI review is requested. It compares logging patterns only:
+// missing records never become a claim that the person received no light.
+// Session arrays are optional so older consumers that only have channel
+// totals still receive a useful source summary.
+/**
+ * @param {Record} [sunTotals7d]
+ * @param {Record} [deviceTotals7d]
+ * @param {Array> | null} [sunSessions]
+ * @param {Array> | null} [deviceSessions]
+ * @returns {string}
+ */
+export function renderSuggestion(sunTotals7d = {}, deviceTotals7d = {}, sunSessions = null, deviceSessions = null) {
+ const hasSun = Object.values(sunTotals7d).some(_hasSignal);
+ const hasDevice = Object.values(deviceTotals7d).some(_hasSignal);
+ const now = Date.now();
+ const dayMs = 86400000;
+ const hasSessionHistory = Array.isArray(sunSessions) || Array.isArray(deviceSessions);
+ const currentSun = hasSessionHistory
+ ? _weeklySessionSummary(sunSessions, now - 7 * dayMs, now)
+ : { sessions: hasSun ? 1 : 0, days: hasSun ? 1 : 0 };
+ const currentDevice = hasSessionHistory
+ ? _weeklySessionSummary(deviceSessions, now - 7 * dayMs, now)
+ : { sessions: hasDevice ? 1 : 0, days: hasDevice ? 1 : 0 };
+ const previousSun = hasSessionHistory
+ ? _weeklySessionSummary(sunSessions, now - 14 * dayMs, now - 7 * dayMs)
+ : { sessions: 0, days: 0 };
+ const previousDevice = hasSessionHistory
+ ? _weeklySessionSummary(deviceSessions, now - 14 * dayMs, now - 7 * dayMs)
+ : { sessions: 0, days: 0 };
+ const currentTotal = currentSun.sessions + currentDevice.sessions;
+ const previousTotal = previousSun.sessions + previousDevice.sessions;
+ const loggedDays = new Set();
+ if (hasSessionHistory) {
+ for (const session of [...(sunSessions || []), ...(deviceSessions || [])]) {
+ const timestamp = Number(session?.endedAt || 0);
+ if (timestamp < now - 7 * dayMs || timestamp >= now) continue;
+ const date = new Date(timestamp);
+ loggedDays.add(`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`);
+ }
+ }
+
+ let headline;
+ let nextStep;
+ if (currentTotal === 0) {
+ headline = 'No outdoor or device sessions were logged in the past 7 days. We can’t tell whether you received little light or simply didn’t record it.';
+ nextStep = 'If this reflects your week, a brief outdoor daylight break when practical can add a clear daytime signal. Ambient daylight is enough for the eyes; never stare at the sun.';
+ } else {
+ const sourceParts = [
+ currentSun.sessions ? _sessionCountText(currentSun.sessions, 'outdoor') : '',
+ currentDevice.sessions ? _sessionCountText(currentDevice.sessions, 'device') : '',
+ ].filter(Boolean);
+ const dayText = hasSessionHistory ? ` across ${loggedDays.size} logged day${loggedDays.size === 1 ? '' : 's'}` : '';
+ headline = `The past 7 days contain ${sourceParts.join(' and ')}${dayText}.`;
+ if (currentSun.sessions === 0 && currentDevice.sessions > 0) {
+ nextStep = 'Devices supply targeted light. Outdoor daylight adds a wider spectrum and time-of-day context that a device does not copy.';
+ } else if (currentSun.sessions > 0 && currentDevice.sessions > 0) {
+ nextStep = 'Sunlight and device records stay separate; neither is added into a biological completion score.';
+ } else {
+ nextStep = 'The records show exposure, not whether every light-responsive pathway received enough stimulation.';
+ }
+ }
+
+ let comparison;
+ if (previousTotal === 0 && currentTotal === 0) comparison = 'There are no logged sessions in the previous 7 days either.';
+ else if (previousTotal === 0) comparison = 'The previous 7 days contain no logged sessions for comparison.';
+ else {
+ comparison = `Previous 7 days: ${_sessionCountText(previousSun.sessions, 'outdoor')} and ${_sessionCountText(previousDevice.sessions, 'device')}.`;
}
- if (!worstKey || worstTier >= 3) return ''; // hide once everything is at least 'good'
- return `
`;
}
diff --git a/js/light-channels-ai-analysis.js b/js/light-channels-ai-analysis.js
index 7673586d..18b5d44d 100644
--- a/js/light-channels-ai-analysis.js
+++ b/js/light-channels-ai-analysis.js
@@ -1,13 +1,7 @@
// @ts-check
-// light-channels-ai-analysis.js — AI verdict for the "Your light, by
-// what it does" channel-mix section. Replaces the hardcoded
-// renderSuggestion() that picked the single lowest-tier channel and
-// returned a generic per-channel string ("10 minutes of outdoor light
-// before 9 am tends to be..."). The new verdict reasons across all 6
-// channels + 7d/30d trends + user goals + biomarkers, and crucially
-// can recommend a SINGLE action that hits multiple channels at once
-// (a morning walk feeds circadian + violet-eye + NIR + low-dose POMC
-// — much higher leverage than a per-channel nudge).
+// light-channels-ai-analysis.js — seven-day Light review. It compares the
+// most recent seven days with the seven before them, while keeping sunlight
+// and targeted devices separate and never grading biological completion.
//
// Storage: singleton at state.importedData.channelMixAI. Trigger is
// manual — channel totals shift across days as sessions roll into
@@ -15,28 +9,28 @@
// auto-fire would be wasteful.
import { state } from './state.js';
-import { escapeHTML, escapeAttr } from './utils.js';
+import { escapeHTML, showNotification } from './utils.js';
import { hasAIProvider } from './api.js';
-import { createAIVerdict, hashString, dotPrefix } from './ai-verdict-engine.js';
+import { createAIVerdict, hashString } from './ai-verdict-engine.js';
import { formatHealthGoalsText } from './health-goals-utils.js';
import { aiActionAttrs, registerAIActionHandler } from './ai-action-delegates.js';
import { getDeviceSessions, rollingDeviceTotals } from './light-devices-store.js';
-import { rollingChannelTotals, tierLabel, weeklyChannelTier } from './sun.js';
+import { rollingChannelTotals } from './sun.js';
import { getSessions } from './sun-sessions-store.js';
const lightChannelsAIAnalysisDeps = {
rollingChannelTotals,
+ rollingDeviceTotals,
getSessions,
- weeklyChannelTier,
- tierLabel,
+ getDeviceSessions,
};
/**
* @param {{
* rollingChannelTotals?: ((days: number) => Record) | null,
+ * rollingDeviceTotals?: ((days: number) => Record) | null,
* getSessions?: (() => any[]) | null,
- * weeklyChannelTier?: ((value: number, channelKey: string) => number) | null,
- * tierLabel?: ((tier: number) => string) | null,
+ * getDeviceSessions?: (() => any[]) | null,
* }} deps
*/
export function configureLightChannelsAIAnalysisDeps(deps = {}) {
@@ -60,12 +54,12 @@ function _setMix(v) {
}
const _CHANNEL_DEF = {
- vitamin_d: { label: 'Vitamin D synthesis', biology: 'UVB 290–315 nm on skin → 7-DHC → previtamin D3' },
- circadian: { label: 'Body clock / melanopic', biology: '450–490 nm at the eye → SCN melanopsin → cortisol/melatonin phase' },
- nir_solar: { label: 'Cellular repair (solar NIR)', biology: '660–850 nm penetrates deep, supports mitochondria + recovery' },
- no_cv: { label: 'Cardiovascular NO', biology: 'UVA-violet on skin → nitric oxide release → vasodilation, BP' },
- pomc: { label: 'Mood / α-MSH', biology: 'UVA on skin → POMC cleavage → α-MSH, β-endorphin' },
- violet_eye: { label: 'Violet-eye dopamine', biology: '360–440 nm at the eye → retinal dopamine, myopia + mood' },
+ vitamin_d: { label: 'Vitamin D', biology: 'vitamin-D-effective UVB reaching uncovered skin' },
+ circadian: { label: 'Body clock', biology: 'timed ambient light reaching the eyes' },
+ nir_solar: { label: 'Cell energy and repair', biology: 'red and near-infrared light reaching skin and deeper tissue; systemic effects remain under study' },
+ no_cv: { label: 'Blood-vessel signal', biology: 'UVA-related release of nitric oxide stores in skin' },
+ pomc: { label: 'Skin and mood pathway', biology: 'UV-related POMC signaling in skin; downstream response is not measured' },
+ violet_eye: { label: 'Outdoor eye light', biology: 'violet/cyan exposure at the eye; human pathway details remain exploratory' },
};
function _rollingChannelTotals(days) {
@@ -73,115 +67,164 @@ function _rollingChannelTotals(days) {
}
function _rollingDeviceTotals(days) {
- return rollingDeviceTotals(days) || {};
+ return lightChannelsAIAnalysisDeps.rollingDeviceTotals(days) || {};
}
function _getSessions() {
- return lightChannelsAIAnalysisDeps.getSessions();
+ const sessions = lightChannelsAIAnalysisDeps.getSessions();
+ return Array.isArray(sessions) ? sessions : [];
}
function _getDeviceSessions() {
- return getDeviceSessions();
+ const sessions = lightChannelsAIAnalysisDeps.getDeviceSessions();
+ return Array.isArray(sessions) ? sessions : [];
}
-function _weeklyChannelTier(value, channelKey) {
- return lightChannelsAIAnalysisDeps.weeklyChannelTier(value, channelKey);
+function _channelTotals() {
+ const sun7 = _rollingChannelTotals(7);
+ const dev7 = _rollingDeviceTotals(7);
+ return { sun7, dev7 };
}
-function _tierLabel(tier) {
- return lightChannelsAIAnalysisDeps.tierLabel(tier);
+const _DAY_MS = 86400000;
+
+function _sessionTime(session) {
+ return Number(session?.endedAt || 0);
}
-function _channelTotals() {
- const sun7 = _rollingChannelTotals(7);
- const dev7 = _rollingDeviceTotals(7);
- const sun30 = _rollingChannelTotals(30);
- const dev30 = _rollingDeviceTotals(30);
- const merge = (a, b) => {
- const out = {};
- for (const k of new Set([...Object.keys(a || {}), ...Object.keys(b || {})])) {
- out[k] = (a[k] || 0) + (b[k] || 0);
+function _sessionsInWindow(sessions, start, end) {
+ return (Array.isArray(sessions) ? sessions : []).filter(session => {
+ const timestamp = _sessionTime(session);
+ return timestamp >= start && timestamp < end;
+ });
+}
+
+function _durationMinutes(session) {
+ const recorded = Number(session?.durationMin);
+ if (Number.isFinite(recorded) && recorded >= 0) return recorded;
+ const startedAt = Number(session?.startedAt);
+ const endedAt = Number(session?.endedAt);
+ if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt) || endedAt < startedAt) return 0;
+ return (endedAt - startedAt) / 60000;
+}
+
+function _localDayKey(timestamp) {
+ const date = new Date(timestamp);
+ return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
+}
+
+function _summarizeSessions(sessions) {
+ const days = new Set();
+ const timing = { morning: 0, afternoon: 0, evening: 0 };
+ let durationMin = 0;
+ for (const session of sessions) {
+ const timestamp = Number(session?.startedAt || session?.endedAt || 0);
+ if (timestamp) {
+ days.add(_localDayKey(timestamp));
+ const hour = new Date(timestamp).getHours();
+ if (hour < 12) timing.morning++;
+ else if (hour < 17) timing.afternoon++;
+ else timing.evening++;
}
- return out;
+ durationMin += _durationMinutes(session);
+ }
+ return { count: sessions.length, days: days.size, durationMin: Math.round(durationMin), timing };
+}
+
+function _timingText(timing) {
+ return `morning ${timing.morning}; afternoon ${timing.afternoon}; evening ${timing.evening}`;
+}
+
+function _weeklyWindows(now = Date.now()) {
+ const currentStart = now - 7 * _DAY_MS;
+ const previousStart = now - 14 * _DAY_MS;
+ const sun = _getSessions();
+ const device = _getDeviceSessions();
+ return {
+ currentSun: _sessionsInWindow(sun, currentStart, now),
+ currentDevice: _sessionsInWindow(device, currentStart, now),
+ previousSun: _sessionsInWindow(sun, previousStart, currentStart),
+ previousDevice: _sessionsInWindow(device, previousStart, currentStart),
};
- return { c7: merge(sun7, dev7), c30: merge(sun30, dev30), sun7, dev7 };
+}
+
+function _hasWeeklyReviewData() {
+ const windows = _weeklyWindows();
+ return windows.currentSun.length + windows.currentDevice.length
+ + windows.previousSun.length + windows.previousDevice.length > 0;
}
export function getChannelMixFingerprint() {
const t = _channelTotals();
- const parts = [];
+ const windows = _weeklyWindows();
+ const parts = ['weekly-light-pattern-v2'];
for (const k of Object.keys(_CHANNEL_DEF).sort()) {
- parts.push(`${k}:${_weeklyChannelTier(t.c7[k] || 0, k)}`);
+ parts.push(`${k}:sun${(t.sun7[k] || 0) > 0 ? 1 : 0}:dev${(t.dev7[k] || 0) > 0 ? 1 : 0}`);
+ }
+ for (const [source, sessions] of Object.entries(windows)) {
+ for (const session of sessions) {
+ parts.push(source, String(session?.id || ''), String(session?.startedAt || 0), String(session?.endedAt || 0), String(Math.round(_durationMinutes(session))));
+ }
}
- // Also fingerprint sun/device session count split — a user who shifted
- // from outdoor to indoor over the week needs a different verdict.
- const sun7 = _getSessions().filter(s => s.endedAt && s.endedAt > Date.now() - 7 * 86400000).length;
- const dev7 = _getDeviceSessions().filter(s => s.endedAt > Date.now() - 7 * 86400000).length;
- parts.push(`sun7:${sun7}`, `dev7:${dev7}`);
return hashString(parts.join('|'));
}
export function buildChannelMixContext() {
const t = _channelTotals();
+ const windows = _weeklyWindows();
+ const currentSun = _summarizeSessions(windows.currentSun);
+ const currentDevice = _summarizeSessions(windows.currentDevice);
+ const previousSun = _summarizeSessions(windows.previousSun);
+ const previousDevice = _summarizeSessions(windows.previousDevice);
const lines = [];
- lines.push('### Channel mix — last 7 days');
- for (const [k, def] of Object.entries(_CHANNEL_DEF)) {
- const t7 = _weeklyChannelTier(t.c7[k] || 0, k);
- const t30 = _weeklyChannelTier(t.c30[k] || 0, k);
- lines.push(`- ${def.label} (${k}): 7d tier "${_tierLabel(t7)}", 30d tier "${_tierLabel(t30)}". Biology: ${def.biology}`);
+ lines.push('### Weekly light review');
+ lines.push('Window: rolling past 7 days, compared with the previous 7 days. These are logged records, not continuous exposure measurements.');
+ lines.push('');
+ lines.push('### Logged sessions — past 7 days');
+ lines.push(`Outdoor sun: ${currentSun.count} session(s) across ${currentSun.days} day(s), ${currentSun.durationMin} total minute(s). Timing: ${_timingText(currentSun.timing)}.`);
+ lines.push(`Light-therapy devices: ${currentDevice.count} session(s) across ${currentDevice.days} day(s), ${currentDevice.durationMin} total minute(s). Timing: ${_timingText(currentDevice.timing)}.`);
+ lines.push('');
+ lines.push('### Comparison — previous 7 days');
+ lines.push(`Outdoor sun: ${previousSun.count} session(s) across ${previousSun.days} day(s), ${previousSun.durationMin} total minute(s). Timing: ${_timingText(previousSun.timing)}.`);
+ lines.push(`Light-therapy devices: ${previousDevice.count} session(s) across ${previousDevice.days} day(s), ${previousDevice.durationMin} total minute(s). Timing: ${_timingText(previousDevice.timing)}.`);
+ if (currentSun.count + currentDevice.count === 0) {
+ lines.push('No sessions were logged in the current period. This is missing log data, not evidence of no real-world light exposure.');
}
- // Source split — outdoor vs device contribution per channel
- const sunSessCount = _getSessions().filter(s => s.endedAt && s.endedAt > Date.now() - 7 * 86400000).length;
- const devSessCount = _getDeviceSessions().filter(s => s.endedAt > Date.now() - 7 * 86400000).length;
lines.push('');
- lines.push('### Source mix this week');
- lines.push(`Outdoor sun: ${sunSessCount} session(s)`);
- lines.push(`Light-therapy devices: ${devSessCount} session(s)`);
+ lines.push('### Light-responsive source signals — past 7 days');
+ for (const [k, def] of Object.entries(_CHANNEL_DEF)) {
+ const sun = (t.sun7[k] || 0) > 0 ? 'logged' : 'not logged';
+ const device = (t.dev7[k] || 0) > 0 ? 'logged separately' : 'not logged';
+ lines.push(`- ${def.label} (${k}): sunlight ${sun}; device ${device}. Model: ${def.biology}`);
+ }
- // User context
- const sd = state.importedData?.sunDefaults || {};
+ // Goals can make a timing observation more useful, but they never turn
+ // this review into a diagnosis, treatment recommendation, or target score.
const goals = formatHealthGoalsText(state.importedData?.healthGoals);
- if (sd.fitzpatrick) lines.push(`Skin type: Fitzpatrick ${sd.fitzpatrick}`);
- if (sd.dailyVitDTargetIU) lines.push(`Vit-D daily target: ${sd.dailyVitDTargetIU} IU`);
if (goals) lines.push(`Health goals: ${String(goals).slice(0, 200)}`);
- // Latest 25-OH-D for context
- try {
- const entries = (state.importedData?.entries || []).slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''));
- for (const e of entries) {
- const v = e?.values?.hormones?.['25-oh-vitamin-d'] ?? e?.values?.lipids?.['25-oh-vitamin-d'];
- if (v != null) { lines.push(`Latest 25-OH-D: ${v} (${e.date})`); break; }
- }
- } catch (_) {}
-
return lines.join('\n');
}
const SYSTEM_PROMPT = [
- 'You evaluate a user\'s 7-day light-channel mix — six biological channels driven by light: vitamin D synthesis, circadian/melanopic, cellular repair (NIR), cardiovascular NO, mood/α-MSH (POMC), and violet-eye. The user already sees a per-channel tier dot for each. Your job is to give one synthesis verdict that reasons ACROSS the channels.',
+ 'You summarize a user\'s logged light pattern for the past 7 days and compare it with the previous 7 days. Keep sunlight and devices separate. A device may deliver a targeted wavelength, but it is not full-spectrum sunlight.',
'Return ONLY valid JSON: {"dot":"green|yellow|red|gray","tip":"string","detail":"string"}.',
+ 'Always return "gray" for dot. The UI is a neutral pattern review, not a traffic-light grade.',
'',
- 'dot:',
- ' green = at least 4 of 6 channels at "good" or "strong" tier, no critical-channel deficit',
- ' yellow = 2–3 channels lit, or one critical channel (vit-D in winter, circadian) at sub-tier',
- ' red = ≤1 channel lit, OR no sun sessions in 7 days, OR vit-D + circadian both at "none"',
- ' gray = no logged sessions',
+ 'Lead with the clearest comparison: source, logged days, timing, or cadence. Say "logged" whenever absence of records could be mistaken for absence of exposure.',
+ 'Never say the user received enough, too little, deficient, complete, balanced, low, good, strong, saturated, or a percentage of a biological target. Missing logs are missing data, not missing biology.',
+ 'Never infer vitamin-D status or measured synthesis. Do not turn session count or duration into a universal light requirement.',
+ 'Morning and evening light may have different timing effects; do not add them into one positive score.',
+ 'Do not recommend extra UV, uncovered midday exposure, removing sunglasses, or extending a session to activate a pathway. Never recommend looking at the sun.',
'',
- 'CRITICAL: pick a tip that hits MULTIPLE channels with one action. Examples of high-leverage cross-channel actions:',
- ' • Morning outdoor walk (15 min before 9 am) → circadian + violet_eye + low-dose NIR + low POMC ALL at once',
- ' • Midday outdoor session with arms uncovered → vitamin_d + no_cv + pomc + nir_solar in 15 min',
- ' • Sunrise watching with eyes-direct → circadian + violet_eye + start-of-day no_cv + cortisol awakening',
- ' • Late-afternoon walk → no_cv + pomc + nir_solar (skips vitamin_d but UVB has dropped anyway)',
- 'AVOID single-channel nudges like "more outdoor light before 9 am for circadian" — that\'s what the user already sees in the per-channel pill drill-downs. Your value is the SYNTHESIS — name the action that maximizes channels-per-minute-outdoors.',
+ 'If only device sessions are logged, explain simply that targeted devices do not recreate the wider spectrum and time-of-day context of outdoor light.',
+ 'If no current sessions are logged, say the app cannot tell whether exposure was limited or simply unrecorded. Compare with the previous window only as logging activity.',
+ 'If sunlight is logged, acknowledge it without asking the user to collect every pathway. Deterministic UV safety belongs to the Today section, not this review.',
'',
- 'When the user has logged device sessions but no outdoor sun: the high-leverage call is OUTDOOR — even 10 min outside delivers channels (violet_eye, NIR, no_cv, low POMC) that no panel can fill. Don\'t recommend more device time when outdoors is missing.',
- 'When the user has outdoor sessions but they\'re all at one solar phase (all sunrise, or all midday): the high-leverage move is the OTHER phase — sunrise users already have circadian, need vit-D from midday; midday users have vit-D, need circadian from morning.',
- 'When all 6 channels are lit: green verdict + maintenance copy. Don\'t invent gaps.',
- '',
- 'tip: one sentence, max 18 words. The single multi-channel action.',
- 'detail: 2–3 sentences. Acknowledge what\'s working (cite specific channels), name the gap with biology, give the concrete cross-channel fix. Reference 25-OH-D if present.',
+ 'tip: one sentence, max 18 words. State the clearest change or stable pattern.',
+ 'detail: 2–3 short sentences. Explain the comparison, the logging uncertainty, and one safe routine-level option if useful.',
'',
'NEVER use jargon acronyms in the user-facing tip or detail. Specifically:',
' • Write "red-light therapy" or "near-infrared light" — NOT "PBM" or "photobiomodulation"',
@@ -190,7 +233,7 @@ const SYSTEM_PROMPT = [
' • Write "cardiovascular nitric oxide" or "blood-vessel" — NOT "NO" alone',
'The internal channel keys (vit-D, circadian, no_cv, pomc, etc) are for YOUR reasoning — translate to plain English in the output.',
'',
- 'No "you should" — be observational. No emoji.',
+ 'Use plain language. No "you should". No emoji.',
].join('\n');
const SINGLETON = { key: 'default', isChannelMixTarget: true };
@@ -205,15 +248,26 @@ const engine = createAIVerdict({
systemPrompt: SYSTEM_PROMPT,
maxTokens: 500,
canAnalyze: () => {
+ const now = Date.now();
+ const cutoff = now - 14 * _DAY_MS;
const sun = _getSessions();
const dev = _getDeviceSessions();
- return sun.some(s => s.endedAt) || dev.length > 0;
+ return sun.some(s => _sessionTime(s) >= cutoff && _sessionTime(s) < now)
+ || dev.some(s => _sessionTime(s) >= cutoff && _sessionTime(s) < now);
},
getAllTargets: () => (state.importedData ? [SINGLETON] : []),
});
export const analyzeChannelMixAI = (opts) => engine.analyze(SINGLETON, opts);
-export const refreshChannelMixAI = () => engine.refresh('default');
+export async function refreshChannelMixAI() {
+ if (!_hasWeeklyReviewData()) {
+ if (typeof document !== 'undefined') {
+ showNotification('Weekly review needs at least one completed sun or device session from the past 14 days.', 'info', 4500);
+ }
+ return null;
+ }
+ return engine.refresh('default');
+}
registerAIActionHandler('refresh-channel-mix', refreshChannelMixAI);
// ─── Render ────────────────────────────────────────────────────────────
@@ -222,26 +276,33 @@ registerAIActionHandler('refresh-channel-mix', refreshChannelMixAI);
// other auto-fire surfaces; prevents tight-loop refire.
const _autoFiredChannelKeys = new Set();
-// Drop-in replacement for renderSuggestion. Returns a verdict block when
-// AI is available + has been triggered; otherwise falls through to the
-// static suggestion (caller still gets non-empty HTML for the empty
-// case, so the layout doesn't shift when AI isn't configured).
+function _renderWeeklyAIReview(analysis, action = '') {
+ return `
+
+
+
+
AI summary · What changed
+
${escapeHTML(analysis?.tip || '')}
+
+ ${action}
+
+ ${analysis?.detail ? `
${escapeHTML(analysis.detail)}
` : ''}
+
+
`;
+}
+
+// Drop-in replacement for renderSuggestion. The AI output is deliberately
+// neutral: safety colors belong to deterministic Today checks, while this
+// block only explains patterns in the two rolling seven-day windows.
export function renderChannelMixVerdict(staticFallback) {
if (!hasAIProvider()) {
// Pre-populated demo or cross-device synced cached verdict still
- // renders even without a provider — only fresh analyses are gated.
+ // renders even without a provider when it matches the new source-aware
+ // fingerprint. Older target/deficit verdicts must not leak back in.
const cached = _getMix();
- if (cached?.status === 'ok' && cached?.dot && cached?.tip) {
- const dot = cached.dot;
- return `
-
-
-
- ${dotPrefix(dot)} ${escapeHTML(cached.tip)}
-
- ${cached.detail ? `
${escapeHTML(cached.detail)}
` : ''}
-
-
`;
+ const currentFp = getChannelMixFingerprint();
+ if (cached?.status === 'ok' && cached?.dot && cached?.tip && cached?.fingerprint === currentFp) {
+ return _renderWeeklyAIReview(cached);
}
return staticFallback || '';
}
@@ -249,57 +310,64 @@ export function renderChannelMixVerdict(staticFallback) {
const a = _getMix();
const currentFp = getChannelMixFingerprint();
const stale = !!(a?.fingerprint && a.fingerprint !== currentFp);
+ const hasReviewData = _hasWeeklyReviewData();
+
+ // A stale cached verdict can survive after its source sessions roll out of
+ // both comparison periods. Do not leave a refresh button that the engine
+ // must reject silently; explain the requirement beside the existing log
+ // action instead. refreshChannelMixAI repeats this guard for old DOM/races.
+ if (!hasReviewData) {
+ return `
+ ${staticFallback || ''}
+
+ AI review needs a little history.
+ Complete at least one sun or device session; the review will become available here.
+
+
`;
+ }
- // Auto-fire on first render when there's actual signal in the mix —
- // gated on rolling totals having any non-zero channel so a brand-new
- // user without sessions doesn't burn an API call on an all-zero mix.
- const _hasSignal = (() => {
- try {
- const t = _rollingChannelTotals(7);
- return Object.values(t).some(v => v > 0);
- } catch (_) { return false; }
- })();
+ // Auto-fire only when either comparison window contains a completed
+ // session. This still analyzes a current no-log week when the prior week
+ // has records, but does not spend a request on a completely blank profile.
const _autoKey = currentFp;
- if (_hasSignal && (status === 'idle' || stale) && !_autoFiredChannelKeys.has(_autoKey)) {
+ if ((status === 'idle' || stale) && !_autoFiredChannelKeys.has(_autoKey)) {
_autoFiredChannelKeys.add(_autoKey);
setTimeout(() => engine.analyze(SINGLETON).catch(() => {}), 0);
}
- // Shimmer ONLY while a request is genuinely in flight. Stale-ok falls
- // through to the bottom CTA branch ("Refresh AI verdict (your mix
- // changed)").
+ // Shimmer ONLY while a request is genuinely in flight. Stale results fall
+ // through to a refresh CTA so an older time window is never presented as
+ // the current comparison.
if (status === 'analyzing') {
return `
-
+
- Analyzing your channel mix…
+ Comparing the past two weeks…
`;
}
- // Idle, OR cached but stale (channels shifted since last run).
- const ctaLabel = stale ? '✨ Refresh AI verdict (your mix changed)' : '✨ Get AI synthesis of your mix';
+ // Idle, OR cached but stale (the rolling windows shifted since last run).
+ const ctaLabel = stale ? 'Refresh review' : 'Generate weekly review';
+ const helper = stale ? 'Your logs changed since this review was generated.' : 'Compares the past 7 days with the 7 before them.';
return `
`;
}
@@ -138,9 +132,8 @@ export function renderLightConditionsWidgetBody({ variant = 'full', slotId = ''
// after first paint so dashboard render isn't blocked by network I/O.
//
// Cache is coords-keyed so a profile swap (different country → different
-// coords) doesn't serve the previous profile's UVI/AQ/etc. Key is rounded
-// to 0.5° (~55 km) — much coarser than the network privacy rounding so
-// near-by points share a cache entry, but cross-country swaps don't.
+// coords) doesn't serve another profile/location's UVI/AQ/etc. Provider-side
+// caching still uses the configured privacy-rounded request coordinates.
let _conditionsCache = null; // { coordKey, atm, fetchedAt }
let _conditionsFetchInFlight = false;
// Per-slot 5min refresh intervals — keyed by deterministic slotId
@@ -155,9 +148,7 @@ function _conditionsTooltipAttr(text, opts = {}) {
function _coordKey(coords) {
if (!coords || !Number.isFinite(coords.lat) || !Number.isFinite(coords.lon)) return null;
- const f = 2; // 0.5° rounding → coarse enough to share within a metro, fine enough to distinguish countries
- const k = (n) => (Math.round(n * f) / f).toFixed(1);
- return `${k(coords.lat)}_${k(coords.lon)}`;
+ return `${coords.lat.toFixed(4)}_${coords.lon.toFixed(4)}`;
}
export function getCachedConditionsAtmosphere() {
@@ -286,7 +277,7 @@ async function _refreshConditions(slotId, variant, opts = {}) {
isoTime: new Date().toISOString(),
noCache: !!opts.force, // user-triggered refresh skips both fresh + stale cache
});
- if (atm?._stale) online = false;
+ if (atm?._stale || atm?._offline || /(?:zenith_offline|offline)/.test(String(atm?.source || ''))) online = false;
if (atm && typeof lightConditionsDeps.applyAtmOverrides === 'function') {
atm = lightConditionsDeps.applyAtmOverrides(atm);
}
@@ -306,7 +297,7 @@ async function _refreshConditions(slotId, variant, opts = {}) {
slot.innerHTML = `
Conditions data unavailable offline. Reconnect once and we'll cache it.${fetchError ? ` (${escapeHTML(fetchError)})` : ''}
`;
return;
}
- _conditionsCache = { coordKey: key, atm, fetchedAt: now };
+ _conditionsCache = { coordKey: key, atm, fetchedAt: Date.now() };
slot.setAttribute('aria-busy', 'false');
slot.innerHTML = renderConditionsHTML(atm, coords, variant, !online);
_centerConditionsNowMarker(slot);
@@ -329,7 +320,7 @@ async function _refreshConditions(slotId, variant, opts = {}) {
// User-triggered: force a re-fetch of conditions, bypassing all caches.
// Re-renders every conditions-now slot on the page (dashboard + Light page
// can both have one mounted at the same time). Also wipes the localStorage
-// meteo:v2:* cache so a device that latched onto a degraded provider
+// meteo cache so a device that latched onto a degraded provider
// (e.g. an Open-Meteo-only response cached while CAMS was unreachable
// during a relay-side outage) can recover without tab-killing — the
// next fetch hits the provider chain fresh.
@@ -345,38 +336,6 @@ export function _refreshConditionsNow() {
});
}
-export async function _setManualUvi() {
- const input = /** @type {HTMLInputElement | null} */ (document.getElementById('manual-uvi-input'));
- if (!input) return;
- const v = parseFloat(input.value);
- if (!Number.isFinite(v) || v < 0 || v > 20) {
- _notify('UVI must be between 0 and 20', 'error');
- return;
- }
- const data = state.importedData;
- if (!data) return;
- if (!data.sunDefaults) data.sunDefaults = {};
- if (!data.sunDefaults.overrides) data.sunDefaults.overrides = {};
- data.sunDefaults.overrides.uvIndex = v;
- if (typeof lightConditionsDeps.saveImportedData === 'function') await lightConditionsDeps.saveImportedData();
- // Bust the in-memory conditions cache so the next render re-renders with
- // the override applied. Fetch isn't re-issued — the override is applied
- // to whatever atm we have cached.
- _conditionsCache = null;
- _notify(`Manual UVI ${v.toFixed(1)} applied — used for burn-time + vit-D-threshold math until cleared. (Spectrum stays driven by ozone + zenith + cloud cover.)`, 'success', 5000);
- _refreshConditionsNow();
-}
-
-export async function _clearManualUvi() {
- const data = state.importedData;
- if (!data?.sunDefaults?.overrides) return;
- delete data.sunDefaults.overrides.uvIndex;
- if (typeof lightConditionsDeps.saveImportedData === 'function') await lightConditionsDeps.saveImportedData();
- _conditionsCache = null;
- _notify('Manual UVI cleared — back to live atmosphere data.');
- _refreshConditionsNow();
-}
-
// User-triggered: open a modal showing the raw atmosphere response so the
// user can verify what the provider returned, what we parsed, and what the
// engine will use. Pure inspection — no side effects.
@@ -408,13 +367,21 @@ export function _inspectConditionsNow() {
` : ''}`;
}
- const uviHeroTip = medResult && medResult.kind === 'minutes'
- ? TANNING_MODIFIERS_NOTE
- : 'WHO UV index — sunburn intensity; vitamin-D synthesis rises as UVI climbs.';
- const fpDefaultTip = 'No skin type set yet — using medium (Fitzpatrick III) as a default. Set your actual skin type in Light setup for a personalized estimate.';
+ const uviHeroTip = 'WHO UV index — an erythema-weighted indicator used for sun-protection decisions. It is not a direct vitamin-D synthesis meter or a personal burn-time guarantee.';
const sunPositionTip = `${SHADOW_RULE_HINT}\n\nSun elevation: ${sunAngle != null ? sunAngle + '°' : 'unknown'} above horizon.`;
- const ozoneTip = ozone != null
- ? 'Total atmospheric ozone column (Dobson Units) — the protective stratospheric layer that blocks UV-B. Lower DU → more UV reaches the surface.'
- : SMOG_HINT;
- const airQualityTip = 'Air quality is the worst-of category across PM2.5, PM10, and NO₂ — so a high traffic-pollutant level (NO₂) won\'t hide behind clean PM. EAQI uses the same multi-pollutant logic.';
+ const showSurfaceOzone = surfaceOzone != null;
+ const ozoneTip = showSurfaceOzone
+ ? `${SMOG_HINT}${ozone != null ? ` The ${ozone} DU total-ozone column remains available in Details as a separate UV-model input.` : ''}`
+ : 'Total atmospheric ozone column (Dobson Units) — a neutral UV-model input, not an air-pollution severity category.';
+ const ozoneCls = showSurfaceOzone && ozoneCategory ? `conditions-aq-${ozoneCategory.cls}` : '';
+ const ozoneValue = showSurfaceOzone
+ ? (ozoneCategory?.label || `${surfaceOzone} µg/m³`)
+ : (ozone != null ? String(ozone) : '—');
+ const ozoneSub = showSurfaceOzone
+ ? (ozoneCategory ? `O₃ ${surfaceOzone} µg/m³ · EU index ${Math.round(ozoneEaqi)}` : 'current O₃ concentration')
+ : (ozone != null ? 'DU · UV model input' : '');
+ const airQualityTip = 'Provider-computed European Air Quality Index. The overall category and pollutant components use their specified averaging windows; raw current concentrations are context only.';
return `
-
-
UV index${atm._uvOverridden ? ` manual` : ''}
+
+
UV index
${uvi != null ? uvi : '—'}
- ${uvi != null ? `
${escapeHTML(vitDLabel)}${(() => {
- if (!medResult) return '';
- if (medResult.kind === 'no-uv') return ' · UV near zero, no burn risk';
- if (medResult.kind === 'safe-til-sunset') return ' · won\'t burn before sunset';
- if (medResult.kind === 'minutes') return ` · ~${_fmtMinutes(medResult.value)} to your sunburn dose${fpIsDefault ? '*' : ''}`;
- return '';
- })()}${fpIsDefault && medResult?.kind === 'minutes' ? ` *` : ''}
` : ''}
+ ${uvi != null ? `
${escapeHTML(uviReliable ? uviLabel : '⚠ UVI data looks inconsistent — see Details')}
- Burn-time estimates are based on Fitzpatrick skin type — actual burn / tan response also depends on genetics (e.g. MC1R variants), diet (omega-3, antioxidants), recent sun history, circadian state, sleep, and hydration.
-
${trustFooter}`;
}
diff --git a/js/light-device-ai-analysis.js b/js/light-device-ai-analysis.js
index 0bcb6be0..0280aa77 100644
--- a/js/light-device-ai-analysis.js
+++ b/js/light-device-ai-analysis.js
@@ -6,14 +6,12 @@
// (controlled-dose biology, distance, eye protection) and fingerprint
// (deviceId + distanceCm + bodyArea + eyesProtected).
-import { state } from './state.js';
import { escapeHTML, escapeAttr } from './utils.js';
import { hasAIProvider } from './api.js';
import { getSunDefaults } from './sun-defaults.js';
import { getDevices, getDeviceSessions } from './light-devices-store.js';
-import { CHANNEL_DISPLAY, channelTier, tierLabel, formatChannelUnit, BODY_REGIONS } from './sun.js';
+import { CHANNEL_DISPLAY, formatChannelUnit, BODY_REGIONS } from './sun.js';
import { createAIVerdict, hashString, dotPrefix } from './ai-verdict-engine.js';
-import { formatHealthGoalsText } from './health-goals-utils.js';
import { aiActionAttrs, registerAIActionHandler } from './ai-action-delegates.js';
// ─── Fingerprint ───────────────────────────────────────────────────────
@@ -21,14 +19,33 @@ import { aiActionAttrs, registerAIActionHandler } from './ai-action-delegates.js
export function getDeviceSessionFingerprint(sess) {
if (!sess) return '';
const parts = [
+ sess.startedAt || 0,
sess.endedAt || 0,
Math.round((sess.durationMin || 0) * 10) / 10,
sess.deviceId || '',
Math.round(sess.distanceCm || 0),
sess.bodyArea || '',
+ Array.isArray(sess.bodyAreas) ? [...sess.bodyAreas].sort().join(',') : '',
sess.eyesProtected ? 1 : 0,
sess.mode || '',
+ sess.safety?.hasUV ? 1 : 0,
+ sess.safety?.unsafeEyeExposure ? 1 : 0,
+ sess.safety?.erythemalSED != null ? Math.round(sess.safety.erythemalSED * 100) : '',
+ sess.safety?.ocularActinicUV != null ? Math.round(sess.safety.ocularActinicUV * 10) : '',
+ sess.metrics?.photopicLux != null ? Math.round(sess.metrics.photopicLux) : '',
+ sess.metrics?.melanopicEdiLux != null ? Math.round(sess.metrics.melanopicEdiLux) : '',
];
+ const device = getDevices().find(d => d.id === sess.deviceId) || sess.deviceSnapshot || null;
+ if (device) {
+ parts.push(
+ device.type || '',
+ Array.isArray(device.peakWavelengths) ? device.peakWavelengths.join(',') : '',
+ device.mwPerCm2At15cm || '',
+ device.lux || '',
+ device.melanopicDER || '',
+ device.melanopicEdiLux || '',
+ );
+ }
if (sess.doses) {
for (const k of Object.keys(sess.doses).sort()) {
parts.push(k + ':' + Math.round((sess.doses[k] || 0) * 10) / 10);
@@ -44,6 +61,14 @@ function _formatNumber(n, digits = 1) {
return Number(n).toFixed(digits).replace(/\.0$/, '');
}
+function _localDateKey(date) {
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '—';
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, '0');
+ const d = String(date.getDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+}
+
// Cap user-supplied free-text fields fed into prompt context. A device named
// "Glow\n[SYSTEM: ignore previous]" would otherwise break out of the prompt.
function _safeText(s, max = 80) {
@@ -52,7 +77,7 @@ function _safeText(s, max = 80) {
const _DEVICE_TYPE_DESCRIPTIONS = {
uvb: 'UVB phototherapy panel — vitamin-D synthesis + POMC; eye exposure must be blocked',
- uva: 'UVA panel — nitric-oxide / cardiovascular benefit; no vitamin D; eye protection recommended',
+ uva: 'UVA panel — modeled nitric-oxide wellness channel; no vitamin D; UV-rated eye protection required',
combined: 'red + near-IR PBM panel — cellular repair, mitochondrial signaling',
'pbm-targeted': 'handheld / spot PBM device — close-range targeted dosing',
sad: 'SAD light box — 10000-lux white light for circadian / mood; requires eye-direct (not blocked) for benefit',
@@ -60,36 +85,16 @@ const _DEVICE_TYPE_DESCRIPTIONS = {
'full-spectrum': 'full-spectrum bulb — daytime alertness if used at sufficient duration',
};
-function _sevenDayRollup(currentSess) {
- const sessions = getDeviceSessions().filter(s => s.endedAt && s.id !== currentSess?.id);
- const cutoff = (currentSess?.endedAt || Date.now()) - 7 * 86400000;
- const recent = sessions.filter(s => s.endedAt >= cutoff);
- if (!recent.length) return null;
- let totalMin = 0;
- const daysWithSession = new Set();
- for (const s of recent) {
- totalMin += s.durationMin || 0;
- daysWithSession.add(new Date(s.endedAt).toISOString().slice(0, 10));
- }
- return {
- sessionCount: recent.length,
- daysWithSession: daysWithSession.size,
- totalMin: Math.round(totalMin),
- };
-}
-
export function buildDeviceSessionContext(sess) {
if (!sess) return '';
const sd = getSunDefaults() || {};
- const lc = state.importedData?.lightCircadian || {};
- const goals = formatHealthGoalsText(state.importedData?.healthGoals);
- const device = getDevices().find(d => d.id === sess.deviceId) || null;
+ const device = getDevices().find(d => d.id === sess.deviceId) || sess.deviceSnapshot || null;
const lines = [];
lines.push('### Session');
const start = new Date(sess.startedAt || Date.now());
const end = sess.endedAt ? new Date(sess.endedAt) : null;
- lines.push(`Date: ${start.toISOString().slice(0, 10)}`);
+ lines.push(`Local date: ${_localDateKey(start)}`);
lines.push(`Time: ${start.toTimeString().slice(0, 5)}${end ? '–' + end.toTimeString().slice(0, 5) : ' (in progress)'}`);
lines.push(`Duration: ${_formatNumber(sess.durationMin)} min`);
@@ -111,6 +116,7 @@ export function buildDeviceSessionContext(sess) {
lines.push(`Irradiance: ${device.mwPerCm2At15cm} mW/cm² at ${device.recommendedDistanceCm || 15} cm reference distance`);
}
if (device.lux) lines.push(`Eye-channel intensity: ${device.lux.toLocaleString()} lux`);
+ if (device.melanopicEdiLux) lines.push(`Eye-channel melanopic EDI: ${device.melanopicEdiLux.toLocaleString()} lx at ${device.recommendedDistanceCm || 15} cm reference distance`);
// Mode disclosure for hybrid panels (Maxi UVB / Trinity / etc.) where
// the user picks an LED-group preset on the touchscreen. Without
// this, the model sees a UVB-typed device with all-zero vit-D and
@@ -138,17 +144,32 @@ export function buildDeviceSessionContext(sess) {
}
}
} else {
- lines.push('Device record removed (was deleted from the user\'s catalog).');
+ lines.push('Device specification unavailable.');
}
lines.push('');
lines.push('### Session parameters');
lines.push(`Working distance: ${sess.distanceCm || '—'} cm`);
lines.push(`Body area: ${sess.bodyArea || '—'}`);
- lines.push(`Eyes: ${sess.eyesProtected ? 'protected (closed / blocked)' : 'uncovered (direct exposure)'}`);
+ const hasUV = sess.safety?.hasUV === true;
+ lines.push(`Eyes: ${hasUV
+ ? (sess.eyesProtected ? 'UV-rated protection recorded' : 'UV-rated protection NOT recorded')
+ : (sess.eyesProtected ? 'shielding recorded' : 'no shielding recorded')}`);
+ if (sess.safety?.hasUV) {
+ lines.push(`Deterministic UV safety: ${sess.safety.unsafeEyeExposure ? 'UNSAFE EYE EXPOSURE RECORDED' : 'UV-rated eye protection recorded'}`);
+ if ((sess.safety.uvDoseStatus === 'modeled' || sess.safety.uvDoseStatus == null)
+ && Number.isFinite(sess.safety.erythemalSED)) {
+ lines.push(`Local erythemal dose: ${_formatNumber(sess.safety.erythemalSED, 2)} SED${Number.isFinite(sess.safety.conservativeBaseMedFraction) ? `; ${Math.round(sess.safety.conservativeBaseMedFraction * 100)}% of conservative Type I base MED` : ''}`);
+ } else {
+ lines.push('UV dose: unavailable — the required spectral output, band split, or supported distance basis was not provided; do not infer burn dose or vitamin-D output.');
+ }
+ }
+ if (Array.isArray(sess.calculation?.warnings) && sess.calculation.warnings.length) {
+ lines.push(`Model limits: ${sess.calculation.warnings.map(warning => _safeText(warning, 240)).join(' ')}`);
+ }
if (sess.doses) {
- const fitz = sd.fitzpatrick || lc.skinType?.match(/^(I{1,3}|IV|VI?)/)?.[1] || 'III';
+ const fitz = sess.fitzpatrick || sd.fitzpatrick || sess.safety?.fitzpatrick || 'III';
const channelOrder = ['vitamin_d', 'circadian', 'nir_solar', 'no_cv', 'pomc', 'violet_eye', 'pbm_red', 'pbm_nir'];
// Body-fraction for the per-session vit-D cap (Audit P1 #8). Device
// session schema stores bodyAreas[]; BODY_REGIONS provides the per-
@@ -165,36 +186,16 @@ export function buildDeviceSessionContext(sess) {
if (v == null || v === 0) continue;
const meta = CHANNEL_DISPLAY[k] || { label: k };
let display = formatChannelUnit(k, v, sess.durationMin || 0, fitz, null, null, false, _bf);
- if (!display) {
- const t = channelTier(v, k);
- const tlabel = tierLabel(t);
- const target = meta.dailyTarget || 0;
- const pct = (target > 0 && v > 0) ? Math.round(100 * v / target) : null;
- display = pct != null ? `${tlabel} (${pct}% of daily target)` : tlabel;
- }
+ if (!display) display = 'targeted device signal logged';
parts.push(`${meta.label || k}: ${display}`);
}
if (parts.length) {
lines.push('');
- lines.push('### Doses (as displayed to user)');
+ lines.push('### Modeled light signals');
for (const p of parts) lines.push(' - ' + p);
}
}
- lines.push('');
- lines.push('### User profile');
- if (sd.fitzpatrick) lines.push(`Skin type: Fitzpatrick ${sd.fitzpatrick}`);
- else if (lc.skinType) lines.push(`Skin type: ${lc.skinType}`);
- if (sd.dailyVitDTargetIU) lines.push(`Vit-D daily target: ${sd.dailyVitDTargetIU} IU`);
- if (goals) lines.push(`Health goals: ${String(goals).slice(0, 200)}`);
-
- const rollup = _sevenDayRollup(sess);
- if (rollup) {
- lines.push('');
- lines.push('### Last 7 days of device use (excluding this session)');
- lines.push(`${rollup.sessionCount} sessions across ${rollup.daysWithSession} days · ${rollup.totalMin} min total`);
- }
-
return lines.join('\n');
}
@@ -203,18 +204,18 @@ const SYSTEM_PROMPT = [
'Return ONLY valid JSON with three keys: {"dot":"green|yellow|red|gray","tip":"string","detail":"string"}.',
'',
'dot:',
- ' green = on-protocol for the device type AND safe (eye protection where required, working distance reasonable, dose adequate)',
- ' yellow = useful but with a caveat (sub-optimal distance, short duration, eye protection mismatched — e.g. SAD lamp with "eyes protected" zeroes the circadian channel)',
- ' red = unsafe or counterproductive (UVB/UVA panel without eye protection, handheld PBM at <5 cm, dose model returning zero on a properly logged session)',
+ ' green = the recorded setup is internally consistent and no deterministic safety flag is present; do not claim it matches a medical or vendor protocol unless one is explicitly supplied',
+ ' yellow = a material setup caveat or uncertainty is recorded (for example reference-distance uncertainty or eye-channel mismatch); do not call a short or low-signal session a failure',
+ ' red = a deterministic safety problem is recorded (especially any UV-emitting mode without UV-rated goggles); do not invent a distance cutoff for PBM panels',
' gray = not enough info (no doses computed, device record removed, missing parameters)',
'',
'Device-class biology:',
- ' • PBM red+NIR (combined / pbm-targeted): cellular repair via cytochrome c oxidase, ~1–10 J/cm² per session is the typical target range; Vitamin-D yield is zero — irrelevant; do NOT flag.',
- ' • SAD light box: needs EYE-DIRECT exposure to deliver the 10000-lux circadian dose. "Eyes protected" defeats the purpose; flag yellow with a "remove the eye block to capture the SAD benefit" tip. Skin/UV channels will be zero — irrelevant.',
- ' • UVB / UVA phototherapy: eye protection MANDATORY (corneal damage). Vitamin-D / NO yield is the value. If eyes uncovered, flag RED.',
- ' • Dawn simulator: gentle ramp, low total dose; circadian-only. Don\'t flag low-tier numbers; the value is the timing, not the dose.',
+ ' • PBM red+NIR (combined / pbm-targeted): retain the cytochrome-c-oxidase wellness model. Dose ranges are device/target specific; follow the device protocol and flag heat or eye discomfort rather than prescribing a universal target. Vitamin-D yield is zero — irrelevant.',
+ ' • SAD light box: needs eyes open to ambient light without staring at the source. Photopic lux is not M-EDI unless a spectrum or melanopic DER is available. Skin/UV channels will be zero — irrelevant.',
+ ' • UVB / UVA devices: UV-rated eye protection is mandatory. Numeric vitamin-D, NO, burn, or ocular dose requires band-resolved spectral irradiance at a supported distance; when it is unavailable, discuss only the recorded UV presence and setup. If eyes are uncovered, flag RED.',
+ ' • Dawn simulator: gentle ramp, low total dose; circadian-only. Judge the timing and setup, not a completion score.',
' • Full-spectrum bulb: daytime alertness; only meaningful at sustained durations (>30 min) and reasonable lux.',
- 'Working distance matters: the dose model already applies an inverse-square correction capped at 3×; below 10 cm on a panel, mention that actual irradiance may be higher than the model captures.',
+ 'Working distance matters: the dose model uses measured distance data when available. Otherwise it retains vendor reference irradiance; it applies inverse-square only to devices explicitly declared point sources. UV-specific numbers are withheld outside a measured range or away from an unmodeled reference distance.',
'',
'Mode (when the context lists a Mode line):',
' • Hybrid panels like Mitochondriak Maxi UVB and Chroma Trinity have named touchscreen modes that gate which LED groups fire. The Mode line tells you what the user DELIBERATELY ran. A UVB-typed panel set to a red/NIR-only mode is a PBM session by intent — judge it as PBM, not as a broken UVB session. Zero vit-D in that case is expected, not a problem.',
@@ -236,8 +237,10 @@ const engine = createAIVerdict({
buildContext: buildDeviceSessionContext,
systemPrompt: SYSTEM_PROMPT,
maxTokens: 400,
- canAnalyze: (s) => !!s?.endedAt,
- shouldAutoFire: (s) => !!s?.endedAt,
+ canAnalyze: (s) => !!s?.endedAt && !!s?.doses && !!s?.safety,
+ // Keep per-session interpretation user-requested. Automatic synthesis lives
+ // at Today/Weekly level and should not run again for every history row.
+ shouldAutoFire: () => false,
getAllTargets: getDeviceSessions,
});
@@ -248,8 +251,14 @@ export const maybeAnalyzeDeviceSessionAfterFinish = engine.maybeAfterFinish;
// ─── Render ────────────────────────────────────────────────────────────
+function _hasCompleteModeledDeviceSession(sess) {
+ return !!sess?.endedAt
+ && !!sess?.doses
+ && !!sess?.safety;
+}
+
export function renderDeviceSessionAIInline(sess) {
- if (!sess?.endedAt) return '';
+ if (!_hasCompleteModeledDeviceSession(sess)) return '';
if (!hasAIProvider() && !(sess.aiAnalysis?.status === 'ok' && sess.aiAnalysis?.dot)) return '';
const status = engine.getStatus(sess);
const a = sess.aiAnalysis;
@@ -283,7 +292,7 @@ export function renderDeviceSessionAIInline(sess) {
}
export function renderDeviceSessionAIDetail(sess) {
- if (!sess?.endedAt) return '';
+ if (!_hasCompleteModeledDeviceSession(sess)) return '';
if (!hasAIProvider() && !(sess.aiAnalysis?.status === 'ok' && sess.aiAnalysis?.dot)) return '';
const status = engine.getStatus(sess);
const a = sess.aiAnalysis;
diff --git a/js/light-device-session-engine.js b/js/light-device-session-engine.js
index 1a644e12..f38b9a11 100644
--- a/js/light-device-session-engine.js
+++ b/js/light-device-session-engine.js
@@ -9,10 +9,15 @@ import { BODY_REGIONS } from './sun-body-silhouette.js';
import {
computeChannelDoses as computeSpectrumChannelDoses,
effectiveDeviceForMode as getEffectiveDeviceForMode,
+ erythemalSED as computeErythemalSED,
+ fractionOfMED as computeFractionOfMED,
+ ocularActinicUVdose as computeOcularActinicUVdose,
synthesizeDeviceSpectrum as synthesizeSpectrumForDevice,
validateModeCoupling as validateDeviceModeCoupling,
} from './sun-spectrum.js';
+export const DEVICE_ENGINE_VERSION = 5;
+
/**
* @typedef {object} DeviceSessionDoseInput
* @property {any} [device]
@@ -34,8 +39,10 @@ export const DEVICE_BODY_AREA_FRACTIONS = {
};
export const DEVICE_TYPE_CHANNELS = {
- uvb: ['vitamin_d', 'pomc', 'no_cv', 'violet_eye', 'circadian', 'pbm_red', 'pbm_nir'],
- uva: ['no_cv', 'violet_eye', 'pbm_red', 'pbm_nir'],
+ // Type-only fallbacks stay narrow. Hybrid channels are derived from the
+ // actual firing wavelengths, not granted by a broad `uvb` label.
+ uvb: ['vitamin_d', 'pomc'],
+ uva: ['pomc', 'no_cv'],
combined: ['pbm_red', 'pbm_nir'],
'pbm-targeted': ['pbm_red', 'pbm_nir'],
sad: ['circadian'],
@@ -49,6 +56,9 @@ function _runtimeDeps(deps = {}) {
effectiveDeviceForMode: deps.effectiveDeviceForMode || getEffectiveDeviceForMode,
synthesizeDeviceSpectrum: deps.synthesizeDeviceSpectrum || synthesizeSpectrumForDevice,
computeChannelDoses: deps.computeChannelDoses || computeSpectrumChannelDoses,
+ erythemalSED: deps.erythemalSED || computeErythemalSED,
+ fractionOfMED: deps.fractionOfMED || computeFractionOfMED,
+ ocularActinicUVdose: deps.ocularActinicUVdose || computeOcularActinicUVdose,
};
}
@@ -86,8 +96,99 @@ export function bodyFractionForDeviceSession({ bodyAreas = null, bodyArea = 'tor
export function deviceDistanceFactor(device, distanceCm = 15) {
const baseRangeCm = device?.recommendedDistanceCm || 15;
const measuredDistance = Number.isFinite(distanceCm) ? distanceCm : 15;
- const rawDistFactor = (baseRangeCm / Math.max(measuredDistance, 5)) ** 2;
- return Math.min(rawDistFactor, 3.0);
+ const table = Array.isArray(device?.irradianceByDistanceCm)
+ ? device.irradianceByDistanceCm
+ .filter(p => Number.isFinite(p?.distanceCm) && Number.isFinite(p?.mwPerCm2) && p.distanceCm > 0 && p.mwPerCm2 >= 0)
+ .slice().sort((a, b) => a.distanceCm - b.distanceCm)
+ : [];
+ if (table.length >= 2) {
+ const sample = (cm) => {
+ if (cm <= table[0].distanceCm) return table[0].mwPerCm2;
+ if (cm >= table[table.length - 1].distanceCm) return table[table.length - 1].mwPerCm2;
+ for (let i = 0; i < table.length - 1; i++) {
+ const a = table[i], b = table[i + 1];
+ if (cm < a.distanceCm || cm > b.distanceCm) continue;
+ const t = (cm - a.distanceCm) / (b.distanceCm - a.distanceCm);
+ return a.mwPerCm2 + t * (b.mwPerCm2 - a.mwPerCm2);
+ }
+ return null;
+ };
+ const reference = sample(baseRangeCm);
+ const actual = sample(Math.max(measuredDistance, 1));
+ if (reference > 0 && actual != null) return Math.max(0, Math.min(5, actual / reference));
+ }
+ // Extended panels in their near field do not obey point-source inverse
+ // square. Only apply that model when the device explicitly declares it.
+ if (device?.distanceModel === 'point-source') {
+ const rawDistFactor = (baseRangeCm / Math.max(measuredDistance, 5)) ** 2;
+ return Math.min(rawDistFactor, 3.0);
+ }
+ return 1;
+}
+
+/**
+ * @param {Record | null | undefined} device
+ * @param {string | null} [mode]
+ * @param {Record} [deps]
+ * @returns {boolean}
+ */
+export function deviceEmitsUV(device, mode = null, deps = {}) {
+ if (!device) return false;
+ const { effectiveDeviceForMode } = _runtimeDeps(deps);
+ const resolvedMode = resolveDeviceMode(device, mode, deps);
+ const effective = effectiveDeviceForMode
+ ? effectiveDeviceForMode(device, resolvedMode)
+ : device;
+ const peaks = Array.isArray(effective?.peakWavelengths)
+ ? effective.peakWavelengths.filter(nm => Number.isFinite(nm))
+ : [];
+ if (peaks.some(nm => nm >= 180 && nm < 400)) return true;
+ // A vendor-defined red/NIR-only mode on a hybrid UV device is genuinely
+ // non-UV. Without a usable mode subset, however, the declared UVA/UVB type
+ // is the safer source of truth when wavelength specs are missing or wrong.
+ const hasResolvedModeSubset = Array.isArray(device?.modes)
+ && device.modes.length > 0
+ && !!device.modes.find(candidate => candidate.id === resolvedMode)
+ && peaks.length > 0;
+ if (hasResolvedModeSubset) return false;
+ return device?.type === 'uvb' || device?.type === 'uva';
+}
+
+function _distanceModelInfo(device, distanceCm) {
+ const referenceCm = Number(device?.recommendedDistanceCm) || 15;
+ const actualCm = Number.isFinite(distanceCm) && distanceCm > 0 ? distanceCm : referenceCm;
+ const table = Array.isArray(device?.irradianceByDistanceCm)
+ ? device.irradianceByDistanceCm
+ .filter(point => Number.isFinite(point?.distanceCm) && Number.isFinite(point?.mwPerCm2) && point.distanceCm > 0)
+ .slice().sort((a, b) => a.distanceCm - b.distanceCm)
+ : [];
+ if (table.length >= 2) {
+ const min = table[0].distanceCm;
+ const max = table[table.length - 1].distanceCm;
+ return actualCm < min || actualCm > max
+ ? { basis: 'measured-boundary', warning: `Recorded distance is outside the ${min}–${max} cm measured range; the nearest measured value was used without extrapolation.` }
+ : { basis: 'measured-table', warning: null };
+ }
+ if (device?.distanceModel === 'point-source') return { basis: 'point-source', warning: null };
+ if (Math.abs(actualCm - referenceCm) > Math.max(1, referenceCm * 0.05)) {
+ return {
+ basis: 'reference-only',
+ warning: `Recorded distance differs from the ${referenceCm} cm reference, but this panel has no measured distance curve; no distance correction was invented.`,
+ };
+ }
+ return { basis: 'reference-distance', warning: null };
+}
+
+function _unweightedUvaDose({ spectrum, durationSec = 0 }) {
+ if (!spectrum || durationSec <= 0) return 0;
+ const dlambda = 5;
+ let uvaIrradiance = 0;
+ for (let index = 0; index < spectrum.irradiance.length; index++) {
+ const nm = spectrum.wavelengths[index];
+ if (nm < 315 || nm > 400) continue;
+ uvaIrradiance += (Number(spectrum.irradiance[index]) || 0) * dlambda;
+ }
+ return uvaIrradiance * durationSec;
}
/**
@@ -106,17 +207,51 @@ export function computeDeviceSessionDoses({
const resolvedMode = resolveDeviceMode(device, mode, deps);
const bodyExposureFraction = bodyFractionForDeviceSession({ bodyAreas, bodyArea });
const distanceFactor = deviceDistanceFactor(device, distanceCm);
- const durationSec = durationMin * 60;
- const eyeMode = eyesProtected ? 'closed-eyes' : 'direct';
- const { effectiveDeviceForMode, synthesizeDeviceSpectrum, computeChannelDoses } = _runtimeDeps(deps);
- const hasPeaks = Array.isArray(device?.peakWavelengths) && device.peakWavelengths.length > 0;
- const hasIrradiance = (device?.mwPerCm2At15cm || 0) > 0;
+ const safeDurationMin = Number.isFinite(durationMin) && durationMin > 0 ? durationMin : 0;
+ const durationSec = safeDurationMin * 60;
+ const { effectiveDeviceForMode, synthesizeDeviceSpectrum, computeChannelDoses, erythemalSED, fractionOfMED, ocularActinicUVdose } = _runtimeDeps(deps);
+ const effectiveDevice = effectiveDeviceForMode
+ ? effectiveDeviceForMode(device, resolvedMode)
+ : device;
+ const effectivePeaks = Array.isArray(effectiveDevice?.peakWavelengths)
+ ? effectiveDevice.peakWavelengths.filter(nm => Number.isFinite(nm))
+ : [];
+ const hasPeaks = effectivePeaks.length > 0;
+ const hasIrradiance = (effectiveDevice?.mwPerCm2At15cm || 0) > 0;
+ const hasUV = deviceEmitsUV(device, resolvedMode, deps);
+ const isAmbientEyeDevice = ['sad', 'dawn-sim', 'full-spectrum'].includes(device?.type) && !hasUV;
+ // Therapy panels never earn an eye-channel benefit merely because goggles
+ // were omitted. Ambient eye-light devices are the only device class whose
+ // normal use intentionally places open eyes in the illuminated environment.
+ const eyeMode = isAmbientEyeDevice && !eyesProtected ? 'direct' : 'closed-eyes';
let doses = {};
+ let safety = {
+ hasUV,
+ uvDoseStatus: hasUV ? 'unavailable' : 'not-applicable',
+ erythemalSED: hasUV ? null : 0,
+ conservativeBaseMedFraction: hasUV ? null : 0,
+ ocularActinicUV: hasUV ? null : 0,
+ ocularUvaJPerM2: hasUV ? null : 0,
+ unsafeEyeExposure: hasUV && !eyesProtected,
+ };
+ const metrics = {};
+ const warnings = [];
+ const distanceInfo = _distanceModelInfo(device, distanceCm);
+ if (distanceInfo.warning) warnings.push(distanceInfo.warning);
+ const sourcePeaks = Array.isArray(device?.peakWavelengths)
+ ? device.peakWavelengths.filter(nm => Number.isFinite(nm))
+ : [];
+ const sourceHasUV = sourcePeaks.some(nm => nm >= 180 && nm < 400) || ['uvb', 'uva'].includes(device?.type);
+ const sourceHasNonUV = sourcePeaks.some(nm => nm >= 400);
+ const hasDeclaredPeakShares = Array.isArray(device?.peakShares)
+ && device.peakShares.length === sourcePeaks.length
+ && device.peakShares.some(share => Number(share) > 0)
+ && device.peakShareBasis !== 'heuristic';
+ const distanceSupportsUvDose = ['measured-table', 'point-source', 'reference-distance'].includes(distanceInfo.basis);
+ const uvDoseQuantifiable = hasUV && hasIrradiance && distanceSupportsUvDose
+ && (!(sourceHasUV && sourceHasNonUV) || hasDeclaredPeakShares);
if (synthesizeDeviceSpectrum && computeChannelDoses && hasPeaks && hasIrradiance) {
- const effectiveDevice = effectiveDeviceForMode
- ? effectiveDeviceForMode(device, resolvedMode)
- : device;
const baseSpec = synthesizeDeviceSpectrum(effectiveDevice);
const spectrum = {
wavelengths: baseSpec.wavelengths,
@@ -124,15 +259,79 @@ export function computeDeviceSessionDoses({
};
doses = computeChannelDoses({
spectrum,
- durationMin,
+ durationMin: safeDurationMin,
bodyExposureFraction,
eyeExposure: { mode: eyeMode, durationSec },
});
+ const sed = uvDoseQuantifiable && erythemalSED ? erythemalSED({
+ spectrum,
+ durationMin: safeDurationMin,
+ bodyExposureFraction,
+ }) : null;
+ const ocularActinicUV = uvDoseQuantifiable && !eyesProtected && ocularActinicUVdose
+ ? ocularActinicUVdose({ spectrum, eyeExposure: { mode: 'direct', durationSec } })
+ : (uvDoseQuantifiable ? 0 : null);
+ const ocularUvaJPerM2 = uvDoseQuantifiable && !eyesProtected
+ ? _unweightedUvaDose({ spectrum, durationSec })
+ : (uvDoseQuantifiable ? 0 : null);
+ if (hasUV && !uvDoseQuantifiable) {
+ // A hybrid panel's total irradiance does not reveal how much power is
+ // in UV. Never manufacture UVB/vitamin-D or burn numbers from the
+ // generic red/NIR-heavy split used for wellness-channel visualization.
+ for (const key of ['vitamin_d', 'pomc', 'no_cv', 'violet_eye']) delete doses[key];
+ warnings.push(!distanceSupportsUvDose
+ ? 'UV output is present, but the recorded distance is outside the measured range or differs from an unmodeled reference distance. UV-derived channels, eye dose, vitamin D, and burn estimates are withheld.'
+ : 'UV output is present, but no measured or declared UV band split is available. UV-derived channels, eye dose, vitamin D, and burn estimates are withheld.');
+ }
+ safety = {
+ hasUV,
+ uvDoseStatus: uvDoseQuantifiable ? 'modeled' : (hasUV ? 'unavailable' : 'not-applicable'),
+ erythemalSED: sed,
+ conservativeBaseMedFraction: uvDoseQuantifiable && fractionOfMED
+ ? fractionOfMED({ sed, fitzpatrick: 'I' })
+ : (hasUV ? null : 0),
+ ocularActinicUV,
+ ocularUvaJPerM2,
+ unsafeEyeExposure: hasUV && !eyesProtected,
+ };
} else {
- // Lux-only fallback (SAD lamps without per-band irradiance / peaks).
+ // Lux-only SAD data is photopic. It cannot become M-EDI without a
+ // measured spectrum or a declared melanopic daylight efficacy ratio.
const lux = device?.lux || 0;
- if (!eyesProtected && lux > 0) doses.circadian = lux * distanceFactor * durationSec / 100;
+ const directMelanopicEdi = Number(device?.melanopicEdiLux);
+ const photopicLux = lux * distanceFactor;
+ const melanopicDER = Number(device?.melanopicDER);
+ metrics.photopicLux = photopicLux > 0 ? photopicLux : null;
+ metrics.melanopicEdiLux = null;
+ metrics.melanopicStatus = 'spectrum-required';
+ if (!eyesProtected && Number.isFinite(directMelanopicEdi) && directMelanopicEdi > 0) {
+ const melanopicEdiLux = directMelanopicEdi * distanceFactor;
+ doses.circadian = melanopicEdiLux * 0.0013262 * durationSec;
+ metrics.melanopicEdiLux = melanopicEdiLux;
+ metrics.melanopicStatus = 'device-medi';
+ } else if (!eyesProtected && photopicLux > 0 && Number.isFinite(melanopicDER) && melanopicDER > 0) {
+ const melanopicEdiLux = photopicLux * melanopicDER;
+ doses.circadian = melanopicEdiLux * 0.0013262 * durationSec;
+ metrics.melanopicEdiLux = melanopicEdiLux;
+ metrics.melanopicStatus = 'device-der';
+ }
+ if (hasUV) warnings.push('This UV mode has no usable spectral irradiance, so UV dose, vitamin D, and burn estimates are unavailable.');
+ else if (photopicLux <= 0 && !(Number.isFinite(directMelanopicEdi) && directMelanopicEdi > 0)) warnings.push('No usable irradiance or eye-level light measurement is stored for this device, so numerical light signals are unavailable.');
+ else if (photopicLux > 0 && (!Number.isFinite(melanopicDER) || melanopicDER <= 0)) warnings.push('Photopic lux is stored, but melanopic EDI needs a measured spectrum or declared melanopic DER.');
+ if (Number.isFinite(directMelanopicEdi) && directMelanopicEdi > 0 && device?.melanopicBasis === 'vendor-claim') {
+ warnings.push('Melanopic EDI is vendor-stated; the measurement method is not independently verified here.');
+ }
+ }
+
+ const irradianceBasis = device?.irradianceBasis || 'unknown';
+ if (hasIrradiance && ['curated-estimate', 'unknown'].includes(irradianceBasis)) {
+ warnings.push(irradianceBasis === 'curated-estimate'
+ ? 'Reference irradiance is a curated estimate rather than a device-specific radiometer measurement.'
+ : 'The provenance of the stored reference irradiance is not recorded.');
}
+ const modelStatus = warnings.some(warning => /unavailable|withheld|no usable/i.test(warning))
+ ? 'partial'
+ : (distanceInfo.basis === 'reference-only' || distanceInfo.basis === 'measured-boundary' ? 'reference-only' : 'computed');
return {
doses,
@@ -141,5 +340,20 @@ export function computeDeviceSessionDoses({
distanceFactor,
eyeMode,
durationSec,
+ safety,
+ metrics,
+ model: {
+ status: modelStatus,
+ spectralBasis: hasPeaks && hasIrradiance
+ ? (hasDeclaredPeakShares ? (device?.peakShareBasis || 'declared') : 'heuristic')
+ : 'insufficient-specs',
+ irradianceBasis,
+ distanceBasis: distanceInfo.basis,
+ warnings,
+ },
+ distanceModel: Array.isArray(device?.irradianceByDistanceCm) && device.irradianceByDistanceCm.length >= 2
+ ? 'measured-table'
+ : device?.distanceModel === 'point-source' ? 'point-source' : 'reference-only',
+ engineVersion: DEVICE_ENGINE_VERSION,
};
}
diff --git a/js/light-device-session-modal.js b/js/light-device-session-modal.js
index 7baa787d..ec1472c5 100644
--- a/js/light-device-session-modal.js
+++ b/js/light-device-session-modal.js
@@ -2,10 +2,11 @@
// light-device-session-modal.js — Log/start light therapy device sessions.
import { state } from './state.js';
-import { escapeHTML, escapeAttr, showNotification } from './utils.js';
+import { escapeHTML, escapeAttr, showNotification, showConfirmDialog } from './utils.js';
import { openAppendedModalOverlay, removeModalOverlay } from './modal-lifecycle.js';
import { BODY_REGIONS, bindBodySilhouette, renderBodySilhouette } from './sun-body-silhouette.js';
import { validateModeCoupling } from './sun-spectrum.js';
+import { deviceEmitsUV } from './light-device-session-engine.js';
/**
* @param {Record} [deps]
@@ -18,6 +19,7 @@ function _resolveSessionDialogDeps(deps = {}) {
renderBodySilhouette: deps.renderBodySilhouette || renderBodySilhouette,
bindBodySilhouette: deps.bindBodySilhouette || bindBodySilhouette,
navigate: deps.navigate || null,
+ openLightSetup: deps.openLightSetup || null,
};
}
@@ -103,22 +105,32 @@ export async function openDeviceSessionDialog(deviceId, deps = {}) {
renderBodySilhouette,
bindBodySilhouette,
navigate,
+ openLightSetup,
} = resolvedDeps;
+ const configuredFitz = state.importedData?.sunDefaults?.fitzpatrick || null;
+ if (!/^(I|II|III|IV|V|VI)$/.test(String(configuredFitz || ''))) {
+ showNotification(
+ 'Confirm your Fitzpatrick skin type in Light setup before starting or logging a light-device session.',
+ 'info',
+ 7000,
+ );
+ openLightSetup?.();
+ return false;
+ }
+
// Lazy hydrate covers page-opened-mid-init / cold preset cache cases so
// the dialog renders with the latest mode/coupling schema.
await hydrateDevicesFromPresets?.().catch(() => {});
const device = getDevices?.()?.find(d => d.id === deviceId);
- if (!device) return;
+ if (!device) return false;
// Prefill from the user's last logged session on this device. First-time
// logs fall through to vendor reference distance + sensible defaults.
const last = device.lastSession || {};
- const defaultDuration = Number.isFinite(last.durationMin) && last.durationMin > 0 ? last.durationMin : 10;
const defaultDistanceCm = Number.isFinite(last.distanceCm) && last.distanceCm > 0
? last.distanceCm
: (device.recommendedDistanceCm || 15);
- const defaultEyesProtected = last.eyesProtected !== false;
const defaultRegions = _defaultRegionsForLastSession(last);
// Mode picker renders only for devices with multiple valid modes.
@@ -131,6 +143,17 @@ export async function openDeviceSessionDialog(deviceId, deps = {}) {
const lastModeValid = last.mode && validModes.some(m => m.id === last.mode);
defaultMode = lastModeValid ? last.mode : (validModes.find(m => m.default) || validModes[0])?.id || null;
}
+ const initialMode = showModePicker ? defaultMode : (last.mode || null);
+ const isUVDevice = deviceEmitsUV(device, initialMode);
+ // Never prefill a first UV session with the generic ten-minute PBM default.
+ // Thirty seconds is only a neutral input starting point, not guidance.
+ const defaultDuration = Number.isFinite(last.durationMin) && last.durationMin > 0
+ ? last.durationMin
+ : (isUVDevice ? 0.5 : 10);
+ const isEyeLightDevice = ['sad', 'dawn-sim', 'full-spectrum'].includes(device.type) && !isUVDevice;
+ const defaultEyeControlChecked = isEyeLightDevice
+ ? last.eyesProtected !== true
+ : last.eyesProtected !== false;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
@@ -151,7 +174,8 @@ export async function openDeviceSessionDialog(deviceId, deps = {}) {
- Vendor reference: ${fmt(refCm, 'cm')} cm (${fmt(refCm, 'in')} in).${overrideHint} The dose math uses inverse-square scaling around this point — close ranges magnify errors fast.
+ Vendor reference: ${fmt(refCm, 'cm')} cm (${fmt(refCm, 'in')} in).${overrideHint} ${Array.isArray(device.irradianceByDistanceCm) && device.irradianceByDistanceCm.length >= 2 ? 'Dose uses the device\'s measured distance table.' : device.distanceModel === 'point-source' ? 'Dose uses point-source inverse-square scaling declared for this device.' : 'No unverified distance correction is applied; use the vendor reference distance or enter measured irradiance data.'}
`;
})()}
- Eyes protected (goggles or closed)
+ ${isUVDevice ? 'UV-rated goggles worn (closed eyelids are not protection)' : isEyeLightDevice ? 'Eyes open to receive ambient light (never stare at lamp)' : 'Device-appropriate eye protection worn'}
@@ -201,6 +225,33 @@ export async function openDeviceSessionDialog(deviceId, deps = {}) {
btn.addEventListener('click', closeDialog);
});
+ const ambientEyeTypes = ['sad', 'dawn-sim', 'full-spectrum'];
+ const durationInput = _input(overlay, '#dev-session-duration');
+ let durationEdited = false;
+ durationInput?.addEventListener('input', () => { durationEdited = true; });
+ let eyeControlKind = isUVDevice ? 'uv' : isEyeLightDevice ? 'ambient' : 'protection';
+ const syncEyeControlForMode = (mode, { initial = false } = {}) => {
+ const emitsUV = deviceEmitsUV(device, mode);
+ const kind = emitsUV ? 'uv' : ambientEyeTypes.includes(device.type) ? 'ambient' : 'protection';
+ const eyeInput = _input(overlay, '#dev-session-eyes');
+ const eyeLabel = overlay.querySelector('#dev-session-eye-label');
+ // A checkbox from a non-UV mode cannot be treated as confirmation that
+ // UV-rated goggles are worn. Require a fresh, explicit confirmation.
+ if (!initial && kind === 'uv' && eyeControlKind !== 'uv') {
+ if (eyeInput) eyeInput.checked = false;
+ if (!durationEdited && durationInput) durationInput.value = '0.5';
+ }
+ if (eyeLabel) {
+ eyeLabel.textContent = kind === 'uv'
+ ? 'UV-rated goggles worn (closed eyelids are not protection)'
+ : kind === 'ambient'
+ ? 'Eyes open to receive ambient light (never stare at lamp)'
+ : 'Device-appropriate eye protection worn';
+ }
+ eyeControlKind = kind;
+ };
+ syncEyeControlForMode(initialMode, { initial: true });
+
let lastModePointerActivation = 0;
/**
* @param {HTMLElement} btn
@@ -214,6 +265,7 @@ export async function openDeviceSessionDialog(deviceId, deps = {}) {
b.classList.toggle('active', active);
b.setAttribute('aria-checked', active ? 'true' : 'false');
}
+ syncEyeControlForMode(mode);
};
for (const rawBtn of overlay.querySelectorAll('.dev-mode-btn[data-mode]')) {
const btn = /** @type {HTMLElement} */ (rawBtn);
@@ -292,17 +344,32 @@ export async function openDeviceSessionDialog(deviceId, deps = {}) {
}
_button(overlay, '#dev-session-save')?.addEventListener('click', async () => {
- const durationMin = parseInt(_input(overlay, '#dev-session-duration')?.value || '', 10) || 10;
+ const durationMin = parseFloat(_input(overlay, '#dev-session-duration')?.value || '');
+ if (!Number.isFinite(durationMin) || durationMin <= 0 || durationMin > 600) {
+ showNotification('Enter the actual duration between 0.1 and 600 minutes.', 'error');
+ return;
+ }
const distanceCm = _readDistanceCm(overlay, device.recommendedDistanceCm || 15);
const bodyAreas = Array.from(selectedRegions);
if (bodyAreas.length === 0) {
_showEmptyRegionError(updateAreaHint, selectedRegions, hint);
return;
}
- const bodyArea = _broadAreaForRegions(bodyAreas);
- const eyesProtected = !!_input(overlay, '#dev-session-eyes')?.checked;
const mode = showModePicker ? _input(overlay, '#dev-session-mode')?.value || null : null;
- await logDeviceSession({ deviceId, durationMin, distanceCm, bodyArea, bodyAreas, eyesProtected, mode });
+ const bodyArea = _broadAreaForRegions(bodyAreas);
+ const emitsUV = deviceEmitsUV(device, mode);
+ const eyeChecked = !!_input(overlay, '#dev-session-eyes')?.checked;
+ const eyeLightForMode = ambientEyeTypes.includes(device.type) && !emitsUV;
+ const eyesProtected = eyeLightForMode ? !eyeChecked : eyeChecked;
+ if (emitsUV && !eyesProtected) {
+ const saveUnsafe = await showConfirmDialog('This records UV exposure without UV-rated goggles. Save it as an unsafe past exposure?');
+ if (!saveUnsafe) return;
+ }
+ const saved = await logDeviceSession({ deviceId, durationMin, distanceCm, bodyArea, bodyAreas, eyesProtected, mode });
+ if (!saved) {
+ showNotification('The session could not be saved. Check the duration and distance.', 'error');
+ return;
+ }
closeDialog();
showNotification(`${durationMin} min ${escapeHTML(device.brand)} session saved.`);
navigate?.('light');
@@ -319,15 +386,27 @@ export async function openDeviceSessionDialog(deviceId, deps = {}) {
_showEmptyRegionError(updateAreaHint, selectedRegions, hint);
return;
}
- const bodyArea = _broadAreaForRegions(bodyAreas);
- const eyesProtected = !!_input(overlay, '#dev-session-eyes')?.checked;
const mode = showModePicker ? _input(overlay, '#dev-session-mode')?.value || null : null;
- await startDeviceSession({ deviceId, distanceCm, bodyAreas, bodyArea, eyesProtected, mode });
+ const bodyArea = _broadAreaForRegions(bodyAreas);
+ const emitsUV = deviceEmitsUV(device, mode);
+ const eyeChecked = !!_input(overlay, '#dev-session-eyes')?.checked;
+ const eyeLightForMode = ambientEyeTypes.includes(device.type) && !emitsUV;
+ const eyesProtected = eyeLightForMode ? !eyeChecked : eyeChecked;
+ if (emitsUV && !eyesProtected) {
+ showNotification('UV sessions require UV-rated goggles. Closed eyelids are not sufficient protection.', 'error', 8000);
+ return;
+ }
+ const startedId = await startDeviceSession({ deviceId, distanceCm, bodyAreas, bodyArea, eyesProtected, mode });
+ if (!startedId) {
+ showNotification('The timer could not start. Check that no other session is active.', 'error');
+ return;
+ }
closeDialog();
showNotification(`Live ${escapeHTML(device.brand)} session started — tap Stop & save when finished.`);
ensureActiveDeviceTicker();
navigate?.('light');
});
+ return true;
}
export {
diff --git a/js/light-device-setup-modal.js b/js/light-device-setup-modal.js
index 06420a32..5de5910c 100644
--- a/js/light-device-setup-modal.js
+++ b/js/light-device-setup-modal.js
@@ -154,7 +154,11 @@ export async function openAddDeviceDialog() {
addBtn?.addEventListener('click', async () => {
const presetId = selectedPresetId;
if (!presetId) return;
- await setupDeps.addDeviceFromPreset(presetId);
+ const added = await setupDeps.addDeviceFromPreset(presetId);
+ if (!added) {
+ showNotification('The device could not be added.', 'error');
+ return;
+ }
closeDialog();
showNotification('Device added.');
setupDeps.refreshLightView();
@@ -170,6 +174,8 @@ function _formatPresetMeta(p) {
parts.push(`${p.mwPerCm2At15cm} mW/cm²`);
} else if (Number.isFinite(Number(p.lux)) && Number(p.lux) > 0) {
parts.push(`${Number(p.lux).toLocaleString()} lux`);
+ } else if (Number.isFinite(Number(p.melanopicEdiLux)) && Number(p.melanopicEdiLux) > 0) {
+ parts.push(`${Number(p.melanopicEdiLux).toLocaleString()} lx M-EDI`);
}
if (Number.isFinite(Number(p.recommendedDistanceCm)) && Number(p.recommendedDistanceCm) > 0) {
parts.push(`${p.recommendedDistanceCm} cm`);
@@ -193,7 +199,7 @@ export async function openCustomDeviceDialog() {
${hasAI ? `
-
Paste a product page URL or scan the label — AI will extract the device specs. You can edit any field before saving.
+
Paste a product page URL or scan the label. AI fills the main fields and keeps any stated mode or measurement details. Verify them against the product page before saving.
@@ -557,7 +624,7 @@ export async function renderDevicesSection() {
`;
if (devices.length === 0) {
- html += `
Therapy panels, SAD lamps, dawn simulators — log them here and your sessions feed the same channels as outdoor sun.
+ html += `
Add therapy panels, SAD lamps, or dawn simulators to see the light signals their measured or stated output can support. Device light stays distinct from full-spectrum sunlight.
@@ -609,59 +678,6 @@ export async function renderDevicesSection() {
return html;
}
-// Compress a peak-wavelength array into a human-friendly summary.
-// 0 peaks → empty. 1-3 peaks → list as comma-separated. 4+ peaks →
-// "min-max nm (N bands)" so a 9-wavelength panel doesn't render as
-// "295/380/480/630/670/760/810/830/850 nm" eyeball-soup.
-function _formatWavelengthSummary(peaks) {
- if (!Array.isArray(peaks) || peaks.length === 0) return '';
- const sorted = peaks.slice().sort((a, b) => a - b);
- if (sorted.length <= 3) return sorted.join(' / ') + ' nm';
- return `${sorted[0]}–${sorted[sorted.length - 1]} nm (${sorted.length} bands)`;
-}
-
-// Per-device channel-icon strip — same icon set the dashboard pills
-// use, so users see at-a-glance which channels this device feeds. Hover
-// title shows the full channel name for screen readers / tooltips.
-function _renderDeviceChannelChips(channelKeys) {
- if (!Array.isArray(channelKeys) || channelKeys.length === 0) return '';
- // Order matches the dashboard pill row so the visual scan is consistent
- const order = ['vitamin_d', 'pomc', 'no_cv', 'violet_eye', 'circadian', 'nir_solar', 'pbm_red', 'pbm_nir'];
- const present = new Set(channelKeys);
- const chips = [];
- for (const k of order) {
- if (!present.has(k)) continue;
- const meta = CHANNEL_DISPLAY[k] || {};
- chips.push(`
- ${meta.icon || '·'}
- ${escapeHTML(meta.label || k)}
- `);
- }
- return chips.join('');
-}
-
-// Coarse relative-time formatter — "today" / "yesterday" / "N days ago"
-// / "N weeks ago" / "N months ago". Specifically NOT "X minutes ago"
-// because device sessions are typically minutes-long therapy bouts —
-// the user cares about the day-grain cadence, not freshness.
-function _relativeTimeShort(ts) {
- if (!ts) return 'never';
- const days = Math.floor((Date.now() - ts) / (24 * 3600 * 1000));
- if (days <= 0) return 'today';
- if (days === 1) return 'yesterday';
- if (days < 7) return `${days} days ago`;
- if (days < 30) {
- const w = Math.floor(days / 7);
- return `${w} week${w !== 1 ? 's' : ''} ago`;
- }
- if (days < 365) {
- const m = Math.floor(days / 30);
- return `${m} month${m !== 1 ? 's' : ''} ago`;
- }
- const y = Math.floor(days / 365);
- return `${y} year${y !== 1 ? 's' : ''} ago`;
-}
-
// ─── Quick-log entry point ────────────────────────────────────────────
// Single entry used by the Light page CTA row, dashboard strip, and
// drill-down panel suggestions. Behaviour by device count:
@@ -677,9 +693,7 @@ export function quickLogDeviceSession() {
function _openDevicePicker(devices) {
// Most-recently-added first so the user's primary panel is at the top.
- // (Devices array order isn't guaranteed chronological — sort by id which
- // embeds Date.now() base36, monotonically increasing.)
- const ordered = devices.slice().sort((a, b) => (b.id || '').localeCompare(a.id || ''));
+ const ordered = devices.slice().sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
const closeDialog = () => removeModalOverlay(overlay);
@@ -725,7 +739,24 @@ export async function deleteDeviceSessionWithConfirm(id) {
// ─── UI wrappers ───────────────────────────────────────────────────────
export async function deleteLightDeviceAndRefresh(id) {
- await deleteDevice(id);
+ const device = getDevices().find(candidate => candidate.id === id);
+ if (!device) return;
+ const active = getActiveDeviceSession();
+ if (active?.deviceId === id) {
+ showNotification('Stop and save the active session before removing this device.', 'error');
+ return;
+ }
+ const sessionCount = getDeviceSessions().filter(session => session.deviceId === id).length;
+ const historyNote = sessionCount
+ ? ` ${sessionCount} saved session${sessionCount === 1 ? '' : 's'} will keep a copy of these device details.`
+ : '';
+ if (!await showConfirmDialog(`Remove ${device.brand} ${device.model}?${historyNote}`)) return;
+ const deleted = await deleteDevice(id);
+ if (!deleted) {
+ showNotification('The device could not be removed.', 'error');
+ return;
+ }
+ showNotification(sessionCount ? 'Device removed. Saved session history was retained.' : 'Device removed.');
refreshLightDevicesView();
}
diff --git a/js/light-env-actions.js b/js/light-env-actions.js
index 7832675c..54d854b1 100644
--- a/js/light-env-actions.js
+++ b/js/light-env-actions.js
@@ -80,6 +80,8 @@ function handleLightEnvAction(actionEl, event, actions) {
if (action === 'set-room-source-archetype') {
void actions.setLightEnvRoomSourceArchetype?.(id, key);
+ } else if (action === 'set-room-daylight-level') {
+ void actions.setLightEnvRoomDaylightLevel?.(id, key);
} else if (action === 'update-room-primary-source') {
void actions.updateLightEnvRoomAndRender?.(id, { primarySource: actionEl.value });
} else if (action === 'set-room-hours-bucket') {
diff --git a/js/light-env-ai-analysis.js b/js/light-env-ai-analysis.js
index 28f20c08..e6f0c585 100644
--- a/js/light-env-ai-analysis.js
+++ b/js/light-env-ai-analysis.js
@@ -12,6 +12,7 @@ import { hasAIProvider } from './api.js';
import { createAIVerdict, hashString, dotPrefix } from './ai-verdict-engine.js';
import { LIGHTING_HARDWARE_CAVEATS } from './lighting-hardware-caveats.js';
import { getRoomEveningHoursAfterSunset } from './light-env-evening.js';
+import { isQuantitativeDarknessMeasurement, isQuantitativeLuxMeasurement } from './light-env-model.js';
import { formatHealthGoalsText } from './health-goals-utils.js';
import { aiActionAttrs, registerAIActionHandler } from './ai-action-delegates.js';
@@ -26,7 +27,7 @@ function _getScreensForRoom(roomId) {
// Bumped 2026-05-08: prompt biology priors tightened to Brown 2022
// melanopic-EDI thresholds. Existing cached verdicts may carry the
// older 100-lux daytime / >1-photopic-lux night anchors — invalidate.
-const _roomFingerprintSalt = 'v2-brown2022-medi';
+const _roomFingerprintSalt = 'v3-measurement-quality';
export function getRoomFingerprint(r) {
if (!r) return '';
const measurements = _getMeasurementsForRoom(r.id);
@@ -35,6 +36,7 @@ export function getRoomFingerprint(r) {
_roomFingerprintSalt,
r.name || '',
r.primarySource || '',
+ r.daylightLevel || '',
r.hoursOccupiedPerDay || 0,
getRoomEveningHoursAfterSunset(r),
];
@@ -43,9 +45,9 @@ export function getRoomFingerprint(r) {
if (!byTool.has(m.tool)) byTool.set(m.tool, m);
}
for (const [tool, m] of [...byTool.entries()].sort()) {
- parts.push(`${tool}:${typeof m.value === 'number' ? Math.round(m.value * 100) / 100 : m.value}`);
+ parts.push(`${tool}:${typeof m.value === 'number' ? Math.round(m.value * 100) / 100 : m.value}:${m.extra?.method || m.extra?.source || ''}`);
}
- parts.push(`screens:${screens.map(s => s.type).sort().join(',')}`);
+ parts.push(`screens:${screens.map(s => `${s.device}:${s.eveningUseAfterSunset ?? ''}:${s.blueBlockerEnabled ? 1 : 0}`).sort().join(',')}`);
return hashString(parts.join('|'));
}
@@ -84,6 +86,7 @@ export function buildRoomContext(r) {
lines.push(`### Room`);
lines.push(`Name: ${_safeText(r.name) || '(unnamed)'}`);
if (r.primarySource) lines.push(`Primary light source: ${_SOURCE_LABELS[r.primarySource] || r.primarySource}`);
+ if (r.daylightLevel && r.daylightLevel !== 'unknown') lines.push(`Daylight reaching room during usual use: ${r.daylightLevel}`);
if (r.hoursOccupiedPerDay != null) lines.push(`Hours occupied per day: ${r.hoursOccupiedPerDay}`);
const eveningHrs = getRoomEveningHoursAfterSunset(r);
lines.push(eveningHrs > 0
@@ -100,19 +103,26 @@ export function buildRoomContext(r) {
for (const [tool, m] of byTool) {
switch (tool) {
case 'lux':
- lines.push(`Lux: ${Math.round(m.value)} lux`);
+ lines.push(`Lux: ${Math.round(m.value)} photopic lux (${m.extra?.source || 'legacy/unknown source'}; ${isQuantitativeLuxMeasurement(m) ? 'usable spot-check' : 'unverified camera estimate — do not threshold'})`);
break;
case 'flicker': {
const score = Math.round(m.value || 0);
const sLabel = ['pristine', 'mild', 'moderate', 'severe'][score] || 'unknown';
- lines.push(`Flicker: ${score}/3 (${sLabel})${m.extra?.stripes ? `, ${m.extra.stripes} PWM stripes` : ''}`);
+ lines.push(`Camera banding: ${score}/3 (${sLabel})${m.extra?.stripes ? `, ${m.extra.stripes} rolling-shutter stripe groups` : ''}`);
break;
}
case 'darkness':
- lines.push(`Sleep darkness: mean ${_formatNumber(m.extra?.meanLux ?? m.value, 2)} lux, peak ${_formatNumber(m.extra?.peakLux, 2)} lux${m.extra?.label ? ' (' + m.extra.label + ')' : ''}`);
+ if (isQuantitativeDarknessMeasurement(m)) {
+ lines.push(`Sleep-time meter entry: ${_formatNumber(m.value, 2)} photopic lux (not melanopic EDI)`);
+ } else {
+ lines.push(`Sleep-light camera check: ${m.extra?.levelLabel || 'qualitative'} (not lux; do not infer melatonin suppression)`);
+ }
break;
case 'cct':
- lines.push(`CCT: ${Math.round(m.value)} K${m.extra?.melanopic != null ? `, melanopic ratio ${_formatNumber(m.extra.melanopic, 2)}` : ''}${m.extra?.pwmActive ? ', PWM detected' : ''}`);
+ {
+ const blueRatio = m.extra?.cameraBlueRatioProxy ?? m.extra?.melanopic;
+ lines.push(`Approximate camera CCT: ~${Math.round(m.value / 100) * 100} K${blueRatio != null ? `, camera RGB blue-ratio proxy ${_formatNumber(blueRatio, 2)} (not melanopic EDI)` : ''}${m.extra?.bandingDetected || m.extra?.pwmActive ? ', camera banding also detected' : ''}`);
+ }
break;
case 'spectrum':
lines.push(`Spectrum: ${m.value || m.extra?.label}${m.extra?.circadian ? ` (${m.extra.circadian})` : ''}`);
@@ -132,7 +142,7 @@ export function buildRoomContext(r) {
lines.push('### Screens used in this room');
const typeCounts = {};
for (const s of screens) {
- const t = _SCREEN_TYPE_LABELS[s.type] || s.type;
+ const t = _SCREEN_TYPE_LABELS[s.device] || s.device;
typeCounts[t] = (typeCounts[t] || 0) + 1;
}
for (const [t, n] of Object.entries(typeCounts)) {
@@ -156,22 +166,23 @@ const SYSTEM_PROMPT = [
'Return ONLY valid JSON with three keys: {"dot":"green|yellow|red|gray","tip":"string","detail":"string"}.',
'',
'dot:',
- ' green = circadian-aligned (daytime rooms get bright + cool-toned light, evening rooms stay dim + warm-toned, sleep rooms are dark + flicker-free)',
- ' yellow = mostly OK with one or two specific issues (one too-cool fixture in evening, modest flicker, sleep room not dark enough)',
- ' red = circadian-hostile (bright cool light in evening, severe flicker, bright sleep room, phone-in-bed unmitigated)',
- ' gray = not enough data to judge (room has only a name)',
+ ' green = the entered timing and trustworthy measurements flag no clear concern',
+ ' yellow = one actionable screening signal is present or important measurement quality is limited',
+ ' red = multiple strong entered signals stack (for example long bright evening use plus clear banding)',
+ ' gray = not enough data or only uncalibrated camera proxies',
'',
'Biology priors:',
- ' • Sleep rooms: per Brown TM 2022 (PLOS Biol 20:e3001571) the modern melanopic-EDI consensus is <1 melanopic lux during sleep, <10 in the hour before bed. Even ~40 photopic lux from a bedside lamp or TV measurably impairs sleep architecture (Cho 2013, Sleep Med 14:1422). Cool-toned (>4000K) light within 2 hours of bedtime delays sleep onset; phone in bed is the largest junk-light vector for most users.',
- ' • Daytime rooms: per Brown 2022, target ≥250 melanopic-EDI lux at the eye during the day. With typical mixed-spectrum indoor lighting that\'s roughly ≥500 photopic lux; bright daylit / north-window setups hit it more easily. Below ~50 photopic lux for hours at a stretch is flat-out under-lit regardless of source.',
- ' • Evening living spaces: warm (≤2700K) + dim (≤200 lux) is melatonin-friendly; bright cool overhead lights with TV blue light is not.',
- ' • Flicker score 2+ correlates with eyestrain + headaches in sensitive populations regardless of brightness.',
+ ' • Brown 2022 recommendations are eye-level melanopic EDI: ≥250 lx during daytime, ≤10 lx in the evening, and ≤1 lx during sleep. Ordinary photopic lux and camera RGB are not interchangeable with melanopic EDI.',
+ ' • A trustworthy eye-level photopic-lux spot-check can describe general brightness, but source spectrum and exposure duration remain unknown.',
+ ' • Camera CCT and RGB results are warm/cool proxies only. CCT cannot establish spectral completeness or melanopic content.',
+ ' • A camera banding score detects some rolling-shutter patterns; no banding does not prove flicker-free output and stripe count is not frequency.',
+ ' • Screen tint / Night Shift / glasses may reduce short-wavelength exposure but never count as zero; brightness, distance, and duration remain relevant.',
' • A high evening-hours-after-sunset count amplifies the cost of a hostile spectrum in that room — flag harder when the user spends multiple evening hours there.',
'',
...LIGHTING_HARDWARE_CAVEATS,
'',
- 'tip: one sentence, max 16 words. Pick the SINGLE most-leveraged fix, with concrete action language.',
- 'detail: 2–3 sentences. List up to 2 specific issues + the corresponding biology, then the highest-priority fix. If the room\'s flicker score is 1+, the recommendation MUST NOT introduce a dimmer; cite the hardware caveats above.',
+ 'tip: one sentence, max 16 words. Pick the single most useful next measurement or change.',
+ 'detail: 2–3 sentences. Separate entered facts, calibrated measurements, and camera proxies. Never estimate hormone suppression, phase shift, or melanopic dose from ordinary lux/CCT/RGB. If flicker is flagged, the recommendation MUST NOT introduce a generic dimmer.',
'',
'No "you should" — be observational and direct. No emoji.',
].join('\n');
diff --git a/js/light-env-audits.js b/js/light-env-audits.js
index 08b8b956..fa180646 100644
--- a/js/light-env-audits.js
+++ b/js/light-env-audits.js
@@ -63,8 +63,16 @@ function getEnvironmentSnapshot() {
return auditDeps.getEnvironment();
}
-function computeRoomSeverity(room, measurements) {
- return auditDeps.computeRoomSeverity(room, measurements);
+function computeRoomSeverity(room, measurements, options = {}) {
+ return auditDeps.computeRoomSeverity(room, measurements, options);
+}
+
+function computeAuditRoomSeverity(audit, room) {
+ const measurements = (audit?.measurements || []).filter(m => m.roomId === room?.id);
+ const screens = (audit?.screens || []).filter(screen => screen?.roomId === room?.id);
+ // A saved audit must be interpreted from its own frozen snapshot, not
+ // whichever screens happen to be live today.
+ return computeRoomSeverity(room, measurements, { screens, isActiveToday: () => true });
}
function refreshLightEnvironmentUI(options = {}) {
@@ -131,13 +139,13 @@ export async function saveLightAudit(label = '') {
const roomIds = new Set((env.rooms || []).map(r => r.id).filter(Boolean));
const measurements = (state.importedData?.lightMeasurements || [])
.filter(m => m?.roomId && roomIds.has(m.roomId))
- .map(m => ({ ...m }));
+ .map(m => JSON.parse(JSON.stringify(m)));
const today = new Date();
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const audit = {
id: createUniqueId('la_'),
date,
- label: label || `Audit ${audits.length + 1}`,
+ label: String(label || `Audit ${audits.length + 1}`).replace(/\s+/g, ' ').trim().slice(0, 80),
notes: '',
rooms: JSON.parse(JSON.stringify(env.rooms || [])),
screens: JSON.parse(JSON.stringify(env.screens || [])),
@@ -159,7 +167,16 @@ export async function updateLightAudit(id, patch) {
const audits = getLightAudits();
const a = audits.find(x => x.id === id);
if (!a) return;
- Object.assign(a, patch);
+ if (Object.prototype.hasOwnProperty.call(patch || {}, 'label')) {
+ a.label = String(patch.label || '').replace(/\s+/g, ' ').trim().slice(0, 80);
+ }
+ if (Object.prototype.hasOwnProperty.call(patch || {}, 'notes')) {
+ a.notes = String(patch.notes || '').slice(0, 1000);
+ }
+ if (Object.prototype.hasOwnProperty.call(patch || {}, 'date')) {
+ const date = String(patch.date || '');
+ if (/^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(new Date(`${date}T00:00:00`).getTime())) a.date = date;
+ }
a.updatedAt = Date.now();
await saveImportedData();
}
@@ -172,15 +189,18 @@ export async function deleteLightAudit(id) {
// Worst-room-tier rolls up to the audit-level severity badge.
function computeAuditSeverity(audit) {
const rooms = audit?.rooms || [];
- const measurements = audit?.measurements || [];
let worstTier = 0;
+ let hasInterpretableRoom = false;
for (const r of rooms) {
- const roomMeas = measurements.filter(m => m.roomId === r.id);
- const sev = computeRoomSeverity(r, roomMeas);
+ const sev = computeAuditRoomSeverity(audit, r);
+ if (sev.color !== 'incomplete') hasInterpretableRoom = true;
if (sev.tier > worstTier) worstTier = sev.tier;
}
+ if (rooms.length === 0 || !hasInterpretableRoom) {
+ return { tier: 0, color: 'incomplete', label: 'Needs details' };
+ }
const colorMap = ['green', 'yellow', 'orange', 'red', 'red'];
- const labelMap = ['Good', 'Mild', 'Moderate', 'Concerning', 'Severe'];
+ const labelMap = ['No concern flagged', 'Worth checking', 'Needs attention', 'High signal', 'Strong signal'];
return { tier: worstTier, color: colorMap[Math.min(worstTier, 4)], label: labelMap[Math.min(worstTier, 4)] };
}
@@ -196,7 +216,20 @@ function fmtAuditDate(d) {
}
function flickerLabel(score) {
- return ['Pristine', 'Mild', 'Moderate', 'Severe'][Math.min(3, Math.max(0, Math.round(score)))] || String(score);
+ return ['No banding detected', 'Some banding', 'Clear banding', 'Strong banding'][Math.min(3, Math.max(0, Math.round(score)))] || String(score);
+}
+
+function fmtAuditLux(m) {
+ const value = Math.round(Number(m?.value) || 0);
+ if (m?.extra?.source === 'camera-estimate') return `~${value} lux (camera estimate)`;
+ if (['AmbientLightSensor', 'manual-entry', 'meter-entry'].includes(m?.extra?.source)) return `${value} lux`;
+ return `${value} lux (method unknown)`;
+}
+
+function fmtAuditDarkness(m) {
+ if (m?.extra?.method === 'camera-relative') return `${m.extra?.levelLabel || 'Qualitative'} camera check`;
+ if (m?.extra?.method === 'meter-entry' || m?.extra?.source === 'meter-entry') return `${Number(m?.value || 0).toFixed(2)} lux (meter)`;
+ return `${Number(m?.value || 0).toFixed(2)} legacy value (method unknown)`;
}
function sortAuditsNewestFirst(audits) {
@@ -244,12 +277,12 @@ function _auditRoomChannels(audit, room) {
const cct = latestInAudit(audit, 'cct', room.id);
const spec = latestInAudit(audit, 'spectrum', room.id);
return [
- lux ? { key: 'lux', label: 'Lux', text: `${Math.round(lux.value)} lux` } : null,
- dark ? { key: 'darkness', label: 'Darkness', text: `${(+dark.value).toFixed(2)} lux` } : null,
+ lux ? { key: 'lux', label: 'Brightness', text: fmtAuditLux(lux) } : null,
+ dark ? { key: 'darkness', label: 'Sleep light', text: fmtAuditDarkness(dark) } : null,
fli ? { key: 'flicker', label: 'Flicker', text: flickerLabel(fli.value) } : null,
- cct ? { key: 'cct', label: 'CCT', text: `${cct.value} K` } : null,
- spec?.extra?.melanopic != null
- ? { key: 'melanopic', label: 'Melanopic', text: `${(spec.extra.melanopic * 100).toFixed(0)}%` }
+ cct ? { key: 'cct', label: 'Warm / cool', text: `~${Math.round(cct.value / 100) * 100} K (camera)` } : null,
+ (spec?.extra?.cameraBlueRatioProxy ?? spec?.extra?.melanopic) != null
+ ? { key: 'camera-blue-proxy', label: 'Camera blue proxy', text: `${((spec.extra.cameraBlueRatioProxy ?? spec.extra.melanopic) * 100).toFixed(0)}%` }
: null,
].filter(ch => ch !== null);
}
@@ -273,8 +306,7 @@ function renderLightAuditDetail(a) {
} else {
html += `
`;
for (const r of a.rooms) {
- const roomMeas = (a.measurements || []).filter(m => m.roomId === r.id);
- const sev = computeRoomSeverity(r, roomMeas);
+ const sev = computeAuditRoomSeverity(a, r);
const channels = _auditRoomChannels(a, r);
html += `
@@ -320,10 +352,10 @@ function _compareArrow(delta, better) {
// darkness/flicker/melanopic down = better (sleep-safer); lux/CCT depend
// on time-of-day so neutral arrow color (we still show direction).
const COMPARE_CHANNELS = [
- { tool: 'lux', label: 'Lux', fmt: v => `${Math.round(v)} lux`, better: 'depends' },
- { tool: 'darkness', label: 'Darkness', fmt: v => `${(+v).toFixed(2)} lux`, better: 'lower' },
- { tool: 'flicker', label: 'Flicker', fmt: v => flickerLabel(v), better: 'lower' },
- { tool: 'cct', label: 'CCT', fmt: v => `${v} K`, better: 'depends' },
+ { tool: 'lux', label: 'Brightness', fmt: (_v, m) => fmtAuditLux(m), better: 'depends' },
+ { tool: 'darkness', label: 'Sleep light', fmt: (_v, m) => fmtAuditDarkness(m), better: 'depends' },
+ { tool: 'flicker', label: 'Banding', fmt: v => flickerLabel(v), better: 'lower' },
+ { tool: 'cct', label: 'Warm / cool', fmt: v => `~${Math.round(v / 100) * 100} K`, better: 'depends' },
];
// Serialize an audit pair into a plain-text comparison the AI can
@@ -346,25 +378,25 @@ function serializeAuditComparison(a1, a2) {
for (const name of roomNames) {
const r1 = (a1.rooms || []).find(r => r.name === name);
const r2 = (a2.rooms || []).find(r => r.name === name);
- const sev1 = r1 ? computeRoomSeverity(r1, (a1.measurements || []).filter(m => m.roomId === r1.id)) : null;
- const sev2 = r2 ? computeRoomSeverity(r2, (a2.measurements || []).filter(m => m.roomId === r2.id)) : null;
+ const sev1 = r1 ? computeAuditRoomSeverity(a1, r1) : null;
+ const sev2 = r2 ? computeAuditRoomSeverity(a2, r2) : null;
const channels = [];
for (const ch of COMPARE_CHANNELS) {
const m1 = r1 ? latestInAudit(a1, ch.tool, r1.id) : null;
const m2 = r2 ? latestInAudit(a2, ch.tool, r2.id) : null;
if (!m1 && !m2) continue;
- const before = m1 ? ch.fmt(m1.value) : '—';
- const after = m2 ? ch.fmt(m2.value) : '—';
+ const before = m1 ? ch.fmt(m1.value, m1) : '—';
+ const after = m2 ? ch.fmt(m2.value, m2) : '—';
channels.push(` ${ch.label}: ${before} → ${after}`);
}
const sp1 = r1 ? latestInAudit(a1, 'spectrum', r1.id) : null;
const sp2 = r2 ? latestInAudit(a2, 'spectrum', r2.id) : null;
- const mel1 = sp1?.extra?.melanopic;
- const mel2 = sp2?.extra?.melanopic;
+ const mel1 = sp1?.extra?.cameraBlueRatioProxy ?? sp1?.extra?.melanopic;
+ const mel2 = sp2?.extra?.cameraBlueRatioProxy ?? sp2?.extra?.melanopic;
if (mel1 != null || mel2 != null) {
const before = mel1 != null ? `${(mel1 * 100).toFixed(0)}%` : '—';
const after = mel2 != null ? `${(mel2 * 100).toFixed(0)}%` : '—';
- channels.push(` Melanopic ratio: ${before} → ${after}`);
+ channels.push(` Camera RGB blue-ratio proxy (not melanopic EDI): ${before} → ${after}`);
}
if (!channels.length && !(sev1 || sev2)) continue;
let header = `Room: ${name}`;
@@ -427,8 +459,8 @@ function renderLightAuditCompare(audits) {
// fall back to before-side, then to a literal "?" so a malformed
// entry can't crash the loop.
const name = (r2 && r2.name) || (r1 && r1.name) || '?';
- const sev1 = r1 ? computeRoomSeverity(r1, (a1.measurements || []).filter(m => m.roomId === r1.id)) : null;
- const sev2 = r2 ? computeRoomSeverity(r2, (a2.measurements || []).filter(m => m.roomId === r2.id)) : null;
+ const sev1 = r1 ? computeAuditRoomSeverity(a1, r1) : null;
+ const sev2 = r2 ? computeAuditRoomSeverity(a2, r2) : null;
// Build the list of comparable rows — only channels that have data
// on at least ONE side. A row with both sides null is dropped.
@@ -439,11 +471,12 @@ function renderLightAuditCompare(audits) {
if (!m1 && !m2) continue;
rows.push({ ch, m1, m2 });
}
- // Melanopic comes from spectrum.extra.
+ // Historical camera readings used `melanopic`; they were normalized RGB
+ // blue ratios, so prefer the explicit proxy key and label both honestly.
const sp1 = r1 ? latestInAudit(a1, 'spectrum', r1.id) : null;
const sp2 = r2 ? latestInAudit(a2, 'spectrum', r2.id) : null;
- const mel1 = sp1?.extra?.melanopic;
- const mel2 = sp2?.extra?.melanopic;
+ const mel1 = sp1?.extra?.cameraBlueRatioProxy ?? sp1?.extra?.melanopic;
+ const mel2 = sp2?.extra?.cameraBlueRatioProxy ?? sp2?.extra?.melanopic;
const hasMelanopic = mel1 != null || mel2 != null;
// Skip rooms that have no measurements on either side AND no
@@ -473,8 +506,8 @@ function renderLightAuditCompare(audits) {
if (rows.length || hasMelanopic) {
html += `
`;
for (const { ch, m1, m2 } of rows) {
- const before = m1 ? ch.fmt(m1.value) : '—';
- const after = m2 ? ch.fmt(m2.value) : '—';
+ const before = m1 ? ch.fmt(m1.value, m1) : '—';
+ const after = m2 ? ch.fmt(m2.value, m2) : '—';
const arrow = (m1 && m2) ? _compareArrow((+m2.value) - (+m1.value), ch.better) : '→';
html += `
${escapeHTML(ch.label)}
@@ -496,7 +529,7 @@ function renderLightAuditCompare(audits) {
arrow = '→';
}
html += `
- Melanopic
+ Camera blue proxy${escapeHTML(before)}
${arrow}
${escapeHTML(after)}
diff --git a/js/light-env-editor.js b/js/light-env-editor.js
index f65e9487..06f45145 100644
--- a/js/light-env-editor.js
+++ b/js/light-env-editor.js
@@ -13,6 +13,7 @@ import {
updateScreen,
} from './light-env-store.js';
import {
+ DAYLIGHT_LEVELS,
EVENING_BUCKETS,
HOURS_BUCKETS,
SCREEN_DEVICES,
@@ -183,21 +184,33 @@ async function setLightEnvRoomHoursBucket(id, bucketKey) {
refreshUI();
}
+async function setLightEnvRoomDaylightLevel(id, levelKey) {
+ if (!DAYLIGHT_LEVELS.some(item => item.key === levelKey)) return;
+ await updateRoom(id, { daylightLevel: levelKey });
+ refreshUI();
+}
+
// Auto-fill a room's primarySource from the Spectrum tool's classification
// only while the source remains unknown.
-export async function suggestRoomSourceFromSpectrum(roomId, spectrumLabel) {
+export async function suggestRoomSourceFromSpectrum(roomId, spectrumLabel, metadata = {}) {
const env = getEnvironment();
const room = (env?.rooms || []).find(item => item.id === roomId);
if (!room) return;
if (room.primarySource && room.primarySource !== 'unknown') return;
+ // Camera RGB cannot reliably identify LED construction, daylight, or a
+ // full spectrum. Only a user's explicit manual classification can fill
+ // the room field automatically.
+ if (metadata?.method !== 'manual-classification') return;
const spectrumToSource = {
'Fluorescent / CFL': 'fluorescent',
+ 'Fluorescent': 'fluorescent',
'Incandescent / halogen': 'incandescent',
'Cool LED (4000K+)': 'led-cool',
'Cool LED with PWM dimming': 'led-cool',
'Warm LED (2700–3000K)': 'led-warm',
'Warm LED with PWM dimming': 'led-warm',
'Daylight or full-spectrum': 'natural-only',
+ 'Daylight': 'natural-only',
'Mixed / unclassified': 'mixed',
};
const mapped = spectrumToSource[spectrumLabel];
@@ -225,7 +238,7 @@ async function deleteLightEnvRoom(id) {
}
async function deleteLightEnvRoomConfirm(id) {
- if (await showConfirmDialog('Delete this room? Room-linked readings will be removed.')) {
+ if (await showConfirmDialog('Delete this room? Its saved readings and audit snapshots will remain available as historical data.')) {
await deleteRoom(id);
if (readActiveRoomId() === id) writeActiveRoomId(null);
refreshUI();
@@ -329,6 +342,7 @@ export const lightEnvEditorActionHandlers = Object.freeze({
toggleLightEnvRoomExpanded,
updateLightEnvRoom,
setLightEnvRoomSourceArchetype,
+ setLightEnvRoomDaylightLevel,
setLightEnvRoomHoursBucket,
setLightEnvRoomEveningBucket,
updateLightEnvRoomAndRender,
diff --git a/js/light-env-model.js b/js/light-env-model.js
index 104094e4..26c85c0d 100644
--- a/js/light-env-model.js
+++ b/js/light-env-model.js
@@ -8,7 +8,6 @@
import {
getRoomEveningHoursAfterSunset,
hasRoomEveningAnswer,
- roomUsesEveningAfterSunset,
} from './light-env-evening.js';
export const PRIMARY_SOURCES = [
@@ -32,6 +31,12 @@ export const SCREEN_DEVICES = [
{ key: 'tv', label: 'TV' },
];
+export const DAYLIGHT_LEVELS = [
+ { key: 'low', label: 'Little' },
+ { key: 'some', label: 'Some' },
+ { key: 'strong', label: 'Strong' },
+];
+
// 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.
@@ -100,103 +105,125 @@ export function defaultHoursForName(name) {
}
// 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"
+// (or "I don't know"), no daylight answer, no measurements, and no
+// evening-hours answer. Occupancy alone cannot grade light. The 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) {
+function _hasAnyRoomSignal(room, measurements, screens = []) {
if (!room) return false;
const hasSource = room.primarySource && room.primarySource !== 'unknown';
- const hasHours = (+room.hoursOccupiedPerDay) > 0;
+ const hasDaylight = room.daylightLevel && room.daylightLevel !== 'unknown';
const hasEvening = hasRoomEveningAnswer(room);
const hasMeas = (measurements || []).length > 0;
- return hasSource || hasHours || hasEvening || hasMeas;
+ const hasScreen = (screens || []).length > 0;
+ return hasSource || hasDaylight || hasEvening || hasMeas || hasScreen;
+}
+
+function _latestMeasurement(measurements, tool) {
+ return (measurements || [])
+ .filter(m => m?.tool === tool)
+ .sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0))[0] || null;
+}
+
+export function isQuantitativeLuxMeasurement(measurement) {
+ if (!measurement || measurement.tool !== 'lux' || !Number.isFinite(Number(measurement.value))) return false;
+ const source = measurement.extra?.source;
+ // A phone camera remains an approximate brightness proxy even after a
+ // one-point calibration: exposure pipelines, lenses, and spectral
+ // response vary by device and light source. Keep camera estimates useful
+ // in the UI, but do not let them drive the screening calculation.
+ return source === 'AmbientLightSensor' || source === 'manual-entry' || source === 'meter-entry';
+}
+
+export function isQuantitativeDarknessMeasurement(measurement) {
+ if (!measurement || measurement.tool !== 'darkness' || !Number.isFinite(Number(measurement.value))) return false;
+ return measurement.extra?.method === 'meter-entry' || measurement.extra?.source === 'meter-entry';
+}
+
+function _isLikelyDaytimeMeasurement(measurement) {
+ if (!measurement) return false;
+ if (measurement.extra?.context === 'daytime') return true;
+ if (measurement.extra?.context === 'evening' || measurement.extra?.context === 'sleep') return false;
+ if (!measurement.capturedAt) return false;
+ const hour = new Date(measurement.capturedAt).getHours();
+ return hour >= 7 && hour < 19;
}
export function computeRoomSeverityForRoom(room, measurements = [], options = {}) {
- if (!room) return { tier: 0, color: 'green', label: 'Unknown', reason: 'No data yet' };
+ if (!room) return { tier: 0, color: 'incomplete', label: 'Unknown', reason: 'No data yet' };
+ const isActiveToday = options.isActiveToday || (() => true);
+ const screensHere = (options.screens || []).filter(s => s && isActiveToday(s));
// Gray-dot incomplete state for empty rooms — distinct from "Good".
- if (!_hasAnyRoomSignal(room, measurements)) {
+ if (!_hasAnyRoomSignal(room, measurements, screensHere)) {
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
+ // Source type alone is context, not a dose. Treat it as a concern only
+ // when after-sunset use is also reported.
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') {
+ const eveningHours = getRoomEveningHoursAfterSunset(room);
+ if (eveningHours > 0 && (src === 'led-cool' || src === 'led-tunable' || src === 'fluorescent')) {
+ tier = Math.max(tier, eveningHours >= 3 ? 2 : 1);
+ reasons.push(`${eveningHours} hr after sunset under a cool-spectrum source; brightness is unknown`);
+ } else if (eveningHours >= 3 && src === 'mixed') {
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');
+ reasons.push('long evening use under mixed lighting; brightness is unknown');
}
- // 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'); }
+ const flicker = _latestMeasurement(measurements, 'flicker');
+ if (flicker) {
+ const score = Number(flicker.value);
+ if (score >= 3) { tier = Math.max(tier, 4); reasons.push('strong camera banding detected'); }
+ else if (score >= 2) { tier = Math.max(tier, 3); reasons.push('clear camera banding detected'); }
+ else if (score >= 1) { tier = Math.max(tier, 1); reasons.push('some camera banding detected'); }
}
- // 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;
+ // Grade photopic lux only when it came from a sensor or meter/manual
+ // entry. Camera estimates stay contextual. Never call it melanopic EDI.
+ const luxReading = _latestMeasurement(measurements, 'lux');
+ if (isQuantitativeLuxMeasurement(luxReading) && _isLikelyDaytimeMeasurement(luxReading)) {
+ const lux = Number(luxReading.value);
if (lux < 50 && (room.hoursOccupiedPerDay || 0) >= 2) {
tier = Math.max(tier, 2);
- reasons.push('very low daytime lux for hours occupied');
+ reasons.push('very dim daytime spot check for a frequently used room');
} else if (lux < 200 && (room.hoursOccupiedPerDay || 0) >= 4) {
tier = Math.max(tier, 1);
- reasons.push('lower than office-bright for prolonged hours');
+ reasons.push('dim daytime spot check; spectrum-weighted light is unknown');
}
}
- // 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'); }
+ // A camera darkness check stays qualitative. Only a user-entered meter
+ // reading gets numerical grading, and photopic lux is still a rough
+ // screen because the source spectrum is unknown.
+ const dark = _latestMeasurement(measurements, 'darkness');
+ if (isQuantitativeDarknessMeasurement(dark) && /bedroom|sleep/i.test(room.name || '')) {
+ const lux = Number(dark.value);
+ if (lux > 5) { tier = Math.max(tier, 3); reasons.push('sleep-time light measured; spectrum-weighted level is unknown'); }
+ else if (lux > 1) { tier = Math.max(tier, 2); reasons.push('sleep-time light measured; check the source and spectrum'); }
+ else if (lux > 0.1) { tier = Math.max(tier, 1); reasons.push('small sleep-time light leak measured'); }
}
- // 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;
+ // Screens-in-this-room contribution: reported evening screen use rolls
+ // into the room's screening tier. Screens skipped today don't count.
+ let screenTier = 0;
+ let screenHours = 0;
for (const s of screensHere) {
- if (!s.blueBlockerEnabled && (s.eveningUseAfterSunset || 0) > 0) {
- unblockedEveHours += s.eveningUseAfterSunset;
- }
+ const status = computeScreenStatus(s);
+ screenTier = Math.max(screenTier, status.tier || 0);
+ screenHours += Math.max(0, Number(s.eveningUseAfterSunset) || 0);
}
- 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`);
+ if (screenTier > 0) {
+ tier = Math.max(tier, screenTier);
+ reasons.push(`${screenHours.toFixed(1)} hr evening screen use here${screensHere.some(s => s.blueBlockerEnabled) ? '; blue reduction noted but not treated as zero exposure' : ''}`);
}
const colorMap = ['green', 'yellow', 'orange', 'red', 'red'];
- const labelMap = ['Sleep-friendly', 'Mild', 'Moderate', 'Concerning', 'Severe'];
+ const labelMap = ['No concern flagged', 'Worth checking', 'Needs attention', 'High signal', 'Strong signal'];
return {
tier,
color: colorMap[Math.min(tier, 4)],
@@ -205,49 +232,92 @@ export function computeRoomSeverityForRoom(room, measurements = [], options = {}
};
}
-// 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;
+ if (!screen) return { tier: 0, color: 'incomplete', label: 'Unknown', reason: 'no data' };
+ if (screen.eveningUseAfterSunset == null) {
+ return { tier: 0, color: 'incomplete', label: 'Needs timing', reason: 'set time used after sunset' };
+ }
+ const eveHours = Math.max(0, Number(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` };
+ if (eveHours <= 0) return { tier: 0, color: 'green', label: 'Daytime only', reason: 'no use after sunset recorded' };
+ let tier = eveHours < 1 ? 1 : eveHours < 3 ? 2 : 3;
+ if (blocker) tier = Math.max(1, tier - 1);
+ const colors = ['green', 'yellow', 'orange', 'red'];
+ const labels = ['Daytime only', 'Low', 'Moderate', 'High'];
+ return {
+ tier,
+ color: colors[tier],
+ label: labels[tier],
+ reason: `${eveHours} evening hour${eveHours === 1 ? '' : 's'}${blocker ? '; blue reduction may help, but brightness and duration still matter' : ''}`,
+ };
}
-// 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)
+// Two bounded screening scores, not doses or literal hours.
+// d2: possible daytime-light opportunity gap, based on stated daylight or
+// a trustworthy daytime lux spot-check.
+// d3: after-sunset exposure screen, based on reported hours and broad source.
export function computeDeficitAxesForEnvironment(env, options = {}) {
- if (!env) return { d2: 0, d3: 0 };
+ if (!env) return { d2: 0, d3: 0, daylightKnown: 0, eveningKnown: 0, missingDaylightRooms: 0 };
const isActiveToday = options.isActiveToday || (() => true);
+ const getMeasurementsForRoom = options.getMeasurementsForRoom || (() => []);
let d2 = 0, d3 = 0;
+ let daylightKnown = 0, eveningKnown = 0, missingDaylightRooms = 0;
for (const r of env.rooms || []) {
if (!r || !isActiveToday(r)) continue;
- const hours = r.hoursOccupiedPerDay || 0;
+ const hours = Math.min(24, Math.max(0, Number(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;
+
+ const measurements = getMeasurementsForRoom(r.id) || [];
+ const lux = _latestMeasurement(measurements, 'lux');
+ let daytimeFactor = null;
+ if (isQuantitativeLuxMeasurement(lux) && _isLikelyDaytimeMeasurement(lux)) {
+ const value = Number(lux.value);
+ daytimeFactor = value < 50 ? 1 : value < 200 ? 0.7 : value < 500 ? 0.3 : 0;
+ } else if (r.primarySource === 'natural-only' || r.daylightLevel === 'strong') {
+ daytimeFactor = 0;
+ } else if (r.daylightLevel === 'some') {
+ daytimeFactor = 0.4;
+ } else if (r.daylightLevel === 'low') {
+ daytimeFactor = 0.8;
+ }
+ if (daytimeFactor == null) missingDaylightRooms++;
+ else {
+ daylightKnown++;
+ d2 += Math.min(4, hours / 2) * daytimeFactor;
}
- if (roomUsesEveningAfterSunset(r) && ['led-cool', 'led-tunable', 'fluorescent'].includes(r.primarySource)) {
- d3 += 1; // bonus penalty for blue-after-sunset
+
+ if (hasRoomEveningAnswer(r)) {
+ eveningKnown++;
+ const evening = Math.min(6, getRoomEveningHoursAfterSunset(r));
+ const sourceWeight = {
+ 'natural-only': 0,
+ candle: 0.1,
+ incandescent: 0.25,
+ halogen: 0.3,
+ 'led-warm': 0.45,
+ mixed: 0.65,
+ 'led-tunable': 0.75,
+ 'led-cool': 1,
+ fluorescent: 1,
+ unknown: 0.6,
+ }[r.primarySource] ?? 0.6;
+ d3 += evening * sourceWeight;
}
}
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;
+ if (s.eveningUseAfterSunset == null) continue;
+ eveningKnown++;
+ const eveningHours = Math.min(6, Math.max(0, Number(s.eveningUseAfterSunset) || 0));
+ d3 += eveningHours * (s.blueBlockerEnabled ? 0.6 : 1);
}
- return { d2, d3 };
+ return {
+ d2: Math.min(10, d2),
+ d3: Math.min(10, d3),
+ daylightKnown,
+ eveningKnown,
+ missingDaylightRooms,
+ };
}
// Aggregate the deficit numbers into a plain-English burden tier.
@@ -262,38 +332,49 @@ export function computeDeficitAxesForEnvironment(env, options = {}) {
// 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 axes = options.axes || computeDeficitAxesForEnvironment(env, {
+ isActiveToday,
+ getMeasurementsForRoom: options.getMeasurementsForRoom,
+ });
+ const { d2, d3 } = axes;
+ let tier = 0;
+ if (d2 > 5 || d3 > 5) tier = 2;
+ else if (d2 > 2 || d3 > 2) tier = 1;
+ const totalItems = (env?.rooms?.length || 0) + (env?.screens?.length || 0);
+ const activeItems = [...(env?.rooms || []), ...(env?.screens || [])].filter(item => item && isActiveToday(item)).length;
+ const allSkipped = totalItems > 0 && activeItems === 0;
+ const knownSignals = (axes.daylightKnown || 0) + (axes.eveningKnown || 0);
+ const incomplete = !allSkipped && totalItems > 0 && knownSignals === 0;
+ const parts = [];
+ if (axes.daylightKnown > 0) parts.push(`Daytime signal: ${d2 > 5 ? 'low' : d2 > 2 ? 'mixed' : 'supported'}`);
+ if (axes.eveningKnown > 0) parts.push(`Evening light: ${d3 > 5 ? 'high' : d3 > 2 ? 'moderate' : 'lower'}`);
+ if (axes.missingDaylightRooms > 0) parts.push(`${axes.missingDaylightRooms} daylight answer${axes.missingDaylightRooms === 1 ? '' : 's'} missing`);
+ const labelMap = ['Generally aligned', 'Mixed signals', 'Needs attention'];
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.';
+ : allSkipped
+ ? 'Everything mapped is skipped today, so no current indoor-light screen is calculated.'
+ : incomplete
+ ? 'The rooms are mapped, but daylight and evening timing are still missing. Add those two signals before reading this as a verdict.'
+ : 'No concern is flagged by the information entered. A room reading can make the picture more useful.';
}
- 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.';
+ else if (tier === 0) interp = 'The mapped pattern looks broadly day-and-evening aligned. This is a screening result, not a measured light dose.';
+ else if (tier === 1 && d3 > d2) interp = 'Evening timing is the clearest opportunity. Lower brightness and warmer, less eye-direct light matter alongside any screen tint.';
+ else if (tier === 1) interp = 'The daytime signal may be weak in one or more frequently used rooms. Confirm it with an eye-level reading or stronger daylight access.';
+ else if (d3 >= d2) interp = 'The strongest signal is repeated after-sunset light exposure. Start with the brightest, closest source used near bedtime.';
+ else interp = 'The strongest signal is a possible daytime-light gap. Confirm it before treating the screening score as a dose.';
return {
tier,
- color: colorMap[tier],
- label: labelMap[tier],
+ color: incomplete || allSkipped ? 'incomplete' : colorMap[tier],
+ label: allSkipped ? 'Skipped today' : incomplete ? 'Needs details' : labelMap[tier],
parts,
interp,
d2, d3,
+ daylightKnown: axes.daylightKnown || 0,
+ eveningKnown: axes.eveningKnown || 0,
+ missingDaylightRooms: axes.missingDaylightRooms || 0,
};
}
diff --git a/js/light-env-screen-ui.js b/js/light-env-screen-ui.js
index 5ebde238..819d450a 100644
--- a/js/light-env-screen-ui.js
+++ b/js/light-env-screen-ui.js
@@ -17,7 +17,7 @@ function screenSummary(s) {
const eve = s.eveningUseAfterSunset;
if (eve != null && eve > 0) parts.push(`${eve} hr evening`);
else if (hours > 0) parts.push('daytime only');
- if (s.blueBlockerEnabled) parts.push('✓ blocker');
+ if (s.blueBlockerEnabled) parts.push('blue reduced');
return parts.join(' · ');
}
@@ -123,8 +123,8 @@ function renderScreenExpandedBody(s, rooms, opts = {}) {
${eveChips}
- Blue blocker active
- Glasses, f.lux, Night Shift, amber tint — zeroes the circadian penalty.
+ Blue reduction active
+ Night Shift, f.lux, amber tint, or glasses may reduce short-wavelength light. Brightness, distance, and duration still matter.
+ Think about the hours you are usually here, not the best moment of the day.
+
`;
+}
+
function renderEveningPicker(r) {
const active = activeEveningBucket(r);
const chips = EVENING_BUCKETS.map(b => {
@@ -163,19 +174,25 @@ function renderEveningPicker(r) {
// 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 = []) {
+export function computeRoomSeverity(room, measurements = [], options = {}) {
return computeRoomSeverityForRoom(room, measurements, {
- screens: room?.id ? getScreensForRoom(room.id) : [],
- isActiveToday,
+ screens: options.screens || (room?.id ? getScreensForRoom(room.id) : []),
+ isActiveToday: options.isActiveToday || isActiveToday,
});
}
export function computeDeficitAxes() {
- return computeDeficitAxesForEnvironment(getEnvironment(), { isActiveToday });
+ return computeDeficitAxesForEnvironment(getEnvironment(), {
+ isActiveToday,
+ getMeasurementsForRoom: getMeasurementsFor,
+ });
}
export function computeIndoorBurden() {
- return computeIndoorBurdenForEnvironment(getEnvironment(), { isActiveToday });
+ return computeIndoorBurdenForEnvironment(getEnvironment(), {
+ isActiveToday,
+ getMeasurementsForRoom: getMeasurementsFor,
+ });
}
// ─── UI: Light Environment page (lives at /light-environment route) ───
@@ -197,12 +214,23 @@ function getMeasurementsFor(roomId) {
}
function fmtMeasureValue(m) {
- if (m.tool === 'lux') return Math.round(m.value).toLocaleString() + ' lux';
- if (m.tool === 'flicker') return ['pristine', 'mild', 'moderate', 'severe'][Math.min(m.value || 0, 3)] + ' flicker';
- if (m.tool === 'cct') return Math.round(m.value).toLocaleString() + ' K';
- if (m.tool === 'darkness') return (m.value < 1 ? m.value.toFixed(2) : Math.round(m.value)) + ' lux (sleep)';
+ if (m.tool === 'lux') {
+ const value = Math.round(m.value).toLocaleString();
+ if (m.extra?.source === 'camera-estimate') return `~${value} lux (camera estimate)`;
+ if (['AmbientLightSensor', 'manual-entry', 'meter-entry'].includes(m.extra?.source)) return `${value} lux`;
+ return `${value} lux (method unknown)`;
+ }
+ if (m.tool === 'flicker') return ['no banding detected', 'some banding', 'clear banding', 'strong banding'][Math.min(m.value || 0, 3)];
+ if (m.tool === 'cct') return `~${Math.round(m.value / 100) * 100} K (camera)`;
+ if (m.tool === 'darkness') {
+ if (m.extra?.method === 'camera-relative') return `${m.extra?.levelLabel || 'Qualitative'} camera check`;
+ const value = m.value < 1 ? Number(m.value).toFixed(2) : Math.round(m.value);
+ if (m.extra?.method === 'meter-entry' || m.extra?.source === 'meter-entry') return `${value} lux (meter)`;
+ return `${value} legacy value (method unknown)`;
+ }
+ if (m.tool === 'brightness-proxy') return `${m.extra?.levelLabel || 'Relative brightness'} (camera comparison)`;
if (m.tool === 'spectrum') return String(m.value);
- if (m.tool === 'glass-transmission') return Math.round((m.value || 0) * 100) + '% transmits';
+ if (m.tool === 'glass-transmission') return `~${Math.round((m.value || 0) * 100)}% camera-visible comparison`;
if (m.tool === 'audit') {
const n = Number.isFinite(m.value) ? m.value : (m?.extra?.rooms?.length || 0);
return `${n} room snapshot${n === 1 ? '' : 's'}`;
@@ -221,15 +249,10 @@ function fmtMeasureTime(ts) {
const TOOL_ICONS = {
lux: '📏', flicker: '⚡', cct: '🎨', darkness: '🌙', spectrum: '🔬', 'glass-transmission': '🪟',
- audit: '👁',
+ audit: '👁', 'brightness-proxy': '◐',
};
-// Per-day "in use today / skipped today" toggle. Auto-resets at
-// midnight via the date stamp on the override (todayKey() check).
-// Header-mode is icon-only (a check or slash) with a tooltip — most
-// users never touch this so the verbose pill of the old layout was
-// burning header real estate. `compact: true` collapses to icon;
-// callers in body footers can pass false for the full text label.
+// Per-day use toggle; the stored date makes it reset at midnight.
function _renderTodayToggle(kind, id, activeToday, opts = {}) {
const compact = opts.compact !== false;
const cls = `light-env-today-toggle${activeToday ? ' light-env-today-on' : ' light-env-today-off'}${compact ? ' light-env-today-compact' : ''}`;
@@ -243,9 +266,7 @@ function _renderTodayToggle(kind, id, activeToday, opts = {}) {
return `${inner}`;
}
-// Screen card — disclosure pattern matching rooms + audits + EMF.
-// Collapsed header shows: status dot + device-icon + device-label +
-// one-line summary (hours, evening, blocker) + today-toggle + chevron.
+// Screen disclosure card.
function renderLightEnvScreenCard(s, rooms) {
return renderScreenCard(s, {
expanded: isLightEnvScreenExpanded(s.id),
@@ -309,30 +330,17 @@ const PRIMARY_SOURCE_SHORT = {
};
function renderEnvironmentLoadSummary() {
- const env = getEnvironment();
- const hasMappedExposure = ((env?.rooms || []).length + (env?.screens || []).length) > 0;
const burden = computeIndoorBurden();
const interpHTML = (typeof lightEnvDeps.renderBurdenInterp === 'function')
? lightEnvDeps.renderBurdenInterp(burden)
: `
${escapeHTML(burden.interp)}
`;
- // Reconcile the banner label with the AI verdict's dot when one exists.
- // The deterministic computeIndoorBurden() tier crosses to "Heavy" at
- // d2 > 8 hr — but the AI looks at the broader picture (sleep-room
- // contamination, evening blue, room-by-room context) and may legitimately
- // call it "moderate". Showing "HEAVY LOAD" as a header above an AI body
- // that says "moderate" was contradictory copy. When the AI verdict is
- // present + ok, drive the banner label/color from its dot so header +
- // body agree. Gray / missing AI → fall through to the deterministic
- // tier (this preserves behaviour for users without an AI provider).
- // If rooms/screens have been deleted, ignore stale burdenAI entirely.
- const aiVerdict = env?.burdenAI || null;
- const aiOk = hasMappedExposure && aiVerdict?.status === 'ok' && ['green','yellow','red'].includes(aiVerdict?.dot);
- const bannerColor = aiOk ? aiVerdict.dot : burden.color;
- const bannerLabel = aiOk
- ? ({ green: 'Light load', yellow: 'Moderate load', red: 'Heavy load' }[aiVerdict.dot])
- : burden.label;
+ // Keep the deterministic screening header tied to the current inputs.
+ // Cached AI copy may be stale between an edit and the next analysis; it
+ // must not recolor the live assessment during that period.
+ const bannerColor = burden.color;
+ const bannerLabel = burden.label;
return `
@@ -440,7 +448,7 @@ function renderLightEnvironmentAssessmentModal() {
Indoor Light Assessment
-
Map the rooms, screens, and readings that shape your indoor day. Save audit snapshots before and after changes to compare what moved.
+
Map daylight, artificial light, screens, and optional room checks. This is a practical screening picture—not a measured biological dose. Save snapshots before and after changes to compare what moved.
`;
if (latestByTool.size === 0) {
@@ -619,7 +628,7 @@ function renderRoomExpandedBody(r, measurements, sev) {
let stepHead, emptyCopy, quickPicks;
if (/bedroom|sleep/.test(roomName)) {
stepHead = 'Screens used in bed';
- emptyCopy = 'Phone in bed is the single biggest pull on melatonin most users have. Add it here so the AI weights evening blue accurately.';
+ emptyCopy = 'If a phone or tablet is used near bedtime, add it here. Timing is useful context; brightness and viewing distance are not measured.';
quickPicks = ['phone', 'tablet', 'tv'];
} else if (/office|study|desk|work/.test(roomName)) {
stepHead = 'Screens at this desk';
@@ -627,7 +636,7 @@ function renderRoomExpandedBody(r, measurements, sev) {
quickPicks = ['laptop', 'monitor', 'phone'];
} else if (/living|family|den|lounge/.test(roomName)) {
stepHead = 'Screens in this room';
- emptyCopy = 'TV after sunset shifts melatonin most when it\'s a wall of cool blue. Worth mapping.';
+ emptyCopy = 'Add a TV or other screen used after sunset so the assessment can include its timing.';
quickPicks = ['tv', 'phone', 'tablet'];
} else {
stepHead = 'Screens used here';
@@ -669,7 +678,7 @@ export function renderEnvironmentSection(options = {}) {
if (!embedded) {
html += `
Light environment
-
Indoor light is the dominant exposure most days. Map your spaces and screens — the rest of the app uses this to weight your channel pills + interpret your sleep data.
+
Map the light that reaches you indoors: daylight access, evening sources, screens, and optional room checks. The rest of Light uses this as context, not as a measured dose.
`;
}
html += renderEnvironmentLoadSummary();
@@ -686,7 +695,7 @@ export function renderEnvironmentSection(options = {}) {
`;
if (rooms.length === 0) {
html += `
-
Map your bedroom first. Sleep-room contamination is the highest-leverage signal in the modern light-environment literature (Brown TM 2022) — even ~1 lux of melanopic-EDI light at night measurably suppresses melatonin. We grade it for melatonin-friendly darkness, flicker, cool-LED contamination, and evening-blue exposure — and feed that grade into your circadian channel.
+
Map your bedroom first. Record the light used after sunset, screens near bed, and any visible light during sleep. Ordinary lux and phone-camera readings are kept separate from melanopic EDI, so the assessment can guide a better setup without pretending to measure a biological dose.
${renderRoomQuickPicks(rooms)}
`;
} else {
diff --git a/js/light-page-view-hooks.js b/js/light-page-view-hooks.js
index a74317dd..9cc99f5f 100644
--- a/js/light-page-view-hooks.js
+++ b/js/light-page-view-hooks.js
@@ -28,7 +28,7 @@ import {
renderActiveDeviceSessionCard,
renderDevicesSection,
} from './light-devices.js';
-import { getDeviceSessions, getDevices, rollingDeviceTotals } from './light-devices-store.js';
+import { getActiveDeviceSession, getDeviceSessions, getDevices, rollingDeviceTotals } from './light-devices-store.js';
import { openLightEnvironmentAssessment, renderEnvironmentAssessmentSummary } from './light-env.js';
import { renderLightTools } from './light-tools.js';
import { renderChannelMixVerdict } from './light-channels-ai-analysis.js';
@@ -42,6 +42,7 @@ configureLightPageView({
cumulativeMEDToday,
cumulativeMEDYesterday,
ensureActiveDeviceTicker,
+ getActiveDeviceSession,
getActiveSession,
getDeviceSessions,
getDevices,
diff --git a/js/light-page-view.js b/js/light-page-view.js
index 97671687..c2a0d724 100644
--- a/js/light-page-view.js
+++ b/js/light-page-view.js
@@ -7,7 +7,6 @@ import { renderLensHeader, renderLensPageWidgets } from './lens-page-shell.js';
import { renderLightConditionsWidgetBody, renderConditionsNow, _formatElapsedShort } from './light-conditions-now.js';
import { renderUnifiedSessionsList } from './light-sessions-view.js';
import {
- mergeTotals,
_channelSparkline,
_channelDayCount,
renderChannelPills,
@@ -23,6 +22,7 @@ const lightPageDeps = {
getSessions: () => [],
getDevices: () => [],
getDeviceSessions: () => [],
+ getActiveDeviceSession: () => null,
getActiveSession: () => null,
rollingChannelTotals: () => ({}),
rollingDeviceTotals: () => ({}),
@@ -53,6 +53,10 @@ const lightPageDeps = {
renderLightTools: () => '',
};
+// A monotonic suffix prevents an older async device render from targeting a
+// newer Light page that happened to render within the same millisecond.
+let lightWidgetSlotSequence = 0;
+
/** @param {Partial} [deps] */
export function configureLightPageView(deps = {}) {
Object.assign(lightPageDeps, deps);
@@ -110,20 +114,19 @@ if (typeof document !== 'undefined') installLightPageActionDelegates();
export function renderDashboardLightChannelPills() {
const ch = lightPageDeps.channelDisplay || {};
- // Dashboard pills represent a 7-day rolling total; classify with the
- // weekly tier so optional Light widgets agree with the Light page pills.
- const tier = lightPageDeps.weeklyChannelTier || (() => 0);
const order = ['vitamin_d', 'circadian', 'nir_solar', 'no_cv', 'pomc', 'violet_eye'];
- const totals7d = lightPageDeps.rollingChannelTotals(7) || {};
+ const sunTotals7d = lightPageDeps.rollingChannelTotals(7) || {};
const devTotals7d = lightPageDeps.rollingDeviceTotals(7) || {};
- const combinedTotals7d = mergeTotals(totals7d, devTotals7d);
return `
${order.map(k => {
const meta = ch[k] || {};
- const t = tier(combinedTotals7d[k] || 0, k);
+ const hasSun = (sunTotals7d[k] || 0) > 0.0001;
+ const hasDevice = (devTotals7d[k] || 0) > 0.0001;
+ const active = hasSun || hasDevice;
+ const source = hasSun && hasDevice ? 'Sunlight and device logged' : hasSun ? 'Sunlight logged' : hasDevice ? 'Device logged' : 'Not logged';
const dc = _channelDayCount(k);
- const tip = `${meta.what || ''} — ${dc.n} of 7 days hit target this week. Tap for details.`;
- return `
+ const tip = `${meta.what || ''} — ${source}${dc.n ? ` on ${dc.n} day${dc.n === 1 ? '' : 's'} this week` : ''}. Tap for details.`;
+ return `${meta.icon || '·'}${escapeHTML(meta.label || k)}
${_channelSparkline(k)}
@@ -145,15 +148,21 @@ export function renderLightSessionLogActions() {
? `${sunCount} sun + ${devCount} device`
: '';
const sunActive = !!lightPageDeps.getActiveSession();
+ const deviceActive = !!lightPageDeps.getActiveDeviceSession();
let ctaButtons = '';
- if (sunActive) {
- // Stop controls live in the pinned active-session card; this widget keeps
- // the remaining logging actions available without duplicating Stop.
- if (hasDevices) {
- ctaButtons = `Start device session`;
- } else {
- ctaButtons = `Add light device`;
+ if (sunActive || deviceActive) {
+ // Stop controls live in the live-session card. Keep only starts that are
+ // actually available; the device store permits one device timer at once.
+ const availableStarts = [];
+ if (!sunActive) {
+ availableStarts.push('Start sun session');
+ }
+ if (!deviceActive) {
+ availableStarts.push(hasDevices
+ ? 'Start device session'
+ : 'Add light device');
}
+ ctaButtons = availableStarts.join('');
} else if (hasDevices) {
ctaButtons = `Start sun sessionStart device session`;
@@ -170,6 +179,34 @@ export function renderLightSessionLogActions() {
`;
}
+/**
+ * Shared live-session surface for the Light page and its optional dashboard
+ * widget. Keeping this renderer shared matters: both placements carry the
+ * same session id, so the existing sun/device tickers can update every copy
+ * without maintaining a second live-dose calculation path.
+ *
+ * @param {{ includeEmptyState?: boolean }} [options]
+ */
+export function renderLightLiveSession({ includeEmptyState = false } = {}) {
+ const activeSunSession = lightPageDeps.getActiveSession() || null;
+ let html = '';
+ if (activeSunSession) {
+ html += `
`;
+ }
+ const activeDeviceHtml = lightPageDeps.renderActiveDeviceSessionCard();
+ if (activeDeviceHtml) {
+ html += `
${activeDeviceHtml}
`;
+ }
+ if (html || !includeEmptyState) return html;
+ return renderLightWidgetPrompt(
+ 'No light session is running',
+ 'Open Light & Sun',
+ 'navigate-light',
+ 'Start an outdoor or therapy-device session there; this widget will then show its live timer, estimates, and stop controls.',
+ 'light-live-session-empty',
+ );
+}
+
function renderLightWidgetPrompt(status, ctaLabel, ctaAction, hint, extraClass = '') {
return `
@@ -181,15 +218,22 @@ function renderLightWidgetPrompt(status, ctaLabel, ctaAction, hint, extraClass =
}
function renderLightMethodsWidgetBody() {
+ const configuredFitzpatrick = state.importedData?.sunDefaults?.fitzpatrick || null;
+ const skinBasis = configuredFitzpatrick
+ ? `The current model uses Fitzpatrick ${escapeHTML(configuredFitzpatrick)} as a rough base-MED reference, not a personal safe exposure time.`
+ : 'Personalized burn-time guidance stays hidden until a skin type is configured.';
let html = `
- How we estimate vitamin D, burn risk & channels
+ How these estimates work
-
Burn dose (% MED). 1 MED = "minimal erythemal dose," the smallest UV dose that turns your skin slightly pink. Set per Fitzpatrick skin type (Type I = 200 J/m² CIE-erythemal, Type VI = 1000 J/m²). 100% means a sunburn is starting; 70% means stop or cover up soon. Yesterday's dose carries forward — when yesterday + today exceeds 100% the banner flags a back-to-back risk, even if today alone is under threshold.
-
Vitamin D in IU. Bogh & Wulf 2010 + Holick 2007. Roughly 60 IU per unit of vit-D-action-spectrum-weighted UVB at sea-level zenith (calibrated against dminder + NIWA at UVI 5-7), scaled by your Fitzpatrick type (melanin lowers it). Saturates at the tens-of-thousands-of-IU level per session — at high doses the skin photoisomerizes excess previtamin D back to inert tachysterol/lumisterol. Below UVI 2 there's no meaningful synthesis (Webb 2018, ramps in linearly between UVI 2 and 3) — winter mornings, low sun, behind glass all yield zero.
-
The ±50% range. Estimate is "central x 0.6 to x 1.5" because the spectral reconstruction model, skin response, and exposed area all vary. Treat the band as honest — the central number alone is false precision.
-
Channels. Sun does six things you can see on this page, each with its own action spectrum: vitamin D synthesis, circadian/melanopic light, cardiovascular nitric-oxide release, mood/alpha-MSH on skin, violet-eye dopamine, and near-infrared cellular repair. Sun and therapy panels both feed these channels by wavelength.
-
Atmosphere data. CAMS by default — real ozone column and aerosols from the hosted getbased-uvdata relay, merged with Open-Meteo clouds, temperature, air quality, and hourly UV baseline. All math runs on-device — your location is rounded before network calls unless you change the privacy slider.
-
Want the math? See the contributor doc for the Bird-Riordan reconstruction, action-spectrum table, and per-channel citations.
+
Burn risk. UV dose is estimated for the skin that is exposed. Exposing more skin changes the whole-body estimate, but does not make the dose safer for each patch. Sunscreen is not counted as extra safe time. ${skinBasis} Medicines, altitude, reflection, irritated skin, uneven sunscreen, and personal sensitivity can all change the real limit. Stop before redness and never stay longer just to raise an estimate.
+
Vitamin D. We estimate how much vitamin-D-making UVB reaches uncovered skin, then account for exposed area and skin type. The result is an IU-equivalent estimate, not a measurement of absorption or a prediction of blood vitamin D.
+
Other light signals. The cards show when parts of sunlight or device light may reach light-sensitive pathways in the eyes and skin. They describe possible stimulation, not a measured body response, daily requirement, or completion score. Sunlight and devices stay separate because a targeted device is not the same as full-spectrum daylight.
+
Uncertainty. Weather, shade, glass, clothing, skin, distance, and device specifications can change the estimate. Treat ranges as context, not a prescription.
+
UV-A transition. “On” marks when modeled UV-A becomes meaningfully available as the sun rises; “off” marks when it fades near sunset. It is a useful transition window, not an instant whole-body switch. Small amounts may still be present outside it. Never look directly at the sun.
Photobiology lens. Channel explanations draw on published photobiology, circadian biology, and light-response research. Safety limits and numerical doses use the cited primary or institutional sources, while exploratory mechanisms remain labeled as modeled or under study.
+
Weather data.CAMS is the default atmosphere source for ozone, aerosols, and UV context. Open-Meteo adds local clouds, weather, air quality, and an hourly UV check, and is the fallback when CAMS is unavailable. Calculations run on your device, and location is rounded before network calls unless you change the privacy setting.
- ${renderLensHeader('Light & Sun', 'Track your light exposure. See how it shapes your sleep, hormones, and lab results.')}`;
+ ${renderLensHeader('Light & Sun', 'See how sunlight and indoor light reach the light-sensitive systems in your eyes, skin, and body.')}`;
// AI hero verdict — synthesizes today's full picture (sun + devices +
// environment + trends) into one read. Sits above active-session and
// conditions so the user gets the "how am I doing?" answer before the
// raw inputs.
try {
- const todayBody = lightPageDeps.renderLightTodayHero() || '';
+ const uvSafetyBody = renderTodayUVSafety(sessions, medToday, medYesterday);
+ const aiTodayBody = lightPageDeps.renderLightTodayHero() || '';
+ const todayBody = `${uvSafetyBody}${aiTodayBody}`;
if (todayBody) {
widgets.push({
id: 'light-today',
title: 'Today',
- description: 'Current light synthesis across sun, devices, and environment',
+ description: 'What stands out today, including modeled UV safety and one useful next step',
body: todayBody,
size: 'full',
opts: { source: 'Light', dashboardId: 'light-today' },
@@ -450,18 +532,7 @@ export function showLight(_data) {
// first thing the user sees when a session is running. Renders above
// Conditions / Setup / Stop CTA. Filtered out of the historical
// sessions list further down so the same row doesn't render twice.
- const _activeSunSess = lightPageDeps.getActiveSession() || null;
- let activeSessionBody = '';
- if (_activeSunSess) {
- activeSessionBody += `
`;
- }
- // Same pattern for active device-therapy sessions (PBM panels, SAD
- // lamps, dawn simulators). Pinned above the conditions panel so the
- // stop button is always one tap away.
- const _activeDevHtml = lightPageDeps.renderActiveDeviceSessionCard();
- if (_activeDevHtml) {
- activeSessionBody += `
${_activeDevHtml}
`;
- }
+ const activeSessionBody = renderLightLiveSession();
if (activeSessionBody) {
widgets.push({
id: 'light-live-session',
@@ -469,7 +540,7 @@ export function showLight(_data) {
description: 'Running sun or therapy sessions with stop controls',
body: activeSessionBody,
size: 'full',
- opts: { source: 'Light', dashboardId: '' },
+ opts: { source: 'Light', dashboardId: 'light-live-session' },
});
}
@@ -481,126 +552,75 @@ export function showLight(_data) {
// compact "Light setup saved" summary with an Edit button otherwise.
let setupHtml = '';
try { setupHtml = lightPageDeps.renderSunSetupCard() || ''; } catch (_) {}
- const conditionsBody = renderLightConditionsWidgetBody({ variant: 'full' });
+ const conditionsCoords = lightPageDeps.getSunCoords();
+ const conditionsLocationHint = conditionsCoords?.source === 'country-band' ? getSunCoordsHint() : '';
+ const conditionsBody = `${conditionsLocationHint}${renderLightConditionsWidgetBody({ variant: 'full' })}`;
widgets.push({
id: 'light-conditions-now',
title: 'Conditions Now',
description: 'Current outdoor UVI, atmosphere, air quality, and sun timing',
body: conditionsBody,
- size: 'two-third',
+ size: 'full',
opts: { source: 'Light', dashboardId: 'light-conditions-now' },
});
+ widgets.push({
+ id: 'light-setup',
+ title: 'Light Setup',
+ description: 'Skin type, indoor light context, and personal light assumptions',
+ body: setupHtml,
+ size: 'half',
+ opts: { source: 'Light', dashboardId: '' },
+ });
const logBody = renderLightSessionLogActions();
widgets.push({
id: 'light-session-log',
title: 'Log Sessions',
description: 'Start sun or therapy sessions and backfill past exposure',
body: logBody,
- size: 'third',
+ size: 'half',
opts: { source: 'Light', dashboardId: 'light-session-log' },
});
- widgets.push({
- id: 'light-setup',
- title: 'Light Setup',
- description: 'Skin type, indoor light context, and personal light assumptions',
- body: setupHtml,
- size: 'full',
- opts: { source: 'Light', dashboardId: '' },
- });
-
- // Combine sun + device totals so channels reflect every light source
+ // Keep sunlight and device signals separate. Both may reach a channel,
+ // but a targeted device is not a fraction of full-spectrum sunlight.
const devTotals7d = lightPageDeps.rollingDeviceTotals(7) || {};
- const devTotals30d = lightPageDeps.rollingDeviceTotals(30) || {};
- const combined7d = mergeTotals(totals7d, devTotals7d);
- const combined30d = mergeTotals(totals30d, devTotals30d);
-
- // Unified channel pill row — same vocabulary as the dashboard strip.
- // Empty state shows all ○○○○; populated state lights up dots as data
- // accumulates. Tapping a pill expands a drill-down panel with the full
- // science copy + tier comparison + suggestion. Empty defined as "no
- // light data of any kind" — devices count too.
+
+ // Unified channel cards. Tapping one opens its source, rhythm, meaning,
+ // safety context, and optional research without presenting a quota.
const isEmpty = totalSessions === 0;
- // Lead copy adapts to the actual state of the data, not just session
- // count. Three regimes:
- // • No sessions ever → explain the model
- // • Sessions exist but every channel is at tier 0 (low-dose / sub-
- // threshold) → don't oversell "30-day comparison"; describe what's
- // actually there
- // • At least one channel has a meaningful tier → invite drill-down
- // with realistic copy
- const channelKeysOrdered = ['vitamin_d', 'circadian', 'nir_solar', 'no_cv', 'pomc', 'violet_eye'];
- const _wkTier = lightPageDeps.weeklyChannelTier || lightPageDeps.channelTier || (() => 0);
- const litChannels = channelKeysOrdered.filter(k => _wkTier(combined7d[k] || 0, k) > 0).length;
- let lead;
- if (isEmpty) {
- lead = "Sun isn't just vitamin D. Each pill is a different biological effect of light — they fill as you log sessions outdoors or with a therapy device. Tap any pill to see how to fill it.";
- } else if (litChannels === 0) {
- lead = `${totalSessions} session${totalSessions === 1 ? '' : 's'} logged but no channel has crossed the meaningful-dose threshold yet (sub-tier exposure). Tap any pill for what it tracks and a concrete next step.`;
- } else {
- lead = `${litChannels} of 6 channels lit by your recent sessions. Tap any pill for what you've logged, the 7-day rhythm, and what would tip it up.`;
- }
+ const lead = isEmpty
+ ? 'Sunlight does more than make vitamin D. Start a session to see which light-responsive systems the exposure may have reached.'
+ : 'Each card shows a light-responsive pathway seen in your recent logs. It is an exposure story, not a daily score or quota.';
const channelsBody = `
`;
widgets.push({
id: 'light-channels',
- title: 'Your Light, By What It Does',
- description: 'Channel doses from outdoor sun and therapy devices',
+ title: 'What Your Light May Stimulate',
+ description: 'Simple eye, skin, and body signals from sunlight and devices',
body: channelsBody,
size: 'full',
opts: { source: 'Light', dashboardId: 'light-channels' },
});
- if (!isEmpty) {
- let guidanceBody = '';
- // Today's burn-risk card — sun-specific, gated on having sun sessions.
- // A winter user with only device sessions doesn't need a "Sun exposure
- // today: safe (0%)" panel taking up space. Surfaces once outdoor sun
- // is part of the routine.
- if (sunCount > 0) {
- const medPct = Math.round(medToday * 100);
- const medY = lightPageDeps.cumulativeMEDYesterday() || 0;
- const combinedMED = medToday + medY;
- let medCls = 'ok', medTitle = 'Sun exposure today: safe', medMsg = 'You\'re well under your burn threshold.';
- if (medToday >= 1) { medCls = 'over'; medTitle = 'Burn threshold reached'; medMsg = 'You\'ve crossed your burn threshold for the day. Avoid more direct sun until tomorrow.'; }
- else if (medToday >= 0.7) { medCls = 'warn'; medTitle = 'Approaching burn threshold'; medMsg = 'You\'re getting close to your daily limit. Move to shade or cover up if you go back out.'; }
- else if (medToday >= 0.3) { medCls = 'ok'; medTitle = 'Moderate sun exposure today'; medMsg = 'A meaningful dose — well under your skin\'s threshold.'; }
- // Carry-over chip — fires when today + yesterday combined exceeds
- // 100%, even if today alone is under threshold. Skin doesn't reset
- // overnight; back-to-back high-dose days are how vacation burns happen.
- const carryChip = (combinedMED > 1.0 && medToday < 1.0)
- ? `
⚠ Cumulative dose with yesterday: ${Math.round(combinedMED * 100)}% — go easy today.
${medTitle}${medPct > 0 ? ` (${medPct}% of your burn threshold)` : ''}
-
${medMsg}
- ${carryChip}
-
-
`;
- }
-
- // Suggestion (channel-agnostic, reads merged totals).
- // Wrapped by the channel-mix AI verdict — when AI is available the
- // AI verdict replaces the hardcoded per-channel string with a
- // multi-channel synthesis. Static suggestion stays as the fallback
- // so users without AI still see something useful, and as the
- // baseline content under the "Get AI synthesis" CTA before the
- // user has clicked it.
- const _staticSuggestion = renderSuggestion(combined7d);
- guidanceBody += lightPageDeps.renderChannelMixVerdict(_staticSuggestion) || _staticSuggestion;
+ // This is a trend review, not today's safety surface. Keep the internal
+ // widget id for saved page-order compatibility while giving the visible
+ // module a precise job. It remains useful when no sessions were logged:
+ // that state is labeled as missing data rather than missing exposure.
+ const staticWeeklyReview = renderSuggestion(totals7d, devTotals7d, sessions, deviceSessionsAll);
+ const weeklyReview = lightPageDeps.renderChannelMixVerdict(staticWeeklyReview) || staticWeeklyReview;
+ const weeklyBody = `${weeklyReview}
Wellness interpretation, not medical advice. This review uses logged sessions and may miss unrecorded exposure; it does not measure vitamin-D status or a personal safe UV limit.
`;
+ widgets.push({
+ id: 'light-guidance',
+ title: 'Weekly Light Review',
+ description: 'What your past 7 days show, what changed, and one conservative next step',
+ body: weeklyBody,
+ size: 'full',
+ opts: { source: 'Light', dashboardId: '' },
+ });
- widgets.push({
- id: 'light-guidance',
- title: 'Guidance',
- description: 'Burn risk and a high-leverage next step from your channel mix',
- body: guidanceBody,
- size: 'full',
- opts: { source: 'Light', dashboardId: '' },
- });
+ if (!isEmpty) {
// Unified sessions list — sun + device merged chronologically.
// Active sun session is pinned at top of page; this list shows
@@ -626,9 +646,10 @@ export function showLight(_data) {
// Page-only Light workbench surfaces stay separate widgets so each one can
// be reordered, scanned, and visually handled like the rest of the redesign.
- const devicesSlotId = `light-devices-slot-${Date.now()}`;
- const environmentSlotId = `light-environment-slot-${Date.now()}`;
- const toolsSlotId = `light-tools-slot-${Date.now()}`;
+ const slotSuffix = `${Date.now()}-${++lightWidgetSlotSequence}`;
+ const devicesSlotId = `light-devices-slot-${slotSuffix}`;
+ const environmentSlotId = `light-environment-slot-${slotSuffix}`;
+ const toolsSlotId = `light-tools-slot-${slotSuffix}`;
widgets.push({
id: 'light-devices',
title: 'Light Devices',
@@ -667,25 +688,42 @@ export function showLight(_data) {
main.innerHTML = html;
main.querySelector('.light-page')?.classList.add('is-ready');
- Promise.resolve(lightPageDeps.renderDevicesSection()).then((devHtml) => {
+ let devicesRender;
+ try {
+ devicesRender = lightPageDeps.renderDevicesSection();
+ } catch (error) {
+ devicesRender = Promise.reject(error);
+ }
+ Promise.resolve(devicesRender).then((devHtml) => {
const slot = document.getElementById(devicesSlotId);
if (!slot) return;
const devices = lightPageDeps.getDevices() || [];
slot.outerHTML = devices.length > 0
? devHtml
- : renderLightWidgetPrompt('No devices added', 'Add device', 'open-add-device', 'Therapy panels, SAD lamps, and dawn simulators feed the same Light channels as outdoor sun.');
- }).catch(() => {});
+ : renderLightWidgetPrompt('No devices added', 'Add device', 'open-add-device', 'Device sessions show targeted light alongside sunlight, without treating the two as interchangeable.');
+ }).catch(() => {
+ const slot = document.getElementById(devicesSlotId);
+ if (slot) slot.outerHTML = renderLightWidgetPrompt('Devices could not load', 'Retry', 'navigate-light', 'Your saved device data was not removed. Reopen Light & Sun to try again.');
+ });
const envSlot = document.getElementById(environmentSlotId);
if (envSlot) {
- const envHtml = lightPageDeps.renderEnvironmentAssessmentSummary() || '';
- envSlot.outerHTML = envHtml
- || renderLightWidgetPrompt('No rooms mapped', 'Open assessment', 'open-light-environment', 'Map bedroom, office, screens, and evening light so Light can interpret your indoor day.', 'light-environment-prompt');
+ try {
+ const envHtml = lightPageDeps.renderEnvironmentAssessmentSummary() || '';
+ envSlot.outerHTML = envHtml
+ || renderLightWidgetPrompt('No rooms mapped', 'Open assessment', 'open-light-environment', 'Map bedroom, office, screens, and evening light so Light can interpret your indoor day.', 'light-environment-prompt');
+ } catch (error) {
+ envSlot.outerHTML = renderLightWidgetPrompt('Assessment could not load', 'Retry', 'navigate-light', 'Your saved rooms and audits were not removed. Reopen Light & Sun to try again.');
+ }
}
const toolsSlot = document.getElementById(toolsSlotId);
if (toolsSlot) {
- const toolsHtml = lightPageDeps.renderLightTools() || '';
- toolsSlot.outerHTML = toolsHtml
- || renderLightWidgetPrompt('No measurements yet', 'Open light tools', 'expand-light-tools', 'Run lux, flicker, color temperature, glass, and sleep-darkness checks on this device. Camera frames stay local.', 'light-tools-section-collapsed');
+ try {
+ const toolsHtml = lightPageDeps.renderLightTools() || '';
+ toolsSlot.outerHTML = toolsHtml
+ || renderLightWidgetPrompt('No measurements yet', 'Open light tools', 'expand-light-tools', 'Run lux, flicker, color temperature, glass, and sleep-darkness checks on this device. Camera frames stay local.', 'light-tools-section-collapsed');
+ } catch (error) {
+ toolsSlot.outerHTML = renderLightWidgetPrompt('Measurement tools could not load', 'Retry', 'navigate-light', 'Saved measurements were not removed. Reopen Light & Sun to try again.');
+ }
}
}
@@ -702,10 +740,10 @@ export function _expandLightToolsSection() {
function getSunCoordsHint() {
const c = lightPageDeps.getSunCoords();
if (!c) {
- return `
Tip: set your country in the profile editor for accurate sun calculations, or share your precise location once.
`;
+ return `
Tip: set your home country in Profile, or use your current location today. Device coordinates are privacy-rounded and temporary.
`;
}
if (c.source === 'country-band') {
- return `
Calculations use your country (~${c.lat}° lat). Use precise location for sharper results.
`;
+ return `
Calculations use your country (~${c.lat}° lat). Add a postal code in Profile, or use current location today for local conditions.
`;
}
return '';
}
diff --git a/js/light-screen-ai-analysis.js b/js/light-screen-ai-analysis.js
index 934e3a28..51d7a2bf 100644
--- a/js/light-screen-ai-analysis.js
+++ b/js/light-screen-ai-analysis.js
@@ -31,6 +31,7 @@ const _DEVICE_LABELS = {
export function getScreenFingerprint(s) {
if (!s) return '';
const parts = [
+ 'v2-blue-reduction-not-zero',
s.device || '',
s.roomId || 'portable',
Math.round((s.hoursPerDay || 0) * 10) / 10,
@@ -57,12 +58,12 @@ export function buildScreenContext(s) {
const ev = Number(s.eveningUseAfterSunset);
lines.push(`Time after sunset: ${ev > 0 ? ev + ' hr' : 'none'}`);
}
- lines.push(`Blue blocker active: ${s.blueBlockerEnabled ? 'yes (glasses / f.lux / Night Shift / amber tint)' : 'no'}`);
+ lines.push(`Blue-reduction measure noted: ${s.blueBlockerEnabled ? 'yes (type and attenuation unknown; do not treat exposure as zero)' : 'no'}`);
- // Bedroom-phone signal — the highest-leverage call-out for most users.
+ // Bedroom-phone signal — important timing context, not a measured dose.
if (s.device === 'phone' && room && /bedroom|sleep/i.test(room.name || '')) {
lines.push('');
- lines.push('NOTE: phone is bound to a sleep room. Phone-in-bed is the single largest junk-light vector for most users.');
+ lines.push('NOTE: phone is bound to a sleep room; ask whether it is used near bedtime and at what brightness. Do not infer dose from location alone.');
}
// User context
@@ -84,22 +85,23 @@ const SYSTEM_PROMPT = [
'Return ONLY valid JSON: {"dot":"green|yellow|red|gray","tip":"string","detail":"string"}.',
'',
'dot:',
- ' green = use pattern is benign (low daily hours, no evening use, OR blue blocker active during evening hours)',
+ ' green = no after-sunset use is recorded',
' yellow = moderate concern (multi-hour evening use without a blue blocker, or screens in sleep-relevant rooms)',
' red = high circadian disruption (phone in bed, multi-hour cool-bright evening use, screens visible from sleeping position)',
' gray = not enough data (no device set, no hours)',
'',
'Biology priors:',
- ' • Phone-in-bed is the single largest junk-light vector for most users — bright, blue-shifted, eye-direct, often used until sleep onset (Cain & Gradisar 2010, LeBourgeois 2017). When a phone is bound to a sleep room, treat as red unless blue-blocker is active AND evening hours are <1.',
- ' • TV in living room evening — cool blue light + bright + multi-hour. Distance helps (vs phone), but spectrum + duration usually dominate.',
- ' • Monitor / laptop work after sunset — same physiology as TV but typically eye-direct + closer + longer-duration. Blue blocker (f.lux / Night Shift / amber glasses) is the cheapest mitigation.',
+ ' • A screen record contains timing, not eye-level light dose. Brightness, viewing distance, content, ambient light, and spectrum are not measured.',
+ ' • Phone use in a sleep room is worth surfacing because it is close and often near bedtime, but do not diagnose disruption from location alone.',
+ ' • TV, monitor, and laptop impact varies substantially with brightness, distance, duration, and surrounding room light.',
+ ' • Night Shift, f.lux, tint, or glasses may reduce short-wavelength exposure. They do not erase it; changing color without lowering brightness may be insufficient.',
' • E-reader (e-ink) — backlight off OR warm-tinted = green; cool backlight on at night ≈ tablet impact.',
' • Tablet — between phone and laptop in impact. Position-dependent (held close like a phone vs propped on a table like a TV).',
'',
...LIGHTING_HARDWARE_CAVEATS,
'',
'tip: one sentence, max 16 words. The single most-leveraged change for THIS screen.',
- 'detail: 2–3 sentences. Cite specific numbers (hours, evening hours, blue-blocker state, room context) and the biology that drives the verdict. Concrete, observational.',
+ 'detail: 2–3 sentences. Cite entered hours and room context, but state that this is a behavior screen rather than a measured dose. Never claim a blue-reduction setting makes evening use harmless.',
'',
'No "you should" — be observational. No emoji.',
].join('\n');
diff --git a/js/light-sessions-view-hooks.js b/js/light-sessions-view-hooks.js
index dd33decd..79b08b87 100644
--- a/js/light-sessions-view-hooks.js
+++ b/js/light-sessions-view-hooks.js
@@ -1,24 +1,19 @@
// @ts-check
// light-sessions-view-hooks.js - wire Light Sessions View callbacks at startup.
-import { CHANNEL_DISPLAY, channelTier, formatChannelUnit, getSessions } from './sun.js';
+import { getSessions } from './sun.js';
import { renderSunSessionRow } from './sun-session-ui.js';
-import { configureLightDevices, deleteDeviceSessionWithConfirm, openDeviceSessionDetail } from './light-devices.js';
+import { configureLightDevices, openDeviceSessionDetail } from './light-devices.js';
import { getDeviceSessions, getDevices } from './light-devices-store.js';
-import { renderDeviceSessionAIDetail, renderDeviceSessionAIInline } from './light-device-ai-analysis.js';
+import { renderDeviceSessionAIDetail } from './light-device-ai-analysis.js';
import { configureLightSessionsView } from './light-sessions-view.js';
configureLightDevices({ renderDeviceSessionAIDetail });
configureLightSessionsView({
- channelDisplay: CHANNEL_DISPLAY,
- channelTier,
- deleteDeviceSession: deleteDeviceSessionWithConfirm,
- formatChannelUnit,
getDeviceSessions,
getDevices,
getSessions,
openDeviceSessionDetail,
- renderDeviceSessionAIInline,
renderSunSessionRow,
});
diff --git a/js/light-sessions-view.js b/js/light-sessions-view.js
index 1018f9f9..aa6cec9e 100644
--- a/js/light-sessions-view.js
+++ b/js/light-sessions-view.js
@@ -15,12 +15,7 @@ const lightSessionsActionDelegateRoots = new WeakSet();
* @property {() => any[]} getDeviceSessions
* @property {() => any[]} getDevices
* @property {(sess: any) => string} renderSunSessionRow
- * @property {(sess: any) => string} renderDeviceSessionAIInline
* @property {(id: string) => void | Promise} openDeviceSessionDetail
- * @property {(id: string) => void | Promise} deleteDeviceSession
- * @property {Record} channelDisplay
- * @property {(value: number, key: string) => number} channelTier
- * @property {(key: string, value: number, durationMin?: number, fitzpatrick?: string, uvi?: any, zenith?: any, rotatedSides?: boolean, bodyFraction?: any) => string} formatChannelUnit
* @property {(type: string, listener: EventListener) => void} addEventListener
* @property {(type: string, listener: EventListener) => void} removeEventListener
*/
@@ -31,12 +26,7 @@ const viewDeps = {
getDeviceSessions: () => [],
getDevices: () => [],
renderSunSessionRow: () => '',
- renderDeviceSessionAIInline: () => '',
openDeviceSessionDetail: () => {},
- deleteDeviceSession: () => {},
- channelDisplay: {},
- channelTier: () => 0,
- formatChannelUnit: () => '',
addEventListener: (type, listener) => {
if (typeof globalThis !== 'undefined' && typeof globalThis.addEventListener === 'function') {
globalThis.addEventListener(type, listener);
@@ -69,11 +59,6 @@ function handleLightSessionsActionClick(event) {
event.stopPropagation();
return;
}
- if (action === 'delete-device-session') {
- event.stopPropagation();
- if (sessionId) viewDeps.deleteDeviceSession(sessionId);
- return;
- }
if (action === 'show-all') {
event.stopPropagation();
_openAllSessionsModal();
@@ -104,8 +89,8 @@ if (typeof document !== 'undefined') installLightSessionsActionDelegates();
// at-a-glance context ("what did I do recently"); the full history
// opens in a modal so the rest of the Light & Sun page (Devices,
// Light Environment, Tools) sits within one scroll-page below.
-// Each row is ~160 px tall (date + duration + channel chips + burn-
-// risk meta + AI verdict chip), so 3 rows ≈ 480 px is a tight default.
+// Rows intentionally stay compact; full setup, signals, safety math and AI
+// interpretation live in the session detail dialog.
export const SESSIONS_DEFAULT_CAP = 3;
// Build the unified, sorted (newest-first) row list of all completed
@@ -126,30 +111,14 @@ function _collectUnifiedSessionRows() {
return { rows, hasDeviceRows: devSessions.length > 0 };
}
-function _renderLightSessionChannelChips(doses, durationMin = 0) {
- if (!doses) return '';
- const ch = viewDeps.channelDisplay || {};
- const tier = viewDeps.channelTier;
- const formatUnit = viewDeps.formatChannelUnit;
- const order = ['vitamin_d', 'pomc', 'no_cv', 'violet_eye', 'circadian', 'nir_solar', 'pbm_red', 'pbm_nir'];
- const ranked = order
- .map(key => ({ key, v: doses[key] || 0, tier: tier(doses[key] || 0, key) }))
- .filter(r => r.v > 0 && r.tier > 0)
- .sort((a, b) => b.tier - a.tier || b.v - a.v)
- .slice(0, 3);
- if (!ranked.length) return '';
- const chips = ranked.map(r => {
- const meta = ch[r.key] || {};
- const label = meta.label || r.key.replace('_', ' ');
- const value = formatUnit(r.key, r.v, durationMin, 'III', null, null, false, null);
- const tip = value ? `${meta.what || ''} — this session: ${value}` : `${meta.what || ''}`;
- return `
- ${meta.icon || '·'}
- ${escapeHTML(label)}
- ${value ? `${escapeHTML(value)}` : ''}
- `;
- }).join('');
- return `
${chips}
`;
+function _localSessionStamp(timestamp) {
+ const date = new Date(timestamp);
+ if (Number.isNaN(date.getTime())) return { date: 'Date unavailable', time: '' };
+ const localKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+ return {
+ date: formatDate(localKey),
+ time: date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }),
+ };
}
function _renderSessionRowsHTML(rows) {
@@ -162,11 +131,10 @@ function _renderSessionRowsHTML(rows) {
html += renderSunRow(row.sess);
} else if (row.kind === 'device') {
const sess = row.sess;
- const dev = deviceById[sess.deviceId];
- const devName = dev ? `${dev.brand} ${dev.model}` : 'Removed device';
- const date = formatDate(new Date(row.startedAt).toISOString().slice(0, 10));
- const dur = sess.durationMin ? `${Math.round(sess.durationMin)} min` : '—';
- const meta = `${dur} @ ${sess.distanceCm}cm · ${sess.bodyArea || ''}${sess.eyesProtected ? ' · eyes protected' : ''}`;
+ const dev = deviceById[sess.deviceId] || sess.deviceSnapshot || null;
+ const devName = dev ? `${dev.brand} ${dev.model}` : 'Device details unavailable';
+ const stamp = _localSessionStamp(row.startedAt);
+ const dur = sess.durationMin ? `${Math.round(sess.durationMin * 10) / 10} min` : '—';
// Mode badge — only on rows for devices that declare modes. The
// resolved mode answers "which LED groups fired" at a glance, key
// for hybrid panels where the same device can produce different
@@ -184,19 +152,27 @@ function _renderSessionRowsHTML(rows) {
modeAria = ` mode ${label}`;
}
}
- const devAriaLabel = `Open ${date} device session details — ${devName}${modeAria}`;
- html += `
`
diff --git a/js/light-sun-loader.js b/js/light-sun-loader.js
index c825a0f6..84a76ca6 100644
--- a/js/light-sun-loader.js
+++ b/js/light-sun-loader.js
@@ -109,6 +109,10 @@ export function renderLoadedLightSessionLogActions() {
return _lightSunModules?.renderLightSessionLogActions?.() || '';
}
+export function renderLoadedLightLiveSession(options) {
+ return _lightSunModules?.renderLightLiveSession?.(options) || '';
+}
+
export function ensureLoadedActiveDeviceTicker() {
if (_lightSunModules) return _lightSunModules.ensureActiveDeviceTicker?.();
const hasActiveDeviceSession = state.importedData?.deviceSessions
diff --git a/js/light-today-ai.js b/js/light-today-ai.js
index 73a8e513..1ca7482a 100644
--- a/js/light-today-ai.js
+++ b/js/light-today-ai.js
@@ -9,7 +9,7 @@
import { state } from './state.js';
import { escapeHTML } from './utils.js';
import { hasAIProvider } from './api.js';
-import { CHANNEL_DISPLAY, formatChannelUnit, channelTier, rollingChannelTotals, rollingVitaminDIU, tierLabel } from './sun.js';
+import { CHANNEL_DISPLAY, formatChannelUnit, rollingChannelTotals, rollingVitaminDIU } from './sun.js';
import { rollingDeviceTotals } from './light-devices-store.js';
import { solarZenithAngle } from './sun-uvdata.js';
import { createAIVerdict, hashString, dotPrefix } from './ai-verdict-engine.js';
@@ -115,11 +115,8 @@ export function computeLightTrends(targetDate = new Date()) {
if (prev7.length > 0 && last7.length < prev7.length * 0.5) {
out.signals.push(`Light activity dropped ${Math.round((1 - last7.length / prev7.length) * 100)}% vs prior week (${last7.length} sessions vs ${prev7.length})`);
}
- const week = lightTodayDeps.rollingVitaminDIU(7);
- const target = state.importedData?.sunDefaults?.dailyVitDTargetIU;
- if (target && week < target * 7 * 0.4) {
- out.signals.push(`Weekly vit-D synthesis ~${Math.round(week)} IU is well below your daily target × 7 (${target * 7} IU)`);
- }
+ // Do not compare modeled sunlight IU-equivalents with an oral-intake target.
+ // They are different constructs and neither predicts serum 25(OH)D here.
return out;
}
@@ -205,33 +202,25 @@ export function buildDayContext(target) {
const sun7 = lightTodayDeps.rollingChannelTotals(7) || {};
const dev7 = lightTodayDeps.rollingDeviceTotals(7) || {};
- const merged7 = {};
- for (const k of new Set([...Object.keys(sun7), ...Object.keys(dev7)])) {
- merged7[k] = (sun7[k] || 0) + (dev7[k] || 0);
- }
const vit7 = lightTodayDeps.rollingVitaminDIU(7);
lines.push('');
lines.push('### Last 7 days context');
- lines.push(`Cumulative vit-D synthesized from sun: ~${Math.round(vit7)} IU`);
- // Channels surface as tier labels only — the raw scores
- // (melanopic-lux-min, J/cm², etc.) aren't user-meaningful, and
- // when the AI quoted them verbatim the verdict read like
- // "outdoor eye light (774465)". Tier labels (none/low/moderate/
- // good/strong) carry the same comparative signal without the
- // numeric noise.
+ lines.push(`Modeled sunlight vitamin-D comparison: ~${Math.round(vit7)} IU-equivalent (wide uncertainty; not measured synthesis or intake)`);
+ // Channel context reports source presence only. It does not merge targeted
+ // devices with sunlight or turn an internal normalization into a grade.
const channelOrder = ['vitamin_d', 'circadian', 'nir_solar', 'no_cv', 'pomc', 'violet_eye'];
for (const k of channelOrder) {
- const v = merged7[k] || 0;
- if (v <= 0) continue;
- const tier = channelTier(v, k);
- lines.push(` - ${(CHANNEL_DISPLAY[k]?.label || k)}: ${tierLabel(tier)}`);
+ const sun = (sun7[k] || 0) > 0 ? 'sunlight logged' : 'no sunlight log';
+ const device = (dev7[k] || 0) > 0 ? 'device logged separately' : 'no device log';
+ if ((sun7[k] || 0) <= 0 && (dev7[k] || 0) <= 0) continue;
+ lines.push(` - ${(CHANNEL_DISPLAY[k]?.label || k)}: ${sun}; ${device}`);
}
lines.push('');
lines.push('### User profile');
if (sd.fitzpatrick) lines.push(`Skin type: Fitzpatrick ${sd.fitzpatrick}`);
else if (lc.skinType) lines.push(`Skin type: ${lc.skinType}`);
- if (sd.dailyVitDTargetIU) lines.push(`Vit-D daily target: ${sd.dailyVitDTargetIU} IU`);
+ if (sd.dailyVitDTargetIU) lines.push(`Separate recorded vitamin-D intake target: ${sd.dailyVitDTargetIU} IU/day (do not compare directly with sunlight IU-equivalent)`);
if (goals) lines.push(`Health goals: ${String(goals).slice(0, 200)}`);
try {
@@ -252,10 +241,8 @@ export function buildDayContext(target) {
return lines.join('\n');
}
-// Bumped 2026-05-08: prompt now strips raw channel scores; existing
-// cached verdicts contain user-hostile numbers like "(1202696)" and
-// need to refresh against the tightened prompt.
-const _dayFingerprintSalt = 'v2-tier-labels';
+// Source-aware framing invalidates older verdicts that graded channel tiers.
+const _dayFingerprintSalt = 'v3-source-signals';
export function getDayFingerprint(target) {
const targetDate = target?.date || new Date();
const { sun, dev, measurements } = _collectWindowData(targetDate);
@@ -267,24 +254,27 @@ export function getDayFingerprint(target) {
}
const SYSTEM_PROMPT = [
- 'You evaluate a single day of a user\'s light exposure. Return one verdict that synthesizes sun + light-therapy + indoor environment + recent trends against the user\'s goals.',
+ 'You summarize a single day of a user\'s logged light. Focus on what stands out now: timing, source, indoor environment, explicit safety flags, and one useful next step.',
'Return ONLY valid JSON: {"dot":"green|yellow|red|gray","tip":"string","detail":"string"}.',
'',
'dot:',
- ' green = the day was on-protocol — sufficient outdoor / circadian exposure, safe burn doses, evening light environment supports sleep',
- ' yellow = mostly OK but one specific gap (e.g., no sunrise + indoor-only screens, evening lights too bright, weekly vit-D under target trending)',
- ' red = circadian-hostile day or unsafe (over MED + no eye protection, late-evening cool-bright light + no morning anchor, prolonged indoor with no daylight at all)',
+ ' green = a useful timing or source pattern is logged and no supplied deterministic warning is present; never imply biological sufficiency or certify safety',
+ ' yellow = mostly aligned but one specific data-backed gap or caution is present',
+ ' red = a deterministic safety flag or strongly counterproductive timing pattern is recorded (base MED reached, UV device without goggles, or intense late-evening light)',
' gray = not enough data (no logged activity)',
'',
- 'Weight the day relative to the USER\'S GOALS (vit-D restoration vs SAD relief vs sleep optimization vs general health). Reference 25-OH-D when present.',
- 'Trend signals (days since last sunrise, weekly vit-D under target, dropping activity) deserve mention when relevant.',
+ 'Use the USER\'S GOALS only to select a relevant observation. Never diagnose a deficiency, prescribe treatment, or infer vitamin-D status from light logs.',
+ 'Trend signals (days since last sunrise or dropping activity) deserve mention when relevant. Never compare sunlight IU-equivalents with an oral-intake target or infer serum 25(OH)D.',
+ 'Channel lines only say whether sunlight or a device was logged. Never call a channel low, good, strong, complete, deficient, balanced, or a percentage of a target. Missing logs are not missing biology.',
+ 'Keep sunlight and devices separate. A targeted device does not recreate full-spectrum outdoor light.',
'Non-obvious patterns to flag: midday session followed by sleep room with measurable light; sunrise sessions logged only on weekends; long device sessions without paired sunlight; evening device sessions on a SAD lamp doing the OPPOSITE of what the user wants.',
'',
...LIGHTING_HARDWARE_CAVEATS,
'',
- 'tip: one sentence, max 18 words. The single highest-leverage observation or fix for this day. Direct.',
- 'detail: 2–4 sentences. Synthesize: what worked + what didn\'t + the highest-leverage tomorrow-action. Recommendations involving fixtures or dimming MUST honor the hardware caveats above.',
- 'NUMBER DISCIPLINE: only quote numbers when they carry user-meaningful units that appear verbatim in the context block — vit-D IU, minutes outdoors, %MED, lux, °elevation. Channel weekly totals are reported as tier labels (none/low/moderate/good/strong); refer to them by tier ("strong body clock this week"), never as raw scores ("body clock 1202696"). Do not invent units that aren\'t in the context.',
+ 'The separate deterministic UV-safety panel owns modeled burn-dose guidance. You may acknowledge an explicit supplied warning, but never soften, override, or invent it.',
+ 'tip: one sentence, max 18 words. The single clearest observation or fix for this day. Direct.',
+ 'detail: 2–4 sentences. Explain what was logged, what remains uncertain, and the highest-leverage today-or-tomorrow action. Recommendations involving fixtures or dimming MUST honor the hardware caveats above.',
+ 'NUMBER DISCIPLINE: only quote numbers when they carry user-meaningful units that appear verbatim in the context block — vit-D IU-equivalent, minutes outdoors, %MED, lux, or °elevation. Do not invent channel scores or units.',
'',
'No "you should" — be observational. No emoji.',
].join('\n');
@@ -337,7 +327,7 @@ const _autoFiredKeys = new Set();
function renderLightTodayQuestion() {
return `
Question this AI answers
-
Is today’s light pattern supporting circadian rhythm, sleep, vitamin-D goals, and safe exposure?
+
What stands out in today’s logged light, and is there a useful next step?
Minimum useful dataSun/device sessionsTime of dayDuration
diff --git a/js/light-tool-camera.js b/js/light-tool-camera.js
index c5425b31..1d103c55 100644
--- a/js/light-tool-camera.js
+++ b/js/light-tool-camera.js
@@ -16,33 +16,33 @@ export function getRequired2DContext(canvas) {
// spectrum, glass transmission) is the difference between a useful
// reading and a misleading one — and there's no way to recover from a
// fixed user-facing webcam (skin tones bias spectrum classifier toward
-// "warm LED" regardless of actual ceiling source; PWM stripes attenuate
+// "warm" regardless of actual ceiling source; rolling-shutter bands attenuate
// when reflected; "bedroom" measurements done from a desk webcam are
// actually office measurements with a bedroom label).
const _AIMING_GUIDES = {
lux: {
- mode: 'FROM your position',
- body: 'Hold the camera at eye height, facing the room as you\'d normally sit, work, or read. The reading captures light reaching your eye, not the bulb\'s raw output.',
- webcam: 'A laptop / monitor webcam pointed at you is acceptable for this — it sees roughly the same light field hitting your face.',
+ mode: 'AT the point you want to check',
+ body: 'Hold the phone at eye height where you normally sit, work, or read. Keep the light sensor uncovered and point its side toward the light field you want to check (usually the screen/front side). If you choose the camera fallback, face the camera into the room rather than directly at a bulb.',
+ webcam: 'A laptop may not expose a light sensor. Its camera can make a relative estimate, but a phone light sensor or real lux meter is preferable.',
},
flicker: {
mode: 'AT the source',
- body: 'Point the camera directly at the bulb / fixture from ~30–50 cm, so the source fills a noticeable chunk of the frame. PWM stripes are subtle when reflected off walls or skin.',
+ body: 'Point the camera directly at the bulb / fixture from ~30–50 cm, so the source fills a noticeable chunk of the frame. Rolling-shutter bands are harder to see after light reflects off walls or skin.',
webcam: '⚠ A user-facing webcam under-reads flicker — modulation amplitude attenuates when bouncing off your face. Use a phone for a real read.',
},
cct: {
mode: 'AT the source',
body: 'Point the camera directly at the fixture or a white wall lit by it from ~30–50 cm. White paper or a grey card under the source also works.',
- webcam: '⚠ A user-facing webcam reads warm — skin tones in the frame skew the integration. Cool sources can under-read by 1000–2000 K.',
+ webcam: '⚠ A user-facing webcam can be skewed by skin, clothing, automatic white balance, and the display. Use a phone aimed at a neutral surface when possible.',
},
spectrum: {
mode: 'AT the source',
body: 'Point the camera directly at the bulb or LED panel so it dominates the frame. The classifier reads the RGB profile of whatever it sees.',
- webcam: '⚠ A user-facing webcam will almost always classify "warm LED" because skin + clothing fills the frame, regardless of the actual ceiling source. Use a phone.',
+ webcam: '⚠ A user-facing webcam mostly sees the person and display, so its RGB balance may not represent the room source. Use a phone aimed at the source when possible.',
},
darkness: {
mode: 'FROM your sleeping position',
- body: 'Place the phone face-up on your pillow or bedside table at night, lens facing the ceiling. Capture the actual light hitting your closed eyelids during sleep, with the room lit as you\'ll sleep (door cracked, hallway light on, alarm clock visible — whatever\'s normal).',
+ body: 'Place the phone face-up on your pillow or bedside table at night, lens facing the ceiling. Check the light field at your sleeping position with the room set as you normally sleep (door, hallway light, alarm clock, and curtains included).',
webcam: '⚠ A monitor webcam in a different room can\'t measure your bedroom darkness. This one needs to be physically on the bed.',
},
'glass-transmission': {
@@ -153,8 +153,8 @@ export async function lockCameraForMeasurement(stream, opts = {}) {
advanced.push({ exposureMode: 'manual' });
if (Number.isFinite(caps.exposureCompensation?.min)) advanced.push({ exposureCompensation: 0 });
// Pin shutter to a usable value for flicker detection — short enough
- // that PWM banding at 100 Hz+ shows up as visible stripes (not blurred
- // by a long shutter), but long enough that ambient indoor light gives
+ // that some temporal modulation can show as rolling-shutter stripes
+ // rather than being blurred by a long shutter, while still giving
// signal. 1/120s = 8.33ms is a reasonable middle ground if the camera
// exposes `exposureTime` (units: 100 µs in the WICG spec).
if (opts.shortExposure && Number.isFinite(caps.exposureTime?.min)) {
@@ -240,17 +240,18 @@ export function cameraLockStatusLine(lock) {
// ─── Shared row-banding analyzer ───────────────────────────────────────
//
// The intra-frame rolling-shutter banding signal: a CMOS sensor reads out
-// rows top-to-bottom over ~15-33 ms. A PWM light source modulates during
+// rows top-to-bottom over ~15-33 ms. A temporally modulated source can vary during
// that readout, painting horizontal stripes. Detecting variance ROW-WISE
-// (per-row mean luma, then stddev across rows) reveals PWM at 100 Hz –
-// 25 kHz that frame-rate sampling literally cannot see.
+// (per-row mean luma, then stddev across rows) can reveal modulation that
+// frame-to-frame sampling misses. Camera readout timing is device-specific,
+// so stripe count is not converted into a frequency.
//
// Returns:
// frameMean — mean luma across the whole frame (0–255 scale)
// frameMax — max single-pixel luma (catches bright spikes)
-// bandingRatio — stddev of row means / frame mean (PWM banding strength)
-// stripes — zero-crossings of detrended row signal across the frame
-// (rough N stripes / 25ms readout = N × 40 Hz PWM frequency)
+// bandingRatio — stddev of row means / frame mean (camera banding strength)
+// stripes — zero-crossings of detrended row signal across the frame;
+// useful as a banding-strength feature, not frequency
// rowMeans — Float32Array of per-row mean luma (debugging / future use)
//
// Used by flicker, spectrum, CCT, and (peripherally) sleep-darkness tools.
@@ -289,13 +290,30 @@ export function computeRowBanding(data, W, H) {
}
-// Shared lux calibration used by Lux, Darkness, Glass Transmission, and Eye-Level Audit.
+// Device-local, one-point camera calibration used only to display an
+// approximate camera lux estimate. It is not meter calibration and camera
+// values remain excluded from biological screening calculations.
export function loadLuxCalibration() {
try { return parseFloat(localStorage.getItem('labcharts-lux-calibration') || '') || 1.0; }
catch (e) { return 1.0; }
}
export function saveLuxCalibration(factor) {
- try { localStorage.setItem('labcharts-lux-calibration', String(factor)); }
+ try {
+ localStorage.setItem('labcharts-lux-calibration', String(factor));
+ localStorage.setItem('labcharts-lux-calibration-confirmed', 'true');
+ }
catch (e) {}
}
+
+export function isLuxCalibrationConfirmed() {
+ try { return localStorage.getItem('labcharts-lux-calibration-confirmed') === 'true'; }
+ catch (e) { return false; }
+}
+
+export function clearLuxCalibration() {
+ try {
+ localStorage.removeItem('labcharts-lux-calibration');
+ localStorage.removeItem('labcharts-lux-calibration-confirmed');
+ } catch (e) {}
+}
diff --git a/js/light-tool-cct-meter.js b/js/light-tool-cct-meter.js
index 58e52f84..ad2ffd5f 100644
--- a/js/light-tool-cct-meter.js
+++ b/js/light-tool-cct-meter.js
@@ -1,5 +1,5 @@
// @ts-check
-// Camera-backed color temperature and melanopic-load workflow.
+// Camera-backed warm/cool appearance workflow.
import { queryRequired, showNotification } from './utils.js';
import { openAppendedModalOverlay, removeModalOverlay } from './modal-lifecycle.js';
@@ -34,7 +34,7 @@ export async function openCCTMeter(opts = {}, deps = {}) {
${aimingGuideHTML('cct')}
-
Reading updates live.
+
Approximate warm/cool camera check. It cannot show spectral completeness, red/infrared content, or melanopic EDI.
— K
@@ -66,6 +66,8 @@ export async function openCCTMeter(opts = {}, deps = {}) {
let currentCCT = null;
let currentMelanopic = null;
let currentPWMActive = false;
+ let measurementConfidence = 0.2;
+ let whiteBalanceMode = 'auto';
const valueEl = /** @type {HTMLElement} */ (queryRequired(overlay, '#cct-value'));
const toneEl = /** @type {HTMLElement} */ (queryRequired(overlay, '#cct-tone'));
const coherenceEl = /** @type {HTMLElement} */ (queryRequired(overlay, '#cct-coherence'));
@@ -86,6 +88,8 @@ export async function openCCTMeter(opts = {}, deps = {}) {
if (closed) return;
const lock = await lockCameraForMeasurement(stream);
if (closed) return;
+ whiteBalanceMode = lock.whiteBalance;
+ measurementConfidence = lock.whiteBalance === 'manual' ? 0.35 : 0.2;
if (lock.whiteBalance !== 'manual') {
coherenceEl.innerHTML = `⚠ camera auto-white-balance is on — CCT reading is the camera's error, not the source. Try a different browser / phone, or use a meter for accurate readings.`;
}
@@ -114,7 +118,7 @@ export async function openCCTMeter(opts = {}, deps = {}) {
const normalizedRed = red / sum;
const normalizedBlue = blue / sum;
const ratio = normalizedBlue / Math.max(normalizedRed, 0.01);
- const cct = Math.round(1800 + Math.min(5200, ratio * 4500));
+ const cct = Math.round((1800 + Math.min(5200, ratio * 4500)) / 100) * 100;
const melanopic = normalizedBlue;
const { bandingRatio, stripes } = computeRowBanding(data, canvas.width, canvas.height);
bandingPeaks.push(bandingRatio);
@@ -123,18 +127,18 @@ export async function openCCTMeter(opts = {}, deps = {}) {
currentCCT = cct;
currentMelanopic = melanopic;
currentPWMActive = peakBanding > 0.10 && stripes >= 2;
- valueEl.textContent = `${cct} K`;
+ valueEl.textContent = `~${cct} K`;
toneEl.textContent = cctTone(cct);
if (lock.whiteBalance === 'manual') {
const melanopicNote = melanopic > 0.32
- ? `⚠ high melanopic load (${(melanopic * 100).toFixed(0)}%) — daytime use only`
+ ? `blue-rich camera signal (${(melanopic * 100).toFixed(0)}% RGB-blue proxy) — not M-EDI`
: melanopic < 0.25
- ? `✓ sleep-safe melanopic load (${(melanopic * 100).toFixed(0)}%)`
- : `mixed melanopic load (${(melanopic * 100).toFixed(0)}%)`;
+ ? `blue-poor camera signal (${(melanopic * 100).toFixed(0)}% RGB-blue proxy) — not a sleep-safety determination`
+ : `mixed camera RGB signal (${(melanopic * 100).toFixed(0)}% blue proxy) — not M-EDI`;
const pwmNote = peakBanding > 0.10 && stripes >= 2
- ? ' ⚠ PWM dimming detected — open Flicker Detector for severity'
+ ? ' ⚠ Rolling-shutter banding detected — use a suitable flicker meter to quantify it'
: '';
- coherenceEl.innerHTML = solarCoherence(cct) + ` ${melanopicNote}${pwmNote}`;
+ coherenceEl.innerHTML = `${melanopicNote}${pwmNote} Warm/cool appearance only. Similar CCT values can have very different spectra.`;
}
if (cctState.running && !closed) requestAnimationFrame(tick);
};
@@ -147,8 +151,19 @@ export async function openCCTMeter(opts = {}, deps = {}) {
queryRequired(overlay, '#cct-save').addEventListener('click', async () => {
if (currentCCT == null) return;
await saveMeasurement('cct', currentCCT, {
- confidence: 0.5,
- extra: { melanopic: currentMelanopic, pwmActive: currentPWMActive },
+ confidence: measurementConfidence,
+ extra: {
+ // Keep the legacy key so existing exports and older consumers do not
+ // lose the recorded value. `cameraBlueRatioProxy` + `method` are the
+ // authoritative fields: a phone RGB ratio is not a melanopic metric.
+ cameraBlueRatioProxy: currentMelanopic,
+ melanopic: currentMelanopic,
+ bandingDetected: currentPWMActive,
+ pwmActive: currentPWMActive, // legacy export key; this is camera banding, not a PWM diagnosis
+ method: 'camera-rgb-proxy',
+ whiteBalanceMode,
+ estimateRoundedToK: 100,
+ },
roomId,
});
showNotification(`Color temp saved: ${currentCCT} K`);
@@ -164,16 +179,3 @@ function cctTone(kelvin) {
if (kelvin < 6000) return 'Daylight';
return 'Overcast / blue-shifted';
}
-
-function solarCoherence(kelvin) {
- const hour = new Date().getHours();
- let solarKelvin;
- if (hour < 6 || hour >= 20) solarKelvin = 2000;
- else if (hour < 8 || hour >= 18) solarKelvin = 3500;
- else if (hour < 10 || hour >= 16) solarKelvin = 5000;
- else solarKelvin = 5500;
- const difference = Math.abs(kelvin - solarKelvin);
- if (difference < 800) return `✓ matches solar time (~${solarKelvin} K)`;
- if (difference < 1500) return `slight mismatch (solar now ~${solarKelvin} K)`;
- return `⚠ mismatch — solar is ~${solarKelvin} K right now`;
-}
diff --git a/js/light-tool-darkness-meter.js b/js/light-tool-darkness-meter.js
index b9133611..47c07494 100644
--- a/js/light-tool-darkness-meter.js
+++ b/js/light-tool-darkness-meter.js
@@ -7,7 +7,6 @@ import {
aimingGuideHTML,
cameraLockStatusLine,
getRequired2DContext,
- loadLuxCalibration,
lockCameraForMeasurement,
} from './light-tool-camera.js';
import {
@@ -35,11 +34,22 @@ export async function openDarknessMeter(opts = {}, deps = {}) {
${aimingGuideHTML('darkness')}
-
Lights as you'll actually sleep — door cracked, hallway light on, etc.
+
Set the room as you actually sleep. The camera check is qualitative; enter a meter reading below for lux.
Press Start when ready.
+
+ Enter a lux-meter reading instead
+
+
+ Save meter reading
+
+ Photopic lux is not melanopic EDI; source spectrum still matters.
+
CancelStart 30-second read
+ Save camera check
`;
@@ -62,8 +72,15 @@ export async function openDarknessMeter(opts = {}, deps = {}) {
let result = null;
const statusEl = /** @type {HTMLElement} */ (queryRequired(overlay, '#dark-status'));
const startBtn = /** @type {HTMLButtonElement} */ (queryRequired(overlay, '#dark-start'));
+ const saveBtn = /** @type {HTMLButtonElement} */ (queryRequired(overlay, '#dark-save'));
startBtn.addEventListener('click', async () => {
+ if (darknessState.stream) {
+ try { darknessState.stream.getTracks().forEach(track => track.stop()); } catch (error) {}
+ darknessState.stream = null;
+ }
+ result = null;
+ saveBtn.disabled = true;
startBtn.disabled = true;
statusEl.textContent = 'Reading… leave the phone face-up and don\'t cover the camera.';
darknessState.running = true;
@@ -111,61 +128,91 @@ export async function openDarknessMeter(opts = {}, deps = {}) {
await new Promise(resolve => setTimeout(resolve, 200));
}
if (cancelled || !darknessState.running) return;
+ try { stream.getTracks().forEach(track => track.stop()); } catch (error) {}
+ if (darknessState.stream === stream) darknessState.stream = null;
+ darknessState.running = false;
const meanLuma = lumas.reduce((sum, value) => sum + value, 0) / Math.max(1, lumas.length);
const sortedPeaks = peaks.slice().sort((left, right) => left - right);
const peakLuma = sortedPeaks[Math.floor(sortedPeaks.length * 0.95)] || 0;
- const calFactor = loadLuxCalibration();
- const noiseFloorLuma = 2;
- const meanLux = Math.max(0, (meanLuma - noiseFloorLuma) * 0.5 * calFactor);
- const peakLux = Math.max(0, (peakLuma - noiseFloorLuma) * 0.5 * calFactor);
+ const cameraLevel = Math.min(100, Math.max(0, meanLuma / 255 * 100));
+ const peakLevel = Math.min(100, Math.max(0, peakLuma / 255 * 100));
let label;
let className;
- if (meanLux < 0.3 && peakLux < 1) {
- label = 'Excellent — true darkness';
+ if (cameraLevel < 3 && peakLevel < 8) {
+ label = 'Very dark camera frame';
className = 'ok';
- } else if (meanLux < 1 && peakLux < 5) {
- label = 'Good — minor leak, melatonin mostly preserved';
+ } else if (cameraLevel < 10 && peakLevel < 25) {
+ label = 'Low light visible to the camera';
className = 'ok';
- } else if (meanLux < 5 && peakLux < 20) {
- label = 'Moderate leak — 20–30% melatonin attenuation likely';
+ } else if (peakLevel >= 45 && cameraLevel < 15) {
+ label = 'Bright points or brief spikes detected';
className = 'warn';
- } else if (peakLux >= 20 && meanLux < 5) {
- label = 'Bright spikes detected — investigate notifications / passing lights';
+ } else if (cameraLevel < 30) {
+ label = 'Room light is clearly visible to the camera';
className = 'warn';
} else {
- label = 'Significant — circadian phase shift likely';
+ label = 'Bright camera frame';
className = 'over';
}
result = {
- meanLux,
- peakLux,
+ method: 'camera-relative',
+ cameraLevel,
+ peakCameraLevel: peakLevel,
+ meanLuma,
+ peakLuma,
lockMode: lock.exposure,
isoLocked: lock.iso != null,
- calFactor,
- label,
+ levelLabel: label,
cls: className,
};
- const calibrationNote = lock.iso != null
- ? `Locked ISO ${lock.iso}, exposure ${lock.exposure}.`
- : `⚠ ISO not lockable on this camera — readings are qualitative (good/moderate/bright), not absolute lux. ${cameraLockStatusLine(lock)}`;
+ const calibrationNote = lock.iso != null && lock.exposure === 'manual'
+ ? `Camera exposure held for this qualitative check. Device-specific low-light response still prevents an absolute lux reading.`
+ : `Camera exposure could not be fully fixed. Treat this only as a check for obvious light or bright points. ${cameraLockStatusLine(lock)}`;
statusEl.innerHTML = `${escapeHTML(label)}` +
- ` ~${meanLux.toFixed(2)} lux average · ~${peakLux.toFixed(2)} lux peak (95th-pctile)` +
+ ` Camera level ${cameraLevel.toFixed(0)}% · peak ${peakLevel.toFixed(0)}%. Not lux and not a melatonin estimate.` +
` ${calibrationNote}`;
- startBtn.textContent = 'Save reading';
+ startBtn.textContent = 'Read again';
startBtn.disabled = false;
- startBtn.onclick = async () => {
- await saveMeasurement('darkness', meanLux, {
- confidence: lock.iso != null ? 0.7 : 0.45,
- extra: result,
- roomId,
- });
- showNotification('Sleep darkness reading saved.');
- closeDarknessOverlay();
- };
+ saveBtn.disabled = false;
} catch (error) {
- statusEl.innerHTML = 'Camera access denied — darkness meter unavailable. Open your browser\'s site settings to allow camera access. This tool runs a long-exposure capture to detect ambient light below 1 lux — there\'s no useful manual-entry fallback.';
+ darknessState.running = false;
+ if (darknessState.stream) {
+ try { darknessState.stream.getTracks().forEach(track => track.stop()); } catch (stopError) {}
+ darknessState.stream = null;
+ }
+ statusEl.innerHTML = 'Camera access denied — the qualitative camera check is unavailable. Use the meter-entry option above for a numerical photopic-lux reading.';
startBtn.disabled = false;
}
});
+
+ saveBtn.addEventListener('click', async () => {
+ if (!result) {
+ showNotification('Run the camera check first.', 'error');
+ return;
+ }
+ await saveMeasurement('darkness', result.cameraLevel, {
+ confidence: result.isoLocked && result.lockMode === 'manual' ? 0.4 : 0.25,
+ extra: result,
+ roomId,
+ });
+ showNotification('Qualitative sleep-light check saved.');
+ closeDarknessOverlay();
+ });
+
+ queryRequired(overlay, '#dark-meter-save').addEventListener('click', async () => {
+ const input = /** @type {HTMLInputElement} */ (queryRequired(overlay, '#dark-meter-input'));
+ const lux = Number(input.value);
+ if (!Number.isFinite(lux) || lux < 0 || lux > 10000) {
+ showNotification('Enter a valid lux-meter reading between 0 and 10,000.', 'error');
+ return;
+ }
+ await saveMeasurement('darkness', lux, {
+ confidence: 0.9,
+ extra: { method: 'meter-entry', source: 'meter-entry', unit: 'photopic-lux', context: 'sleep' },
+ roomId,
+ });
+ showNotification('Sleep-time lux-meter reading saved.');
+ closeDarknessOverlay();
+ });
}
diff --git a/js/light-tool-flicker-detector.js b/js/light-tool-flicker-detector.js
index 9385362a..78454070 100644
--- a/js/light-tool-flicker-detector.js
+++ b/js/light-tool-flicker-detector.js
@@ -35,7 +35,7 @@ export async function openFlickerDetector(opts = {}, deps = {}) {
${aimingGuideHTML('flicker')}
-
Banding stripes indicate PWM flicker.
+
Rolling-shutter bands can reveal some modulated lights. No bands does not prove a light is flicker-free, and this camera cannot report a reliable flicker frequency.
Hold camera on a light for 5 seconds…
@@ -82,7 +82,7 @@ export async function openFlickerDetector(opts = {}, deps = {}) {
const lockNote = cameraLockStatusLine(lock);
if (lockNote) resultEl.innerHTML = `Hold camera on a light for 5 seconds… ${lockNote}`;
if (lock.frameRate && lock.frameRate < 60) {
- resultEl.innerHTML += ` ⚠ camera running at ${Math.round(lock.frameRate)} fps — PWM above ${Math.round(lock.frameRate / 2)} Hz won't show up. Try a different camera if available.`;
+ resultEl.innerHTML += ` ⚠ camera running at ${Math.round(lock.frameRate)} fps. Frame rate, rolling-shutter timing, and exposure limit what this screen can detect.`;
}
const canvas = document.createElement('canvas');
@@ -107,7 +107,7 @@ export async function openFlickerDetector(opts = {}, deps = {}) {
requestAnimationFrame(tick);
} catch (error) {
if (closed) return;
- resultEl.innerHTML = 'Camera access denied — flicker detector unavailable. This tool needs the camera at 240 fps to detect PWM banding. To re-enable, open your browser\'s site settings and allow camera access.';
+ resultEl.innerHTML = 'Camera access denied — banding screen unavailable. To re-enable this qualitative camera check, open your browser\'s site settings and allow camera access. Use a purpose-built meter for flicker frequency and modulation.';
}
function renderFlicker(frameSamples, bandingSamples, lock) {
@@ -128,39 +128,41 @@ export async function openFlickerDetector(opts = {}, deps = {}) {
const autoExposureActive = !lock || lock.exposure !== 'manual';
if (peakBanding > 0.18) {
score = 3;
- label = 'Heavy flicker — consider replacing this light';
+ label = 'Strong rolling-shutter banding';
} else if (peakBanding > 0.10) {
score = 2;
- label = 'Visible flicker — eye-strain risk';
+ label = 'Clear rolling-shutter banding';
} else if (peakBanding > 0.04 || frameRatio > 0.12) {
score = 1;
- label = 'Mild flicker, likely OK for most';
+ label = 'Some banding detected';
} else if (autoExposureActive) {
score = 0;
- label = 'Below detection threshold (camera in auto mode)';
+ label = 'No banding detected (camera auto mode)';
} else {
score = 0;
- label = 'Flicker-free (no rolling-shutter banding detected)';
+ label = 'No rolling-shutter banding detected';
}
- let frequency = '';
- if (peakStripes >= 2) {
- frequency = ` · ~${peakStripes * 40} Hz (rolling-shutter banding)`;
- }
lastResult = {
score,
label,
bandingRatio: peakBanding,
stripes: peakStripes,
frameRatio,
+ method: 'rolling-shutter-camera-screen',
+ exposureLock: lock?.exposure || 'auto',
+ frameRate: lock?.frameRate || null,
};
- resultEl.innerHTML = `${escapeHTML(label)}${escapeHTML(frequency)} banding ${peakBanding.toFixed(3)} · frame-luma ${frameRatio.toFixed(3)}${peakStripes >= 2 ? ` · ${peakStripes} stripes/frame` : ''}`;
+ resultEl.innerHTML = `${escapeHTML(label)} banding proxy ${peakBanding.toFixed(3)} · frame change ${frameRatio.toFixed(3)}${peakStripes >= 2 ? ` · ${peakStripes} stripes/frame` : ''} · no frequency estimate`;
}
queryRequired(overlay, '#flicker-save').addEventListener('click', async () => {
- if (!lastResult) return;
+ if (!lastResult) {
+ showNotification('Wait for a camera result before saving.', 'error');
+ return;
+ }
await saveMeasurement('flicker', lastResult.score, {
- confidence: 0.7,
+ confidence: lastResult.exposureLock === 'manual' ? 0.55 : 0.35,
extra: lastResult,
roomId,
});
diff --git a/js/light-tool-glass-transmission.js b/js/light-tool-glass-transmission.js
index f034ea28..f1e21db5 100644
--- a/js/light-tool-glass-transmission.js
+++ b/js/light-tool-glass-transmission.js
@@ -6,7 +6,6 @@ import { openAppendedModalOverlay, removeModalOverlay } from './modal-lifecycle.
import {
aimingGuideHTML,
getRequired2DContext,
- loadLuxCalibration,
lockCameraForMeasurement,
} from './light-tool-camera.js';
import {
@@ -107,11 +106,10 @@ export async function openGlassTransmission(opts = {}, deps = {}) {
await new Promise(resolve => setTimeout(resolve, 125));
}
const meanLuma = samples.reduce((sum, value) => sum + value, 0) / samples.length;
- const luxEstimate = Math.max(0, meanLuma * 40 * loadLuxCalibration());
if (closed) return;
- glassReadings[which] = luxEstimate;
+ glassReadings[which] = meanLuma;
const readingEl = queryOptionalLightToolElement(overlay, `#glass-reading-${which}`);
- if (readingEl) readingEl.textContent = `${Math.round(luxEstimate)} lux`;
+ if (readingEl) readingEl.textContent = `${Math.round(meanLuma / 255 * 100)}% camera level`;
computeGlass();
} catch (error) {
if (!closed) {
@@ -131,22 +129,23 @@ export async function openGlassTransmission(opts = {}, deps = {}) {
function computeGlass() {
if (glassReadings.inside == null || glassReadings.outside == null) return;
const transmission = Math.min(1, glassReadings.inside / Math.max(glassReadings.outside, 1));
- const blocked = (1 - transmission) * 100;
const lockNote = lastGlassLock && lastGlassLock.exposure !== 'manual'
- ? ' ⚠ camera auto-exposure was active — re-exposes between samples, the ratio above is approximate. Re-take readings if you need precision.'
+ ? ' ⚠ Camera auto-exposure was active, so it may have erased part of the difference. Treat this as qualitative.'
: '';
queryRequired(overlay, '#glass-result').innerHTML =
- `Glass transmits ${(transmission * 100).toFixed(0)}% of visible light` +
- ` Blocks ~${blocked.toFixed(0)}% of broadband visible. UV transmission cannot be inferred from this measurement — Low-E and UV-blocking coatings have very different UV/visible ratios. A handheld UV meter is required to verify UV-A or UV-B blocking.${lockNote}`;
+ `Camera-visible response through glass: about ${(transmission * 100).toFixed(0)}% of the direct comparison` +
+ ` This is not a calibrated visible-transmission value. Scene movement, reflections, exposure, and phone spectral response affect it. UV or infrared transmission cannot be inferred; those require wavelength-appropriate meters.${lockNote}`;
const glassSave = /** @type {HTMLButtonElement} */ (queryRequired(overlay, '#glass-save'));
glassSave.disabled = false;
glassSave.onclick = async () => {
await saveMeasurement('glass-transmission', transmission, {
- confidence: lastGlassLock?.exposure === 'manual' ? 0.7 : 0.5,
+ confidence: lastGlassLock?.exposure === 'manual' ? 0.45 : 0.25,
extra: {
inside: glassReadings.inside,
outside: glassReadings.outside,
lockMode: lastGlassLock?.exposure || 'auto',
+ method: 'two-sample-camera-ratio',
+ unit: 'relative-camera-response',
},
roomId,
});
diff --git a/js/light-tool-lux-meter.js b/js/light-tool-lux-meter.js
index aea2e8d8..3e9ea5c9 100644
--- a/js/light-tool-lux-meter.js
+++ b/js/light-tool-lux-meter.js
@@ -6,7 +6,9 @@ import { openAppendedModalOverlay, removeModalOverlay } from './modal-lifecycle.
import {
aimingGuideHTML,
cameraLockStatusLine,
+ clearLuxCalibration,
getRequired2DContext,
+ isLuxCalibrationConfirmed,
loadLuxCalibration,
lockCameraForMeasurement,
saveLuxCalibration,
@@ -22,9 +24,9 @@ import {
} from './light-tool-camera-modal-runtime.js';
const LUX_ZONES = [
- { max: 10, label: 'Darkness', color: 'var(--text-muted)' },
- { max: 100, label: 'Low indoor', color: 'var(--text-secondary)' },
- { max: 500, label: 'Office', color: 'var(--text-primary)' },
+ { max: 10, label: 'Very dim', color: 'var(--text-muted)' },
+ { max: 100, label: 'Dim indoor', color: 'var(--text-secondary)' },
+ { max: 500, label: 'Typical indoor', color: 'var(--text-primary)' },
{ max: 1000, label: 'Bright indoor', color: 'var(--accent)' },
{ max: 10000, label: 'Overcast outdoor', color: 'var(--green)' },
{ max: 100000, label: 'Outdoor daylight', color: 'var(--orange)' },
@@ -36,12 +38,13 @@ function luxZone(lux) {
return LUX_ZONES[LUX_ZONES.length - 1];
}
-let luxState = /** @type {{ running: boolean, sensor: { stop: () => void } | null, stream: MediaStream | null, video: HTMLVideoElement | null, calibration: number }} */ ({
+let luxState = /** @type {{ running: boolean, sensor: { stop: () => void } | null, stream: MediaStream | null, video: HTMLVideoElement | null, calibration: number, calibrationConfirmed: boolean }} */ ({
running: false,
sensor: null,
stream: null,
video: null,
calibration: 1,
+ calibrationConfirmed: false,
});
export async function openLuxMeter(opts = {}, deps = {}) {
@@ -56,19 +59,38 @@ export async function openLuxMeter(opts = {}, deps = {}) {
${aimingGuideHTML('lux')}
+
+ Measure with
+
+ Phone light sensor
+ Preferred
+
+
+ Camera
+ Approximate fallback
+
+
Initializing…
-
+
—
-
lux
+
lux
—
+
+
+
+
+ lux
+
+
—
+
${LUX_ZONES.slice(0, 6).map(zone => `
≤ ${zone.max} ${zone.label}
`).join('')}
⚙ Calibrate against a known reference
-
Aim the camera at a light source whose lux you know — from a real meter, a second phone with an ambient-light sensor, or an indoor reading you trust. Enter the reference value below; we'll compute the factor that maps the camera's raw luma to that lux value and save it for future readings.
+
Place a real lux meter beside this phone, aim both in the same direction, and enter the reference. Camera lux is withheld until this device has been calibrated.
`;
- const manualInput = /** @type {HTMLInputElement | null} */ (queryOptionalLightToolElement(overlay, '#lux-manual-input'));
- const newZoneEl = /** @type {HTMLElement | null} */ (queryOptionalLightToolElement(overlay, '#lux-zone'));
- manualInput?.addEventListener('input', () => {
- const value = parseFloat(manualInput.value);
- if (Number.isFinite(value) && value >= 0) {
- currentLux = value;
- const zone = luxZone(value);
- if (newZoneEl) {
- newZoneEl.textContent = zone.label;
- newZoneEl.style.color = zone.color;
- }
- } else {
- currentLux = null;
- if (newZoneEl) {
- newZoneEl.textContent = '—';
- newZoneEl.style.color = '';
- }
- }
- });
- }
- if (calibrationPanel) calibrationPanel.style.display = 'none';
+ if (closed || activeSource !== 'camera' || thisCameraRun !== cameraRun) return;
+ cameraFallbackStarted = false;
+ cameraButton.disabled = true;
+ cameraButton.title = 'The browser blocked or could not start the camera';
+ cameraDetail.textContent = 'Unavailable here';
+ setActiveSource('manual');
+ sourceLine.innerHTML = 'Camera unavailable. Enter a reading from a real lux meter or a trusted meter app. Do not estimate it from the scale.';
}
};
const AmbientLightSensorCtor = getUtilsRuntimeValue('AmbientLightSensor');
- if (typeof AmbientLightSensorCtor === 'function') {
+ const ambientSensorSupported = typeof AmbientLightSensorCtor === 'function';
+ if (!ambientSensorSupported) {
+ alsButton.disabled = true;
+ alsButton.title = 'This browser does not expose the phone light sensor';
+ alsDetail.textContent = 'Unavailable here';
+ }
+
+ const startAmbientSensor = () => {
+ if (closed || !ambientSensorSupported) return false;
+ stopCamera();
+ stopAmbientSensor();
+ resetReading();
+ setActiveSource('als');
try {
const sensor = new AmbientLightSensorCtor({ frequency: 4 });
sensor.addEventListener('reading', () => {
- currentLux = sensor.illuminance;
+ if (closed || activeSource !== 'als' || luxState.sensor !== sensor) return;
+ const illuminance = Number(sensor.illuminance);
+ currentLux = Number.isFinite(illuminance) && illuminance >= 0 ? illuminance : null;
renderLux(currentLux);
});
sensor.addEventListener('error', () => {
+ if (closed || activeSource !== 'als' || luxState.sensor !== sensor) return;
try { sensor.stop(); } catch (error) {}
luxState.sensor = null;
+ alsButton.disabled = true;
+ alsButton.title = 'The browser blocked or could not read this sensor';
+ alsDetail.textContent = 'Unavailable here';
currentLux = null;
renderLux(null);
- void startCameraFallback('Ambient light sensor blocked by browser permissions. Retrying with camera estimate…');
+ void startCameraFallback('Phone light sensor unavailable. Using the camera fallback instead.');
});
- sensor.start();
luxState.sensor = sensor;
- usingALS = true;
- sourceLine.textContent = 'Reading from your phone\'s ambient light sensor.';
- if (calibrationPanel) calibrationPanel.style.display = 'none';
+ sensor.start();
+ sourceLine.textContent = 'Reading lux from your phone\'s light sensor. Keep it uncovered; readings can vary between phone models.';
+ return true;
} catch (error) {
- // Synchronous construction failure falls through to the camera.
+ stopAmbientSensor();
+ alsButton.disabled = true;
+ alsButton.title = 'The browser blocked or could not start this sensor';
+ alsDetail.textContent = 'Unavailable here';
+ return false;
}
- }
+ };
- if (!usingALS) await startCameraFallback();
+ alsButton.addEventListener('click', () => {
+ if (activeSource === 'als') return;
+ if (!startAmbientSensor()) {
+ void startCameraFallback('Phone light sensor unavailable. Using the camera fallback instead.');
+ }
+ });
+ cameraButton.addEventListener('click', () => {
+ if (activeSource === 'camera' || activeSource === 'manual') return;
+ void startCameraFallback();
+ });
+
+ manualInput.addEventListener('input', () => {
+ if (activeSource !== 'manual') return;
+ const value = parseFloat(manualInput.value);
+ if (Number.isFinite(value) && value >= 0) {
+ currentLux = value;
+ const zone = luxZone(value);
+ manualZoneEl.textContent = zone.label;
+ manualZoneEl.style.color = zone.color;
+ } else {
+ currentLux = null;
+ manualZoneEl.textContent = '—';
+ manualZoneEl.style.color = '';
+ }
+ });
+
+ if (!startAmbientSensor()) await startCameraFallback();
function renderLux(value) {
if (value == null) {
@@ -236,16 +342,29 @@ export async function openLuxMeter(opts = {}, deps = {}) {
zoneEl.textContent = '—';
return;
}
+ unitEl.textContent = 'lux';
valueEl.textContent = value < 100 ? value.toFixed(0) : Math.round(value).toLocaleString();
const zone = luxZone(value);
zoneEl.textContent = zone.label;
zoneEl.style.color = zone.color;
}
+ function renderCameraProxy(rawLuma) {
+ valueEl.textContent = `${Math.round(Math.min(100, Math.max(0, rawLuma / 255 * 100)))}%`;
+ unitEl.textContent = 'camera level';
+ zoneEl.textContent = 'Calibration required for lux';
+ zoneEl.style.color = 'var(--text-muted)';
+ }
+
const calApplyBtn = /** @type {HTMLButtonElement | null} */ (queryOptionalLightToolElement(overlay, '#lux-cal-apply'));
const calResetBtn = /** @type {HTMLButtonElement | null} */ (queryOptionalLightToolElement(overlay, '#lux-cal-reset'));
const calRefInput = /** @type {HTMLInputElement | null} */ (queryOptionalLightToolElement(overlay, '#lux-cal-reference'));
calApplyBtn?.addEventListener('click', () => {
+ if (activeSource !== 'camera') return;
+ if (!cameraExposureHeld) {
+ showNotification('This camera cannot hold exposure in this browser, so a reusable lux calibration would be misleading.', 'error', 7000);
+ return;
+ }
if (currentRawLuma == null || currentRawLuma < 0.5) {
showNotification('Camera not reading yet — wait a moment, then try again.', 'error');
return;
@@ -259,28 +378,45 @@ export async function openLuxMeter(opts = {}, deps = {}) {
const clamped = Math.min(10, Math.max(0.1, newFactor));
luxState.calibration = clamped;
saveLuxCalibration(clamped);
+ luxState.calibrationConfirmed = true;
+ currentLux = refLux;
+ renderLux(currentLux);
if (calCurrentEl) calCurrentEl.textContent = `${clamped.toFixed(2)}×`;
- sourceLine.innerHTML = `Camera estimate (calibration ${clamped.toFixed(2)}×, ±30%). Calibrated against ${refLux} lux reference.`;
+ sourceLine.innerHTML = `Device-calibrated camera estimate using a ${refLux} lux reference. Approximate only; it stays outside biological scoring.`;
showNotification(`Lux meter calibrated · factor ${clamped.toFixed(2)}×`);
});
calResetBtn?.addEventListener('click', () => {
+ if (activeSource !== 'camera') return;
luxState.calibration = 1;
- saveLuxCalibration(1);
- if (calCurrentEl) calCurrentEl.textContent = '1.00×';
- sourceLine.innerHTML = 'Camera estimate (calibration 1.00×, ±30%). Reset to default.';
+ luxState.calibrationConfirmed = false;
+ clearLuxCalibration();
+ currentLux = null;
+ if (calCurrentEl) calCurrentEl.textContent = 'not calibrated';
+ sourceLine.innerHTML = 'Camera calibration removed. Lux values are withheld until this phone is calibrated again.';
showNotification('Lux calibration reset to 1.00×');
});
queryRequired(overlay, '#lux-save').addEventListener('click', async () => {
if (currentLux == null) {
- if (usingManualEntry) showNotification('Enter a lux value first.', 'error');
+ const message = activeSource === 'manual'
+ ? 'Enter a lux value first.'
+ : activeSource === 'als'
+ ? 'Waiting for the phone light sensor to report a reading.'
+ : 'Calibrate this camera beside a lux meter before saving.';
+ showNotification(message, 'error', 7000);
return;
}
- const source = usingALS ? 'AmbientLightSensor' : usingManualEntry ? 'manual-entry' : 'camera-estimate';
- const confidence = usingALS ? 0.85 : usingManualEntry ? 0.9 : 0.55;
+ const source = activeSource === 'als' ? 'AmbientLightSensor' : activeSource === 'manual' ? 'manual-entry' : 'camera-estimate';
+ const confidence = activeSource === 'als' ? 0.8 : activeSource === 'manual' ? 0.85 : 0.55;
await saveMeasurement('lux', currentLux, {
confidence,
- extra: { source, calibrationFactor: luxState.calibration },
+ extra: {
+ source,
+ calibrationFactor: luxState.calibration,
+ calibrationConfirmed: source === 'camera-estimate' ? luxState.calibrationConfirmed : undefined,
+ measurementKind: 'photopic-illuminance',
+ context: opts.context || null,
+ },
roomId,
});
showNotification(`Lux reading saved: ${Math.round(currentLux)}`);
diff --git a/js/light-tool-spectrum-classifier.js b/js/light-tool-spectrum-classifier.js
index 435f3f7d..17f38989 100644
--- a/js/light-tool-spectrum-classifier.js
+++ b/js/light-tool-spectrum-classifier.js
@@ -1,5 +1,5 @@
// @ts-check
-// Camera-backed RGB, melanopic-load, and PWM spectrum classification.
+// Camera-backed RGB pattern and rolling-shutter banding screen.
import { escapeHTML, queryRequired, showNotification } from './utils.js';
import { openAppendedModalOverlay, removeModalOverlay } from './modal-lifecycle.js';
@@ -35,7 +35,7 @@ export async function openSpectrumClassifier(opts = {}, deps = {}) {
${aimingGuideHTML('spectrum')}
-
We classify by RGB pattern and flicker.
+
Qualitative warm/cool RGB pattern and rolling-shutter banding screen. This cannot identify a full spectrum, UV, infrared, melanopic EDI, or sleep safety.
Reading…
@@ -115,12 +115,12 @@ export async function openSpectrumClassifier(opts = {}, deps = {}) {
reason: result.reason + ' (camera auto-WB → low confidence)',
};
}
- const circadianBadge = result.circadian === 'sleep-safe'
- ? '✓ sleep-safe spectrum'
- : result.circadian === 'day-only'
- ? '⚠ day-only — high melanopic load'
- : 'mixed melanopic load';
- resultEl.innerHTML = `${escapeHTML(result.label)}· ${(result.confidence * 100).toFixed(0)}% confidence ${escapeHTML(result.reason)} ${circadianBadge} · melanopic ratio ${(result.melanopic * 100).toFixed(0)}%`;
+ const circadianBadge = result.circadian === 'blue-poor'
+ ? 'blue-poor camera-RGB pattern'
+ : result.circadian === 'blue-rich'
+ ? 'blue-rich camera-RGB pattern'
+ : 'mixed camera-RGB pattern';
+ resultEl.innerHTML = `${escapeHTML(result.label)}· ${(result.confidence * 100).toFixed(0)}% confidence ${escapeHTML(result.reason)} ${circadianBadge} · camera blue ratio ${(result.melanopic * 100).toFixed(0)}% · not a spectrum or M-EDI`;
if (spectrumState.running && !closed) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
@@ -163,7 +163,13 @@ export async function openSpectrumClassifier(opts = {}, deps = {}) {
}
await saveMeasurement('spectrum', result.label, {
confidence: result.confidence,
- extra: result,
+ extra: {
+ ...result,
+ // Preserve `melanopic` for old exports/readers without pretending it
+ // is CIE melanopic EDI. New code should prefer the explicit proxy.
+ cameraBlueRatioProxy: result.melanopic,
+ method: result.melanopic == null ? 'manual-classification' : 'camera-rgb-proxy',
+ },
roomId,
});
showNotification(`Light type saved: ${result.label}`);
@@ -177,29 +183,29 @@ function classifyLight({ r, g, b, peakBanding, stripes }) {
const normalizedGreen = g / sum;
const normalizedBlue = b / sum;
const melanopic = normalizedBlue;
- const circadian = melanopic < 0.25 ? 'sleep-safe' : melanopic > 0.32 ? 'day-only' : 'mixed';
- const heavyPWM = peakBanding > 0.10 && stripes >= 2;
+ const circadian = melanopic < 0.25 ? 'blue-poor' : melanopic > 0.32 ? 'blue-rich' : 'mixed';
+ const hasBanding = peakBanding > 0.10 && stripes >= 2;
- if (heavyPWM && normalizedGreen > 0.36) {
- return { label: 'Fluorescent / CFL', confidence: 0.75, reason: 'PWM banding + green spike — fluorescent signature.', melanopic, circadian };
+ if (hasBanding && normalizedGreen > 0.36) {
+ return { label: 'Green-biased source with banding', confidence: 0.55, reason: 'Camera RGB is green-biased and rolling-shutter bands are visible; source technology is not identified.', melanopic, circadian };
}
if (normalizedRed > 0.40 && normalizedBlue < 0.20) {
- return { label: 'Incandescent / halogen', confidence: 0.8, reason: 'Red-rich, low blue — filament-style emitter, sleep-safe.', melanopic, circadian };
+ return { label: 'Very warm-looking source', confidence: 0.55, reason: 'Red-rich, low-blue camera pattern; construction and missing wavelengths remain unknown.', melanopic, circadian };
}
- if (normalizedBlue > 0.36 && !heavyPWM) {
- return { label: 'Cool LED (4000K+)', confidence: 0.75, reason: 'Blue-rich, near-flicker-free — daytime / focus light.', melanopic, circadian };
+ if (normalizedBlue > 0.36 && !hasBanding) {
+ return { label: 'Cool-looking source', confidence: 0.5, reason: 'Blue-rich camera pattern with no rolling-shutter bands detected.', melanopic, circadian };
}
- if (normalizedBlue > 0.36 && heavyPWM) {
- return { label: 'Cool LED with PWM dimming', confidence: 0.75, reason: 'Blue-rich + visible PWM stripes — eye-strain risk on dim setting.', melanopic, circadian };
+ if (normalizedBlue > 0.36 && hasBanding) {
+ return { label: 'Cool-looking source with banding', confidence: 0.55, reason: 'Blue-rich camera pattern plus rolling-shutter bands; modulation method is not identified.', melanopic, circadian };
}
- if (normalizedRed > 0.32 && normalizedBlue < 0.30 && !heavyPWM) {
- return { label: 'Warm LED (2700–3000K)', confidence: 0.75, reason: 'Slight red lift, near-flicker-free — evening-friendly.', melanopic, circadian };
+ if (normalizedRed > 0.32 && normalizedBlue < 0.30 && !hasBanding) {
+ return { label: 'Warm-looking source', confidence: 0.5, reason: 'Warm camera-RGB pattern with no rolling-shutter bands detected.', melanopic, circadian };
}
- if (normalizedRed > 0.32 && normalizedBlue < 0.30 && heavyPWM) {
- return { label: 'Warm LED with PWM dimming', confidence: 0.7, reason: 'Warm + PWM stripes — replace with flicker-free for evening rooms.', melanopic, circadian };
+ if (normalizedRed > 0.32 && normalizedBlue < 0.30 && hasBanding) {
+ return { label: 'Warm-looking source with banding', confidence: 0.55, reason: 'Warm camera-RGB pattern plus rolling-shutter bands; source technology is not identified.', melanopic, circadian };
}
if (Math.abs(normalizedRed - 0.33) < 0.05 && Math.abs(normalizedBlue - 0.33) < 0.05) {
- return { label: 'Daylight or full-spectrum', confidence: 0.65, reason: 'Balanced RGB — natural or full-spectrum source.', melanopic, circadian };
+ return { label: 'Balanced camera RGB — source unknown', confidence: 0.35, reason: 'Balanced phone RGB cannot distinguish daylight, white LEDs, or a full spectrum.', melanopic, circadian };
}
- return { label: 'Mixed / unclassified', confidence: 0.4, reason: 'Pattern doesn\'t match a known signature.', melanopic, circadian };
+ return { label: 'Mixed / unclassified', confidence: 0.3, reason: 'Camera pattern does not support a more specific description.', melanopic, circadian };
}
diff --git a/js/light-tools-ai-analysis.js b/js/light-tools-ai-analysis.js
index c90aa5ec..f65e3b60 100644
--- a/js/light-tools-ai-analysis.js
+++ b/js/light-tools-ai-analysis.js
@@ -35,6 +35,7 @@ function getRoomNameFor(m) {
export function getMeasurementFingerprint(m) {
if (!m) return '';
const parts = [
+ 'v2-measurement-quality',
m.tool || '',
typeof m.value === 'number' ? Math.round(m.value * 1000) / 1000 : String(m.value || ''),
m.roomId || '',
@@ -52,17 +53,24 @@ export function getMeasurementFingerprint(m) {
const _TOOL_DESCRIPTIONS = {
lux: 'Illuminance reading (general light level at the user\'s position)',
- flicker: 'PWM / mains-flicker scan (5 s) — looking for invisible-but-eyestrain pulses',
- darkness: 'Sleep-darkness long-exposure measurement (30 s mean, peak)',
- cct: 'Correlated color temperature (Kelvin) — warmth vs coolness of the source',
- spectrum: 'Spectrum classifier — categorizes the light source by RGB + flicker profile',
- 'glass-transmission': 'Glass transmission ratio — how much visible light passes through a window',
- audit: 'Eye-level audit — multi-room walkthrough lux snapshot',
+ flicker: 'Rolling-shutter camera screen for visible banding; not a calibrated flicker meter',
+ darkness: 'Qualitative low-light camera check or user-entered photopic lux-meter reading',
+ cct: 'Approximate warm/cool camera estimate; not spectrum or melanopic EDI',
+ spectrum: 'Qualitative camera RGB pattern and banding screen; not a spectrometer',
+ 'glass-transmission': 'Two-sample relative camera-visible comparison through a window',
+ audit: 'Eye-level multi-room camera walkthrough for relative brightness; not lux',
};
function _buildLuxContext(m) {
- const lines = [`Tool: lux meter`, `Reading: ${Math.round(m.value)} lux`];
+ const cameraEstimate = m.extra?.source === 'camera-estimate';
+ const lines = [
+ 'Tool: lux meter',
+ cameraEstimate
+ ? `Reading: ~${Math.round(m.value)} camera-estimated photopic lux; approximate and excluded from indoor scoring`
+ : `Reading: ${Math.round(m.value)} photopic lux`,
+ ];
if (m.extra?.source) lines.push(`Sensor: ${m.extra.source}`);
+ if (m.extra?.source === 'camera-estimate') lines.push(`Camera calibration confirmed: ${m.extra?.calibrationConfirmed === true ? 'yes' : 'no — do not threshold'}`);
if (m.extra?.calibrationFactor && m.extra.calibrationFactor !== 1) {
lines.push(`Calibration factor applied: ×${_formatNumber(m.extra.calibrationFactor, 2)}`);
}
@@ -71,37 +79,41 @@ function _buildLuxContext(m) {
function _buildFlickerContext(m) {
const lines = [`Tool: flicker detector`];
- const SCORE_LABELS = { 0: 'pristine (no detectable flicker)', 1: 'mild', 2: 'moderate', 3: 'severe' };
+ const SCORE_LABELS = { 0: 'no rolling-shutter banding detected', 1: 'some banding', 2: 'clear banding', 3: 'strong banding' };
const score = Math.round(m.value || 0);
lines.push(`Flicker score: ${score}/3 — ${SCORE_LABELS[score] || 'unknown'}`);
if (m.extra?.label) lines.push(`Tool's verdict: ${m.extra.label}`);
if (m.extra?.peakBanding != null) lines.push(`Peak banding (intra-frame): ${_formatNumber(m.extra.peakBanding, 2)}`);
- if (m.extra?.stripes != null) lines.push(`PWM stripe count: ${m.extra.stripes}`);
+ if (m.extra?.stripes != null) lines.push(`Rolling-shutter stripe count: ${m.extra.stripes}`);
if (m.extra?.frameRatio != null) lines.push(`Frame-luma variance: ${_formatNumber(m.extra.frameRatio, 3)}`);
return lines;
}
function _buildDarknessContext(m) {
- const lines = [`Tool: sleep-darkness meter (30 s long exposure)`];
- lines.push(`Mean lux: ${_formatNumber(m.extra?.meanLux ?? m.value, 2)}`);
- if (m.extra?.peakLux != null) lines.push(`Peak lux (95th-percentile spike): ${_formatNumber(m.extra.peakLux, 2)}`);
- if (m.extra?.label) lines.push(`Classifier: ${m.extra.label}`);
- if (m.extra?.isoLocked) lines.push('Camera ISO was locked (higher confidence)');
+ const lines = [`Tool: sleep-light check`];
+ if (m.extra?.method === 'meter-entry') {
+ lines.push(`Meter entry: ${_formatNumber(m.value, 2)} photopic lux (not melanopic EDI)`);
+ } else {
+ lines.push(`Qualitative camera result: ${m.extra?.levelLabel || 'unknown'}`);
+ lines.push(`Camera level: ${_formatNumber(m.extra?.cameraLevel ?? m.value, 0)}%; not lux and not a hormone estimate`);
+ }
return lines;
}
function _buildCCTContext(m) {
- const lines = [`Tool: CCT meter`, `Color temperature: ${Math.round(m.value)} K`];
- if (m.extra?.melanopic != null) lines.push(`Melanopic ratio (B/(R+G+B)): ${_formatNumber(m.extra.melanopic, 2)}`);
+ const lines = [`Tool: camera warm/cool estimate`, `Approximate color temperature: ~${Math.round(m.value / 100) * 100} K`];
+ const blueRatio = m.extra?.cameraBlueRatioProxy ?? m.extra?.melanopic;
+ if (blueRatio != null) lines.push(`Camera RGB blue-ratio proxy (not melanopic EDI): ${_formatNumber(blueRatio, 2)}`);
if (m.extra?.temperatureTone) lines.push(`Tone: ${m.extra.temperatureTone}`);
- if (m.extra?.pwmActive) lines.push('PWM dimming detected during reading');
+ if (m.extra?.bandingDetected || m.extra?.pwmActive) lines.push('Rolling-shutter banding detected during reading; frequency and modulation are unknown');
return lines;
}
function _buildSpectrumContext(m) {
const lines = [`Tool: spectrum classifier`, `Source classification: ${m.value || m.extra?.label || 'unknown'}`];
if (m.extra?.reason) lines.push(`Tool's reasoning: ${m.extra.reason}`);
- if (m.extra?.melanopic != null) lines.push(`Melanopic ratio: ${_formatNumber(m.extra.melanopic, 2)}`);
+ const blueRatio = m.extra?.cameraBlueRatioProxy ?? m.extra?.melanopic;
+ if (blueRatio != null) lines.push(`Camera RGB blue-ratio proxy (not melanopic EDI): ${_formatNumber(blueRatio, 2)}`);
if (m.extra?.circadian) lines.push(`Circadian category: ${m.extra.circadian}`);
if (m.extra?.r != null && m.extra?.g != null && m.extra?.b != null) {
lines.push(`RGB ratios: R=${_formatNumber(m.extra.r, 2)} G=${_formatNumber(m.extra.g, 2)} B=${_formatNumber(m.extra.b, 2)}`);
@@ -110,12 +122,10 @@ function _buildSpectrumContext(m) {
}
function _buildGlassContext(m) {
- const lines = [`Tool: glass transmission test`];
+ const lines = [`Tool: two-sample camera window comparison`];
const pct = Math.round((m.value || 0) * 100);
- lines.push(`Transmission ratio: ${pct}% (${pct}% of outdoor light reaches inside)`);
- if (m.extra?.outside != null) lines.push(`Outdoor lux: ${Math.round(m.extra.outside)}`);
- if (m.extra?.inside != null) lines.push(`Indoor (through-glass) lux: ${Math.round(m.extra.inside)}`);
- if (m.extra?.lockMode === 'manual') lines.push('Camera exposure manually locked (higher confidence)');
+ lines.push(`Relative camera-visible response: about ${pct}%; not calibrated visible, UV, or IR transmission`);
+ if (m.extra?.lockMode !== 'manual') lines.push('Camera auto-exposure was active; treat as qualitative only');
return lines;
}
@@ -124,7 +134,7 @@ function _buildAuditContext(m) {
const rooms = m.extra?.rooms;
if (Array.isArray(rooms) && rooms.length) {
for (const r of rooms.slice(0, 6)) {
- lines.push(` - Room ${r.index}${r.label ? ' (' + r.label + ')' : ''}: ${Math.round(r.lux || 0)} lux`);
+ lines.push(` - Room ${r.index}${r.label ? ' (' + r.label + ')' : ''}: ${r.levelLabel || 'relative'} camera brightness`);
}
}
return lines;
@@ -173,20 +183,20 @@ const SYSTEM_PROMPT = [
'You interpret a single environmental light measurement (lux / flicker / sleep-darkness / CCT / spectrum / glass-transmission / multi-room audit).',
'Return ONLY valid JSON with three keys: {"dot":"green|yellow|red|gray","tip":"string","detail":"string"}.',
'',
- 'Color thresholds by tool type:',
+ 'Interpretation rules by tool type:',
'',
- 'lux: <50 lux daytime → red (sub-circadian); 50–500 lux daytime → yellow; 500+ lux daytime → green. Evening/night flips: anything >5 lux post-sunset trends yellow→red for melatonin onset; <1 lux green.',
- 'flicker: 0 → green; 1 → yellow; 2 → yellow→red; 3 → red.',
- 'darkness (sleep room, captured at night): <0.1 lux → green; 0.1–1 lux → yellow; 1–10 lux → red (clinically significant melatonin suppression); >10 lux → red.',
- 'CCT: warm (1800–2700 K) → green for evening, yellow daytime; cool (4000+ K) → green daytime, red evening. PWM-active flag → yellow regardless.',
- 'spectrum: incandescent / halogen / full-spectrum LED → green; warm LED with PWM, fluorescent → yellow; cool LED in evening → red.',
- 'glass transmission: standard window blocks ~98% UVB, transmits ~70-85% UVA + visible. Note that no UVB passes through standard glass even at 90% visible transmission.',
+ 'lux: ordinary photopic lux is not melanopic EDI. Use sensor or meter-entry values as general brightness spot-checks. All camera estimates, including one-point-calibrated values, stay approximate, excluded from indoor scoring, and gray.',
+ 'flicker: score 0 means no camera banding detected, not flicker-free. Scores 1–3 indicate increasing banding strength; no frequency or health effect is measured.',
+ 'darkness: meter-entry photopic lux can flag visible sleep-time light but remains spectrum-blind. Camera-relative darkness results are qualitative and must never produce melatonin percentages, phase-shift claims, or lux thresholds.',
+ 'CCT: camera estimate is approximate and cannot establish spectral completeness or melanopic content. Time of day changes interpretation, but CCT alone never determines safety.',
+ 'spectrum: camera RGB is a warm/cool pattern, not a spectrometer. Never infer full spectrum, UV, infrared, or missing wavelengths.',
+ 'glass transmission: result is a two-sample camera-visible ratio. It cannot infer calibrated visible, UVA, UVB, or infrared transmission.',
'audit (multi-room): look for room-to-room variation. Bedroom + living-room being near-identical lux suggests over-lit bedrooms or under-lit living spaces.',
'',
...LIGHTING_HARDWARE_CAVEATS,
'',
'tip: one sentence, max 14 words. Reference specific number + concrete action when relevant.',
- 'detail: 1–2 sentences. Cite the threshold or biology that drove the verdict. No restating the data verbatim. If the measurement flags flicker (score 1+) or PWM, the recommendation MUST honor the hardware caveats above — never suggest a generic "dimmable LED" or "dim it" as a fix.',
+ 'detail: 1–2 sentences. State measurement quality before interpretation. Never convert ordinary lux/CCT/RGB into melanopic dose, hormone suppression, or guaranteed sleep effects. If banding is flagged, honor the hardware caveats and never suggest a generic dimmer.',
'',
'No "you should" — be observational. No emoji.',
].join('\n');
@@ -202,7 +212,7 @@ const engine = createAIVerdict({
maxTokens: 350,
// Skip the audit aggregate row — its per-room lux entries get analyzed
// on their own (saveMeasurement fires once per pause).
- shouldAutoFire: (m) => m?.tool !== 'audit',
+ shouldAutoFire: (m) => !['audit', 'brightness-proxy'].includes(m?.tool),
getAllTargets: getMeasurements,
// Anchor the post-verdict rebuild to the row's room so the user
// stays put when the verdict lands. Portable readings (no roomId)
diff --git a/js/light-tools-solar-time.js b/js/light-tools-solar-time.js
new file mode 100644
index 00000000..8280fa31
--- /dev/null
+++ b/js/light-tools-solar-time.js
@@ -0,0 +1,58 @@
+// @ts-check
+
+export function computeSunriseSunset(coords, date, solarZenithAngle) {
+ if (!coords || typeof solarZenithAngle !== 'function') return { sunrise: null, sunset: null };
+ const baseDate = date ? new Date(date) : new Date();
+ const day = new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate());
+ let sunrise = null;
+ let sunset = null;
+ let prevAbove = null;
+ for (let minutes = 0; minutes < 24 * 60; minutes += 5) {
+ const time = new Date(day.getTime() + minutes * 60_000);
+ const zenith = solarZenithAngle(time, coords.lat, coords.lon);
+ if (!Number.isFinite(zenith)) continue;
+ const above = zenith < 90.83;
+ if (prevAbove != null && above !== prevAbove) {
+ if (above && !sunrise) sunrise = time;
+ else if (!above && !sunset) sunset = time;
+ }
+ prevAbove = above;
+ }
+ return { sunrise, sunset };
+}
+
+export function classifyDayWindow(coords, now, solarZenithAngle) {
+ const time = now || new Date();
+ const { sunrise, sunset } = computeSunriseSunset(coords, time, solarZenithAngle);
+ if (!sunrise || !sunset) {
+ const hour = time.getHours();
+ let label = 'Outside golden hour';
+ if (hour >= 5 && hour < 9) label = 'Sunrise window';
+ else if (hour >= 16 && hour < 21) label = 'Sunset window';
+ return { kind: 'unknown', label, sunrise: null, sunset: null };
+ }
+ const timestamp = time.getTime();
+ const sunriseMs = sunrise.getTime();
+ const sunsetMs = sunset.getTime();
+ if (timestamp >= sunriseMs - 30 * 60_000 && timestamp <= sunriseMs + 90 * 60_000) {
+ return { kind: 'sunrise', label: 'Sunrise window', sunrise, sunset };
+ }
+ if (timestamp >= sunsetMs - 90 * 60_000 && timestamp <= sunsetMs + 30 * 60_000) {
+ return { kind: 'sunset', label: 'Sunset window', sunrise, sunset };
+ }
+ if (timestamp > sunriseMs && timestamp < sunsetMs) {
+ return { kind: 'midday', label: 'Midday — past sunrise, before sunset', sunrise, sunset };
+ }
+ return { kind: 'night', label: 'Night — sun is below horizon', sunrise, sunset };
+}
+
+export function formatSunClock(date) {
+ if (!date) return '—';
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+export function normalizeGoldenHourMinutes(value) {
+ const parsed = parseInt(value, 10);
+ if (!Number.isFinite(parsed)) return 15;
+ return Math.min(120, Math.max(1, parsed));
+}
diff --git a/js/light-tools.js b/js/light-tools.js
index ee8eee77..a98311af 100644
--- a/js/light-tools.js
+++ b/js/light-tools.js
@@ -11,8 +11,10 @@ import { escapeHTML, escapeAttr, queryRequired, showNotification } from './utils
import { openAppendedModalOverlay, removeModalOverlay } from './modal-lifecycle.js';
import { saveImportedData } from './data.js';
import { deleteImportedArrayItem } from './data-merge.js';
-import { aimingGuideHTML, getRequired2DContext, lockCameraForMeasurement, loadLuxCalibration } from './light-tool-camera.js';
+import { aimingGuideHTML, getRequired2DContext, lockCameraForMeasurement } from './light-tool-camera.js';
import { createUniqueId } from './unique-id.js';
+import { classifyDayWindow, formatSunClock, normalizeGoldenHourMinutes } from './light-tools-solar-time.js';
+export { normalizeGoldenHourMinutes } from './light-tools-solar-time.js';
/** @typedef {typeof import('./light-tool-camera-modals.js')} LightToolCameraModals */
/** @type {Promise | null} */ let lightToolCameraModalsPromise = null;
/** @type {LightToolCameraModals | null} */ let lightToolCameraModals = null;
@@ -116,7 +118,9 @@ export {
lockCameraForMeasurement,
cameraLockStatusLine,
computeRowBanding,
+ clearLuxCalibration,
dismissAimingGuide,
+ isLuxCalibrationConfirmed,
loadLuxCalibration,
saveLuxCalibration,
} from './light-tool-camera.js';
@@ -244,7 +248,7 @@ export async function saveMeasurement(tool, value, opts = {}) {
// the classifier knows warm vs cool vs fluorescent. Only fires when
// a roomId is bound; only updates when source is unset/unknown.
if (tool === 'spectrum' && opts.roomId) {
- try { await lightToolsDeps.suggestRoomSourceFromSpectrum(opts.roomId, value); } catch (e) {}
+ try { await lightToolsDeps.suggestRoomSourceFromSpectrum(opts.roomId, value, entry.extra); } catch (e) {}
}
refreshLightEnvironmentAssessment();
// Re-render the Light & Sun page if the user is on it so per-room
@@ -335,91 +339,22 @@ export const closeLuxMeter = closeCameraToolIfLoaded('closeLuxMeter'), closeFlic
// ─── Tool 7: Sunrise / Sunset Logger ──────────────────────────────────
-// Compute today's sunrise / sunset (sun at 90.83° zenith — the standard
-// definition accounting for atmospheric refraction at the horizon) for
-// the user's coords. Walks the day in 5-minute steps from the previous
-// midnight; returns null when the sun never rises or never sets at the
-// given latitude on the given date (high-latitude polar day/night).
-function _computeSunriseSunset(coords, date) {
- const solarZenithAngle = lightToolsDeps.solarZenithAngle;
- if (!coords || typeof solarZenithAngle !== 'function') return { sunrise: null, sunset: null };
- const baseDate = date ? new Date(date) : new Date();
- const day = new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate());
- const STEP_MIN = 5;
- let sunrise = null, sunset = null;
- let prevAbove = null;
- for (let m = 0; m < 24 * 60; m += STEP_MIN) {
- const t = new Date(day.getTime() + m * 60_000);
- const zenith = solarZenithAngle(t, coords.lat, coords.lon);
- if (!Number.isFinite(zenith)) continue;
- const above = zenith < 90.83; // sun above horizon (refraction-corrected)
- if (prevAbove != null && above !== prevAbove) {
- if (above && !sunrise) sunrise = t;
- else if (!above && !sunset) sunset = t;
- }
- prevAbove = above;
- }
- return { sunrise, sunset };
-}
-
-// Window classification from now-vs-sunrise/sunset. Returns:
-// { kind: 'sunrise'|'sunset'|'midday'|'night'|'pre-sunrise',
-// label: , sunrise: Date|null, sunset: Date|null }
-// "Golden hour" definitions: sunrise window = 30 min before to 90 min
-// after sunrise; sunset window = 90 min before to 30 min after sunset.
-function _classifyDayWindow(coords, now) {
- const t = now || new Date();
- const { sunrise, sunset } = _computeSunriseSunset(coords, t);
- if (!sunrise || !sunset) {
- // Polar day/night or no coords — fall back to hour heuristic.
- const hr = t.getHours();
- let label = 'Outside golden hour';
- if (hr >= 5 && hr < 9) label = 'Sunrise window';
- else if (hr >= 16 && hr < 21) label = 'Sunset window';
- return { kind: 'unknown', label, sunrise: null, sunset: null };
- }
- const ms = t.getTime();
- const srMs = sunrise.getTime(), ssMs = sunset.getTime();
- // Sunrise window: 30 min before sunrise → 90 min after sunrise
- if (ms >= srMs - 30 * 60_000 && ms <= srMs + 90 * 60_000) {
- return { kind: 'sunrise', label: 'Sunrise window', sunrise, sunset };
- }
- // Sunset window: 90 min before sunset → 30 min after sunset
- if (ms >= ssMs - 90 * 60_000 && ms <= ssMs + 30 * 60_000) {
- return { kind: 'sunset', label: 'Sunset window', sunrise, sunset };
- }
- // Midday vs night
- if (ms > srMs && ms < ssMs) return { kind: 'midday', label: 'Midday — past sunrise, before sunset', sunrise, sunset };
- return { kind: 'night', label: 'Night — sun is below horizon', sunrise, sunset };
-}
-
-function _fmtClock(d) {
- if (!d) return '—';
- return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
-}
-
-export function normalizeGoldenHourMinutes(value) {
- const parsed = parseInt(value, 10);
- if (!Number.isFinite(parsed)) return 15;
- return Math.min(120, Math.max(1, parsed));
-}
-
export function openSunriseLogger() {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay light-tool-overlay';
const coords = getSunCoords();
- const cls = _classifyDayWindow(coords, new Date());
+ const cls = classifyDayWindow(coords, new Date(), lightToolsDeps.solarZenithAngle);
const subtitleHtml = cls.kind === 'unknown'
? `No location coords — set country in profile for accurate sunrise/sunset windows.`
: (cls.sunrise && cls.sunset)
- ? `today: sunrise ${_fmtClock(cls.sunrise)} · sunset ${_fmtClock(cls.sunset)}`
+ ? `today: sunrise ${formatSunClock(cls.sunrise)} · sunset ${formatSunClock(cls.sunset)}`
: '';
// CTA copy adapts to the actual window we're in. Outside golden hour
// we can still log a session but flag it so the user knows.
const inGolden = cls.kind === 'sunrise' || cls.kind === 'sunset';
const headerHint = inGolden
- ? `Quick log for golden-hour outdoor light. Eye exposure is automatic — circadian channel maxed for the duration.`
- : `It's ${escapeHTML(cls.label.toLowerCase())} right now — golden-hour benefits don't apply, but you can still log this as a regular outdoor session.`;
+ ? `Quick log for ambient golden-hour outdoor light. The modeled eye channel uses the logged duration; never look directly at the sun.`
+ : `It's ${escapeHTML(cls.label.toLowerCase())} right now. You can still log this as a regular outdoor-light session; the engine will use the actual solar angle.`;
overlay.innerHTML = `
`).join('');
@@ -564,7 +504,9 @@ export async function openEyeLevelAudit() {
// brightness signal, not the camera-corrected one.
const lock = await lockCameraForMeasurement(stream);
if (lock.exposure !== 'manual') {
- statusEl.innerHTML = `Recording… ⚠ camera auto-exposure on — per-room values will be relative, not absolute lux.`;
+ statusEl.innerHTML = `Recording… ⚠ camera auto-exposure is on — room levels are rough comparisons only.`;
+ } else {
+ statusEl.innerHTML = `Recording… Exposure is held so rooms can be compared. The walkthrough saves relative brightness—not lux.`;
}
const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 24;
const ctx = getRequired2DContext(canvas);
@@ -590,8 +532,10 @@ export async function openEyeLevelAudit() {
if (!pauseStart) pauseStart = t;
else if (t - pauseStart > 5000) {
// Mark a pause snapshot
- const lux = Math.max(0, luma * 40 * loadLuxCalibration());
- pauseDetections.push({ at: t, luma, lux, label: '' });
+ const cameraLevel = Math.min(100, Math.max(0, luma / 255 * 100));
+ const lux = null;
+ const levelLabel = cameraLevel < 20 ? 'Dimmer' : cameraLevel < 55 ? 'Medium' : 'Brighter';
+ pauseDetections.push({ at: t, luma, cameraLevel, lux, levelLabel, label: '' });
renderAuditList();
pauseStart = null;
waitingForMovement = true;
@@ -624,8 +568,10 @@ export async function openEyeLevelAudit() {
extra: { rooms: pauseDetections.map((p, i) => ({
index: i + 1,
lux: p.lux,
+ cameraLevel: p.cameraLevel,
+ levelLabel: p.levelLabel,
label: (p.label || '').trim() || `Room ${i + 1}`,
- })) },
+ })), method: 'relative-camera-walkthrough' },
});
// Try to bind each pause to an existing room by name; create
// one if no match. Startup wiring injects getRooms/addRoom from
@@ -647,18 +593,18 @@ export async function openEyeLevelAudit() {
if (roomId) byLabel.set(label.toLowerCase(), roomId);
} catch (e) {}
}
- if (roomId && typeof p.lux === 'number') {
- await saveMeasurement('lux', p.lux, {
+ if (roomId) {
+ await saveMeasurement('brightness-proxy', p.cameraLevel, {
roomId,
- confidence: 0.5,
- extra: { source: 'eye-level-audit', auditPauseIndex: i + 1 },
+ confidence: 0.25,
+ extra: { method: 'relative-camera-walkthrough', levelLabel: p.levelLabel, auditPauseIndex: i + 1 },
});
bound++;
}
}
const labeled = pauseDetections.filter(p => (p.label || '').trim()).length;
const labelNote = labeled > 0
- ? ` (${labeled}/${pauseDetections.length} labeled, ${bound} written to rooms)`
+ ? ` (${labeled}/${pauseDetections.length} labeled, ${bound} attached to rooms as relative brightness)`
: '';
showNotification(`Audit saved · ${pauseDetections.length} room snapshots${labelNote}.`);
} else {
@@ -684,31 +630,31 @@ export function renderLightTools() {
spectrum: {
icon: '🔬',
name: 'What is this light?',
- desc: 'Classify LED, fluorescent, daylight, or incandescent and estimate melanopic load.',
- short: 'Bulb type + spectrum',
+ desc: 'Screen the camera RGB pattern as warm, cool, or mixed without calling it a spectrum.',
+ short: 'Warm / cool pattern',
},
lux: {
icon: '📏',
name: 'Lux meter',
- desc: 'Measure room brightness with daylight comparison and per-device calibration.',
+ desc: 'Measure photopic lux from a built-in sensor, meter entry, or a device-calibrated camera.',
short: 'Brightness baseline',
},
cct: {
icon: '🎨',
name: 'Color temp',
- desc: 'Check warm/cool kelvin, solar-time match, and dimming warning signs.',
+ desc: 'Get an approximate warm/cool camera estimate and screen for visible banding.',
short: 'Warm vs cool',
},
flicker: {
icon: '⚡',
name: 'Flicker detector',
- desc: 'Find PWM and rolling-shutter banding up to 25 kHz.',
- short: 'PWM risk',
+ desc: 'Screen for rolling-shutter banding; absence does not prove flicker-free output.',
+ short: 'Banding screen',
},
darkness: {
icon: '🌙',
name: 'Sleep darkness',
- desc: 'Measure mean and peak lux at the pillow.',
+ desc: 'Run a qualitative low-light camera check or enter a meter reading at the pillow.',
short: 'Bedroom night check',
},
glass: {
@@ -720,7 +666,7 @@ export function renderLightTools() {
audit: {
icon: '🚶',
name: 'Home audit',
- desc: 'Walk through rooms and capture a per-room snapshot in about 10 minutes.',
+ desc: 'Walk through rooms for relative brightness with one held camera exposure.',
short: 'Room sweep',
},
golden: {
@@ -746,8 +692,8 @@ export function renderLightTools() {
const next = [
{ id: 'lux', reason: 'Set brightness baseline', primary: true },
- { id: 'flicker', reason: 'Rule out PWM risk' },
- { id: 'spectrum', reason: 'Identify the light source' },
+ { id: 'flicker', reason: 'Screen for visible banding' },
+ { id: 'spectrum', reason: 'Check warm / cool pattern' },
];
const statusChips = total > 0
diff --git a/js/lighting-hardware-caveats.js b/js/lighting-hardware-caveats.js
index 30871c5b..429b7421 100644
--- a/js/lighting-hardware-caveats.js
+++ b/js/lighting-hardware-caveats.js
@@ -3,20 +3,17 @@
// lighting-hardware-caveats.js — load-bearing prompt block shared by
// every Light & Sun AI surface that recommends fixtures or dimming.
//
-// Without this, the model cheerfully suggests "dimmable LED" as the fix
-// for a room with measured flicker — except dimmable LEDs are the #1
-// source of household PWM flicker, so the recommendation IS the cause.
-// One block, one import, every prompt stays consistent.
+// Keeps fixture advice cautious when the available evidence is only a
+// camera-banding screen or a user-entered room description.
export const LIGHTING_HARDWARE_CAVEATS = [
- 'Lighting hardware caveats (load-bearing — never violate when recommending fixtures):',
- ' • DIMMABLE LEDs are the #1 source of household PWM flicker. The cheap path to LED dimming is pulse-width modulation, which is exactly what flicker scoring measures. NEVER recommend a generic "dimmable LED" — especially on a room or measurement where flicker is already flagged 1+. If dimming is truly required, recommend the CATEGORY (e.g. "DC-dimmable LED" or "high-frequency-PWM driver, >2 kHz") or describe the qualifier the user should look for ("flicker-free, CCR-dimmable, or filament-style at fixed low warmth 2000–2400K"). NEVER name a specific brand or product in your tip or detail — categories only.',
- ' • TRIAC wall dimmers + LED bulb is the worst-case combination — the dimmer chops the AC waveform and the LED driver re-clamps it, often producing visible AND invisible flicker even on bulbs labelled "dimmable."',
- ' • Smart bulbs typically dim via PWM internally — measure before assuming they\'re flicker-free at low brightness. Some premium tunable lines from established lighting brands handle low brightness without visible flicker; many cheaper smart bulbs do not.',
- ' • If flicker is 1+, prefer NON-DIMMING fixes: swap a cool bulb for a warm fixed-output bulb, install multiple lower-wattage warm bulbs on separate switches (so "dim" is achieved by turning off some), use candles / salt lamps for the lowest evening setting, or specify INCANDESCENT / HALOGEN as the bedside fixture (no flicker, full spectrum, dimmable without PWM).',
- ' • "Soft white" / "warm white" labels are color-temperature claims (typically 2700-3000K) and say NOTHING about flicker, CRI, or melanopic content. Don\'t treat the label as a flicker fix.',
- ' • "Tunable" LEDs typically blend two LED dies (warm + cool) at the same brightness. Setting them to "warm" reduces blue but does NOT make them dim; pairing with a dimmer reintroduces PWM.',
- ' • For sleep rooms specifically, the strongest fix is usually source REPLACEMENT (warm + low-wattage + non-dimming) + LIGHT-BLOCKING (blackout curtains, taping LED indicators on chargers/clocks), not dimmer installation.',
+ 'Lighting-hardware guardrails:',
+ ' • A camera-banding result can flag a pattern worth checking, but it cannot identify flicker frequency, modulation depth, or health risk. Recommend a purpose-built flicker meter before a strong conclusion.',
+ ' • Some LED drivers and dimmer combinations produce temporal light modulation, and performance can change with brightness. Do not assume that every dimmable LED, smart bulb, TRIAC dimmer, incandescent, or halogen source is flicker-free.',
+ ' • If banding is repeatable, suggest simple comparisons first: full versus reduced brightness, dimmer bypassed versus engaged, and a different known fixture. Recommend checking product flicker specifications or measuring with an appropriate meter.',
+ ' • "Soft white", "warm white", CCT, CRI, and "full spectrum" labels do not establish flicker performance or melanopic EDI. Brightness, spectrum, timing, distance, and fixture electronics all matter.',
+ ' • For evening rooms, suggest lower eye-level brightness, less direct light, and reducing light leakage as practical options. Never recommend an open flame or an unverified product as a health intervention.',
+ ' • Do not name a specific brand or diagnose symptoms from a room description or camera check.',
];
// Convenience joined string for prompts that just splice as a single block.
diff --git a/js/profile.js b/js/profile.js
index 8791334d..c4a19b9c 100644
--- a/js/profile.js
+++ b/js/profile.js
@@ -3,7 +3,6 @@
import { state } from './state.js';
import { COUNTRY_LATITUDES, LATITUDE_BANDS } from './constants.js';
-import { callClaudeAPI } from './api.js';
import { isDebugMode, showConfirmDialog, showNotification } from './utils.js';
import { encryptedSetItem, encryptedGetItem, getEncryptionEnabled, isUnlocked } from './crypto.js';
import { migrateProfileData } from './profile-data-migrations.js';
@@ -22,8 +21,8 @@ export { migrateProfileData, profileStorageKey };
/** @type {Record any>} */
const profileDeps = {
- callClaudeAPI,
deleteProfileFromRelay: async () => {},
+ fetchImpl: (input, init) => fetch(input, init),
isDebugMode,
onProfileSaved: async () => {},
pushContextToGateway: async () => {},
@@ -34,8 +33,8 @@ const profileDeps = {
export function configureProfileDeps(deps = {}) {
const previous = { ...profileDeps };
const previousStoreDeps = configureProfileListStoreDeps(deps);
- if (typeof deps.callClaudeAPI === 'function') profileDeps.callClaudeAPI = deps.callClaudeAPI;
if (typeof deps.deleteProfileFromRelay === 'function') profileDeps.deleteProfileFromRelay = deps.deleteProfileFromRelay;
+ if (typeof deps.fetchImpl === 'function') profileDeps.fetchImpl = deps.fetchImpl;
if (typeof deps.isDebugMode === 'function') profileDeps.isDebugMode = deps.isDebugMode;
if (typeof deps.onProfileSaved === 'function') profileDeps.onProfileSaved = deps.onProfileSaved;
if (typeof deps.pushContextToGateway === 'function') profileDeps.pushContextToGateway = deps.pushContextToGateway;
@@ -618,17 +617,48 @@ export async function setProfileHeight(profileId, height, unit) {
return changed;
}
-// AI-powered latitude detection with hardcoded fallback
+// Privacy-rounded home-area resolution; the legacy export name is retained.
/**
- * @returns {Record}
+ * @returns {Record}
*/
export function getLocationCache() { try { return JSON.parse(localStorage.getItem('labcharts-location-cache') || '{}'); } catch(e) { return {}; } }
/**
* @param {string} key
- * @param {number} lat
+ * @param {any} value
* @returns {void}
*/
-export function setLocationCache(key, lat) { var c = getLocationCache(); c[key] = lat; try { localStorage.setItem('labcharts-location-cache', JSON.stringify(c)); } catch(e) {} }
+export function setLocationCache(key, value) { var c = getLocationCache(); c[key] = value; try { localStorage.setItem('labcharts-location-cache', JSON.stringify(c)); } catch(e) {} }
+
+function cachedLatitude(value) {
+ if (Number.isFinite(value)) return Number(value);
+ const latitude = Number(value?.lat ?? value?.latitude);
+ return Number.isFinite(latitude) ? latitude : null;
+}
+
+/**
+ * @param {string} [optCountry]
+ * @param {string} [optZip]
+ * @returns {{ lat: number, lon: number, accuracyKm: number | null, timezone: string | null, label: string, resolvedAt: number | null, source: string } | null}
+ */
+export function getResolvedProfileCoords(optCountry, optZip) {
+ const loc = getProfileLocation();
+ const country = (optCountry !== undefined ? optCountry : loc.country || '').trim();
+ const zip = (optZip !== undefined ? optZip : loc.zip || '').trim();
+ if (!country || !zip) return null;
+ const cached = getLocationCache()[`${country}|${zip}`.toLowerCase()];
+ const lat = cachedLatitude(cached);
+ const lon = Number(cached?.lon ?? cached?.longitude);
+ if (lat == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return null;
+ return {
+ lat,
+ lon,
+ accuracyKm: Number.isFinite(Number(cached?.accuracyKm)) ? Number(cached.accuracyKm) : null,
+ timezone: typeof cached?.timezone === 'string' ? cached.timezone : null,
+ label: typeof cached?.label === 'string' ? cached.label : '',
+ resolvedAt: Number.isFinite(Number(cached?.resolvedAt)) ? Number(cached.resolvedAt) : null,
+ source: 'home-postal',
+ };
+}
/**
* @param {number} lat
* @returns {number}
@@ -642,17 +672,35 @@ export function latitudeToBand(lat) { var a = Math.abs(lat); if (a < 25) return
*/
export async function detectLatitudeWithAI(country, zip) {
var cacheKey = (country + '|' + zip).toLowerCase();
- if (getLocationCache()[cacheKey] !== undefined) return;
+ const cached = getLocationCache()[cacheKey];
+ if (cached && typeof cached === 'object'
+ && Number.isFinite(Number(cached.lat ?? cached.latitude))
+ && Number.isFinite(Number(cached.lon ?? cached.longitude))) return;
+ if (!String(country || '').trim() || !String(zip || '').trim()) return;
try {
- var locationStr = zip ? country + ' ' + zip : country;
- var { text: response } = await profileDeps.callClaudeAPI({
- system: 'You are a geography assistant. Reply with ONLY a number \u2014 the approximate latitude in decimal degrees (positive for North, negative for South). No text, no degree symbol, just the number.',
- messages: [{ role: 'user', content: 'Latitude of: ' + locationStr }],
- maxTokens: 10
+ const response = await profileDeps.fetchImpl('/api/proxy', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ meteo: 'postal_geocode',
+ country: String(country).trim(),
+ postalCode: String(zip).trim(),
+ }),
});
- var lat = parseFloat((response || '').trim());
- if (!isNaN(lat) && lat >= -90 && lat <= 90) {
- setLocationCache(cacheKey, lat);
+ if (!response.ok) return;
+ const resolved = await response.json();
+ const lat = Number(resolved?.latitude);
+ const lon = Number(resolved?.longitude);
+ if (Number.isFinite(lat) && Number.isFinite(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
+ setLocationCache(cacheKey, {
+ lat,
+ lon,
+ accuracyKm: Number.isFinite(Number(resolved?.accuracyKm)) ? Number(resolved.accuracyKm) : 11,
+ timezone: typeof resolved?.timezone === 'string' ? resolved.timezone : null,
+ label: typeof resolved?.label === 'string' ? resolved.label : '',
+ source: 'postal-area',
+ resolvedAt: Number.isFinite(Number(resolved?.resolvedAt)) ? Number(resolved.resolvedAt) : Date.now(),
+ });
var el = document.getElementById('loc-lat-display');
if (el) {
var band = latitudeToBand(lat);
@@ -661,7 +709,7 @@ export async function detectLatitudeWithAI(country, zip) {
}
}
} catch(e) {
- if (profileDeps.isDebugMode()) console.warn('[Location] AI detection failed:', e);
+ if (profileDeps.isDebugMode()) console.warn('[Location] postal-area resolution failed:', e);
}
}
@@ -677,13 +725,11 @@ export function getLatitudeFromLocation(optCountry, optZip) {
const c = country.toLowerCase().trim();
const zip = (optZip !== undefined ? optZip : loc.zip || '').trim();
- // AI cache (most accurate — covers any country/ZIP worldwide)
var cacheKey = (c + '|' + zip).toLowerCase();
- var aiCached = getLocationCache()[cacheKey];
- if (aiCached !== undefined) return LATITUDE_BANDS[latitudeToBand(aiCached)];
+ var aiCached = cachedLatitude(getLocationCache()[cacheKey]);
+ if (aiCached !== null) return LATITUDE_BANDS[latitudeToBand(aiCached)];
var zn = zip.replace(/\s/g, '');
- // ZIP refinement for USA (first digit = region, special prefixes for HI/AK/PR)
if (zn && (c === 'usa' || c === 'us' || c === 'united states' || c === 'america')) {
var p3 = zn.substring(0, 3);
if (p3 >= '006' && p3 <= '009') return LATITUDE_BANDS[0]; // PR/VI → tropical
@@ -694,53 +740,43 @@ export function getLatitudeFromLocation(optCountry, optZip) {
if (usb[d] !== undefined) return LATITUDE_BANDS[usb[d]];
}
- // ZIP refinement for Canada (first letter = province/territory)
if (zn && (c === 'canada' || c === 'ca')) {
var letter = zn.charAt(0).toUpperCase();
var cab = { 'A':3,'B':2,'C':2,'E':2, 'G':2,'H':2,'J':2,'K':2,'L':2,'M':2,'N':2, 'P':3,'R':3,'S':3,'T':3, 'V':2, 'X':4,'Y':4 };
if (cab[letter] !== undefined) return LATITUDE_BANDS[cab[letter]];
}
- // ZIP refinement for European countries
var zd = zn.charAt(0);
- // Norway (4-digit): 0-5 southern ~58-60°N → northern, 6-9 central/north ~62-71°N → subarctic
if (zn && (c === 'norway' || c === 'norge')) {
if (zd >= '0' && zd <= '5') return LATITUDE_BANDS[3];
return LATITUDE_BANDS[4];
}
- // Sweden (5-digit): 1-6 southern/central ~55-60°N → northern, 7-9 north ~62-69°N → subarctic
if (zn && (c === 'sweden' || c === 'sverige')) {
if (zd >= '1' && zd <= '6') return LATITUDE_BANDS[3];
if (zd >= '7') return LATITUDE_BANDS[4];
}
- // Finland (5-digit): 00-39 southern ~60°N → northern, 40-99 central/north ~62-70°N → subarctic
if (zn && (c === 'finland' || c === 'suomi')) {
var f2 = parseInt(zn.substring(0, 2));
if (!isNaN(f2)) return LATITUDE_BANDS[f2 < 40 ? 3 : 4];
}
- // Germany (5-digit): 0-6 northern/central ~50-54°N → northern, 7-9 southern ~48-50°N → temperate
if (zn && (c === 'germany' || c === 'deutschland')) {
if (zd >= '7') return LATITUDE_BANDS[2];
return LATITUDE_BANDS[3];
}
- // Italy (5-digit): 00-79 central/north ~41-47°N → temperate, 80-98 south/islands ~36-41°N → subtropical
if (zn && (c === 'italy' || c === 'italia')) {
var i2 = parseInt(zn.substring(0, 2));
if (!isNaN(i2)) return LATITUDE_BANDS[i2 >= 80 ? 1 : 2];
}
- // Spain (5-digit): northern provinces ~43°N → temperate, rest → subtropical
if (zn && (c === 'spain' || c === 'españa' || c === 'espana')) {
var s2 = parseInt(zn.substring(0, 2));
if (!isNaN(s2) && (s2 >= 15 && s2 <= 16 || s2 >= 20 && s2 <= 24 || s2 >= 26 && s2 <= 28 || s2 >= 31 && s2 <= 34 || s2 >= 39 && s2 <= 50)) return LATITUDE_BANDS[2];
return LATITUDE_BANDS[1];
}
- // France (5-digit): mostly temperate, northern departments ~50°N → borderline northern
if (zn && (c === 'france')) {
var fr2 = parseInt(zn.substring(0, 2));
if (!isNaN(fr2) && (fr2 >= 59 && fr2 <= 62 || fr2 === 80 || fr2 === 2)) return LATITUDE_BANDS[3];
return LATITUDE_BANDS[2];
}
- // Russia (6-digit): default northern, 350-385 south → temperate, 163/183-184 Murmansk → subarctic
if (zn && (c === 'russia' || c === 'россия' || c === 'rossiya')) {
var r3 = parseInt(zn.substring(0, 3));
if (!isNaN(r3)) {
@@ -750,7 +786,6 @@ export function getLatitudeFromLocation(optCountry, optZip) {
return LATITUDE_BANDS[3];
}
- // Country-level lookup
const band = COUNTRY_LATITUDES[c];
if (band !== undefined) return LATITUDE_BANDS[band];
for (const [key, val] of Object.entries(COUNTRY_LATITUDES)) {
diff --git a/js/settings-privacy.js b/js/settings-privacy.js
index f115706f..e72155e0 100644
--- a/js/settings-privacy.js
+++ b/js/settings-privacy.js
@@ -113,12 +113,11 @@ export function renderSunDataSourceSettings() {
const cfg = getSettingsMeteoConfig();
return `
☀ Sun data source
-
Where the Light & Sun lens fetches UV / ozone / atmosphere data. Lat/lon defaults to your country (no automatic geolocation). Manual entry always works.
+
Where the Light & Sun lens fetches UV, ozone, clouds, and air-quality model data. It uses your privacy-rounded home postal area or country by default. Current device location is optional, rounded locally, and kept only for today.
- ${_renderMeteoModeOption('auto', 'Default — best accuracy', 'Real ozone + aerosols from CAMS, clouds + temperature from Open-Meteo, automatically merged. Falls back to Open-Meteo only if CAMS is unreachable. Pick this unless you have a specific reason not to.')}
- ${_renderMeteoModeOption('open-meteo', 'Open-Meteo only', 'Skip CAMS. Slightly noisier UV math (no real ozone DU), but only one upstream sees your lat/lon. Faster too.')}
+ ${_renderMeteoModeOption('auto', 'Default — CAMS enhanced', 'Direct CAMS UV plus CAMS ozone and aerosols, with recent satellite cloud correction where coverage exists and Open-Meteo weather context. Falls back to Open-Meteo UVI when CAMS is unavailable.')}
+ ${_renderMeteoModeOption('open-meteo', 'Open-Meteo only', 'Skip the CAMS relay. Uses Open-Meteo model UVI and weather, without CAMS total-column ozone or satellite cloud enhancement.')}
${_renderMeteoModeOption('selfhost', 'Self-hosted server', 'You run your own getbased-uvdata box. Lat/lon never leaves your infrastructure. Paste the URL + bearer below.')}
- ${_renderMeteoModeOption('manual', 'UV meter / manual entry', 'Type the UV index yourself per session — most accurate if you own a UV meter (Solarmeter 6.5R, Hocoma, EMR-Tek). No network calls at all.')}
diff --git a/js/startup-maintenance.js b/js/startup-maintenance.js
index 3cb37b00..698fb076 100644
--- a/js/startup-maintenance.js
+++ b/js/startup-maintenance.js
@@ -68,11 +68,18 @@ function hydrateUserLightDevicesFromPresets() {
// / Trinity device records have no `modes` array, so the session-log
// dialog can't render the mode picker for them. Idempotent - re-runs
// are no-ops once devices carry the fields.
- if (!state.importedData?.lightDevices?.length) return;
+ // Sessions keep a device snapshot, so stale history can still be repaired
+ // after the user removes the live device from their library.
+ if (!state.importedData?.lightDevices?.length && !state.importedData?.deviceSessions?.length) return;
import('./light-devices.js')
- .then(({ hydrateDevicesFromPresets }) => hydrateDevicesFromPresets())
- .then(dirty => {
+ .then(async ({ hydrateDevicesFromPresets, rehydrateStaleDeviceSessions }) => {
+ const dirty = await hydrateDevicesFromPresets();
+ const sessions = await rehydrateStaleDeviceSessions();
+ return { dirty, sessions };
+ })
+ .then(({ dirty, sessions }) => {
if (dirty) logStartupMaintenanceRuntime('[light] hydrated user devices from preset library');
+ if (sessions?.rehydrated) logStartupMaintenanceRuntime('[light] self-healed', sessions.rehydrated, 'device session(s)');
})
.catch(() => {});
}
diff --git a/js/sun-active-session-format.js b/js/sun-active-session-format.js
new file mode 100644
index 00000000..8929dcbe
--- /dev/null
+++ b/js/sun-active-session-format.js
@@ -0,0 +1,45 @@
+// @ts-check
+
+export function formatElapsed(ms) {
+ const totalSec = Math.max(0, Math.floor(ms / 1000));
+ const hours = Math.floor(totalSec / 3600);
+ const minutes = Math.floor((totalSec % 3600) / 60);
+ const seconds = totalSec % 60;
+ const pad = value => String(value).padStart(2, '0');
+ return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
+}
+
+export function activeElapsedMs(session, now = Date.now()) {
+ const currentPause = session?.paused && Number.isFinite(session?.pausedAt)
+ ? Math.max(0, now - session.pausedAt)
+ : 0;
+ return Math.max(0, now - (session?.startedAt || now) - (session?.accumulatedPausedMs || 0) - currentPause);
+}
+
+export function plainStopSummary(session, durationMin, options = {}) {
+ if (!session) return `Session saved — ${durationMin} min`;
+ const parts = [`Saved · ${durationMin} min outside`];
+ const fitzpatrick = session.safety?.fitzpatrick || 'I';
+ const uvIndex = session.atmosphere?.uvIndex;
+ const vitaminDAu = session.doses?.vitamin_d || 0;
+ if (vitaminDAu > 0 && typeof options.vitaminDIU === 'function') {
+ const bodyFraction = session.bodyExposure?.fraction;
+ const estimate = Number.isFinite(bodyFraction) && bodyFraction > 0 && typeof options.vitaminDIUPerSession === 'function'
+ ? options.vitaminDIUPerSession(vitaminDAu, fitzpatrick, uvIndex, !!session.bodyExposure?.rotatedSides, options.genetics || null, bodyFraction)
+ : options.vitaminDIU(vitaminDAu, fitzpatrick, uvIndex, !!session.bodyExposure?.rotatedSides, options.genetics || null);
+ if (estimate >= 100) {
+ const low = Math.round(estimate * 0.25 / 50) * 50;
+ const high = Math.round(estimate * 2 / 50) * 50;
+ parts.push(`~${low}–${high} IU-equivalent vitamin D estimate`);
+ }
+ } else if (session.bodyExposure?.glassBetween) {
+ parts.push('negligible modeled vitamin-D-effective UVB through the generic glass model');
+ } else if (uvIndex != null) {
+ parts.push(`negligible modeled vitamin-D-effective UVB at UVI ${uvIndex.toFixed(1)}`);
+ }
+ const medFraction = session.safety?.medFraction || 0;
+ if (medFraction >= 1) parts.push('over the base skin-type burn estimate — stop UV exposure');
+ else if (medFraction >= 0.7) parts.push(`base burn dose ${Math.round(medFraction * 100)}% — close to the modeled limit`);
+ else if (medFraction >= 0.3) parts.push(`base burn dose ${Math.round(medFraction * 100)}% — model only; avoid redness`);
+ return parts.join(' · ');
+}
diff --git a/js/sun-active-session.js b/js/sun-active-session.js
index 0e397916..9eed1275 100644
--- a/js/sun-active-session.js
+++ b/js/sun-active-session.js
@@ -1,15 +1,13 @@
// @ts-check
-// sun-active-session.js — active sun-session UI, live dose ticker, and
-// active-session modal. Core persisted session storage and hydration live in
-// sun-sessions-store.js; this module receives those operations through
-// configuration to avoid importing sun.js back into the active UI layer.
-
+// sun-active-session.js — active sun-session UI and live dose ticker.
import { state } from './state.js';
import { escapeHTML, escapeAttr, showNotification } from './utils.js';
import { openAppendedModalOverlay, removeModalOverlay } from './modal-lifecycle.js';
import { BODY_REGIONS, renderBodySilhouette, bindBodySilhouette } from './sun-body-silhouette.js';
import { POSTURE_MULTIPLIERS, SURFACE_ALBEDO } from './sun-session-model.js';
import { renderChannelChips } from './sun-session-ui.js';
+import { setSunChannelChipsExpanded } from './sun-session-actions.js';
+import { activeElapsedMs as _activeElapsedMs, formatElapsed as _formatElapsed, plainStopSummary } from './sun-active-session-format.js';
/**
* @typedef {object} SunActiveSessionDeps
@@ -23,7 +21,8 @@ import { renderChannelChips } from './sun-session-ui.js';
* @property {(atm: any) => any} applyAtmOverrides
* @property {() => void} refreshSurfaces
* @property {(raw: any) => string} normalizePSMTier
- * @property {(tier?: any) => number} photosensitiveMedScale
+ * @property {(tier?: any) => number|null} photosensitiveMedScale
+ * @property {() => void} openLightSetup
* @property {Array<{ key: string, label: string, pickerLabel?: string }>} eyeModes
* @property {Array<{ key: string, label: string }>} lensTints
* @property {Array<{ key: string, label: string }>} postureOptions
@@ -44,12 +43,14 @@ const activeDeps = {
refreshSurfaces: () => {},
normalizePSMTier: (raw) => raw || 'none',
photosensitiveMedScale: () => 1.0,
+ openLightSetup: () => {},
eyeModes: [], lensTints: [], postureOptions: [], surfaceOptions: [],
fetchAtmosphere: async () => null, reconstructSpectrum: () => null,
computeChannelDoses: () => ({}), erythemalSED: () => 0,
+ ocularActinicUVdose: () => 0,
fractionOfMED: () => 0, solarZenithAngle: () => 90,
interpolateAtmosphere: () => null,
- vitaminDIU: (channelAu, _fitzpatrick = 'III', _uvi = null, rotatedSides = false) => channelAu * 60 * (rotatedSides ? 2 : 1),
+ vitaminDIU: channelAu => channelAu * 60,
vitaminDIUPerSession: null,
skinTypeToFitzpatrick: (skinType) => (String(skinType || '').match(/^(I{1,3}|IV|VI?)\b/) || [])[1] || null,
renderLightChannelsLive: () => {}, renderLightTodayStrip: () => '',
@@ -59,9 +60,8 @@ const activeDeps = {
export function configureSunActiveSession(deps = {}) { Object.assign(activeDeps, deps); }
export { POSTURE_MULTIPLIERS, SURFACE_ALBEDO } from './sun-session-model.js';
+export { _formatElapsed };
-// Single-tap "I'm outside now" — starts a session with last-used defaults.
-// On stop: skips confirm dialog because the user explicitly tapped stop.
export async function quickLogSunSession() {
const active = activeDeps.getActiveSession();
if (active) {
@@ -70,9 +70,9 @@ export async function quickLogSunSession() {
const sess = activeDeps.getSessions().find(s => s.id === active.id);
const dur = Math.round(sess?.durationMin || 0);
const summary = _plainStopSummary(sess, dur);
- showNotification(summary, summary.includes('over your burn threshold') ? 'error' : 'success', 7000);
+ showNotification(summary, summary.includes('stop UV exposure') ? 'error' : 'success', 7000);
activeDeps.refreshSurfaces();
- return;
+ return true;
}
return openStartSunSessionDialog();
}
@@ -99,7 +99,7 @@ function _estimateMedMinutes(uvi, fitzpatrick, psmTier) {
return Math.round(seconds / 60);
}
-function _renderUVIPreflightBanner(uvi, fitzpatrick, psmTier) {
+function _renderUVIPreflightBanner(uvi, fitzpatrick, psmTier, fitzpatrickAssumed = false) {
if (!Number.isFinite(uvi)) return '';
const psmHigh = psmTier === 'moderate' || psmTier === 'severe';
const fairSkin = fitzpatrick === 'I' || fitzpatrick === 'II';
@@ -112,8 +112,13 @@ function _renderUVIPreflightBanner(uvi, fitzpatrick, psmTier) {
if (uvi >= 11) { cls = 'sun-uvi-extreme'; icon = '⚠'; title = `Extreme UV (UVI ${uvi.toFixed(1)})`; }
else if (uvi >= 8) { cls = 'sun-uvi-veryhigh'; title = `Very high UV (UVI ${uvi.toFixed(1)})`; }
else { title = `UV ${uvi.toFixed(1)} — burn risk elevated ${psmHigh ? 'by photosensitizer' : 'for fair skin'}`; }
- const medLine = medMin ? `Estimated MED for Fitzpatrick ${fitzpatrick}${psmHigh ? ` + ${psmTier} photosensitizer` : ''}: ~${medMin} min uncovered.` : '';
- return `
${icon} ${escapeHTML(title)} ${escapeHTML(medLine)} Sunscreen + cover up + a shorter session strongly suggested.
`;
+ const medLine = medMin
+ ? `${fitzpatrickAssumed ? 'Conservative Type I assumption because skin type is unset' : `Fitzpatrick ${fitzpatrick} base-MED model`}: ~${medMin} min to the modeled base MED under current UVI—not a safe exposure time.`
+ : '';
+ const medicationLine = psmTier !== 'none'
+ ? ' Medication effects are not included because a drug-specific burn threshold cannot be inferred; follow the label or clinician.'
+ : '';
+ return `
${icon} ${escapeHTML(title)} ${escapeHTML(medLine + medicationLine)} Use shade, clothing, and suitable sun protection; shorten or skip the session when warnings apply.
`;
}
function _buildStartSessionToast({ regionCount, uvi, psmTier, eyeMode }) {
@@ -122,13 +127,36 @@ function _buildStartSessionToast({ regionCount, uvi, psmTier, eyeMode }) {
if (Number.isFinite(uvi) && uvi >= 11) notes.push(`extreme UV ${uvi.toFixed(1)}`);
else if (Number.isFinite(uvi) && uvi >= 8) notes.push(`high UV ${uvi.toFixed(1)}`);
const tier = activeDeps.normalizePSMTier(psmTier);
- if (tier !== 'none') notes.push(`${tier} photosensitizer`);
+ if (tier === 'unknown') notes.push('sunlight warnings not reviewed');
+ else if (tier !== 'none') notes.push(`${tier} photosensitivity caution`);
if (eyeMode === 'direct') notes.push('eyes uncovered');
if (notes.length) parts.push(`${notes.join(' + ')} · keep it short`);
return parts.join(' · ');
}
export async function openStartSunSessionDialog() {
+ const configuredFitz = state.importedData?.sunDefaults?.fitzpatrick || null;
+ if (!/^(I|II|III|IV|V|VI)$/.test(String(configuredFitz || ''))) {
+ showNotification(
+ 'Confirm your Fitzpatrick skin type in Light setup before starting a session. It anchors the UV and skin-response estimates.',
+ 'info',
+ 7000,
+ );
+ activeDeps.openLightSetup();
+ return false;
+ }
+ const startCoords = activeDeps.getSunCoords();
+ if (!startCoords || startCoords.source === 'country-band') {
+ showNotification(
+ startCoords?.source === 'country-band'
+ ? 'A country-level location is too broad for live UV safety guidance. Add a home postal area or use your privacy-rounded current location today before starting.'
+ : 'A location is needed for live UV safety guidance. Add a home location or use your privacy-rounded current location today before starting.',
+ 'info',
+ 8000,
+ );
+ activeDeps.openLightSetup();
+ return false;
+ }
const last = activeDeps.getSessions().filter(s => s.endedAt).slice(-1)[0];
const lastRegions = new Set(last?.bodyExposure?.regions || []);
const defaultEye = last?.eyeExposure?.mode || 'direct';
@@ -136,8 +164,8 @@ export async function openStartSunSessionDialog() {
const defaultGlass = !!last?.bodyExposure?.glassBetween;
const defaultPosture = last?.posture || 'standing';
const defaultSurface = last?.surfaceAlbedo || 'grass';
- const fitz = state.importedData?.sunDefaults?.fitzpatrick || 'III';
- const psm = state.importedData?.sunDefaults?.photosensitiveMeds || 'none';
+ const fitz = configuredFitz;
+ const psm = state.importedData?.sunDefaults?.photosensitiveMeds ?? 'unknown';
const uviPromise = _fetchCurrentUVI();
let latestPreflightUvi = null;
@@ -171,6 +199,7 @@ export async function openStartSunSessionDialog() {
+
Choose a protected-eye option only when the lenses are labeled UV-blocking. Dark tint alone does not prove UV protection.
Lying flat catches more sun than standing (~40%). Reflective surfaces (sand, water, snow) bounce UV onto your skin from below.
-
Standard window glass blocks ~99% of UVB. Vitamin D synthesis stops; circadian and warmth signals still get through. We zero the burn dose accordingly. (Want to measure YOUR glass's transmission? Light tools → Window check.)
-
- Plan to flip front ↔ back during the session
-
-
-
Toggle on if you'll alternate sides — doubles the vitamin D estimate to reflect that fresh skin keeps synthesizing after the first side approaches saturation. You can also tap 🔄 Flip mid-session.
+
Ordinary window glass usually blocks most vitamin-D-effective UVB but can pass some UVA, visible light, and near-infrared. Glass types vary, so the model uses a generic wavelength-by-wavelength estimate and never treats glass as guaranteed UV protection. Light tools → Window check can compare your own glass.
+
If you turn over later, use Side change at that moment. It records the timing boundary without multiplying the dose; use Coverage too if different skin becomes exposed.
@@ -218,7 +240,7 @@ export async function openStartSunSessionDialog() {
const slot = overlay.querySelector('#sun-start-silhouette-slot');
const hint = overlay.querySelector('#sun-start-hint');
const confirm = overlay.querySelector('#start-confirm');
- if (!(slot instanceof HTMLElement) || !(hint instanceof HTMLElement) || !(confirm instanceof HTMLElement)) { closeDialog(); return; }
+ if (!(slot instanceof HTMLElement) || !(hint instanceof HTMLElement) || !(confirm instanceof HTMLElement)) { closeDialog(); return false; }
const updateHint = () => {
const fraction = Array.from(selected).reduce((sum, key) => {
const r = BODY_REGIONS.find(b => b.key === key);
@@ -245,7 +267,7 @@ export async function openStartSunSessionDialog() {
latestPreflightUvi = uvi;
const banner = overlay.querySelector('#sun-start-uvi-banner');
if (!(banner instanceof HTMLElement)) return;
- const html = _renderUVIPreflightBanner(uvi, fitz, psm);
+ const html = _renderUVIPreflightBanner(uvi, fitz, psm, !configuredFitz);
if (html) {
banner.innerHTML = html;
banner.hidden = false;
@@ -258,7 +280,7 @@ export async function openStartSunSessionDialog() {
const glassBetween = !!/** @type {HTMLInputElement | null} */ (overlay.querySelector('#start-glass'))?.checked;
const posture = /** @type {HTMLSelectElement | null} */ (overlay.querySelector('#start-posture'))?.value || 'standing';
const surfaceAlbedo = /** @type {HTMLSelectElement | null} */ (overlay.querySelector('#start-surface'))?.value || 'grass';
- const rotatedSides = !!/** @type {HTMLInputElement | null} */ (overlay.querySelector('#start-rotated'))?.checked;
+ const modeledEyeMode = glassBetween && eyeMode === 'direct' ? 'glass-window' : eyeMode;
const regions = Array.from(selected);
if (regions.length === 0) {
hint.textContent = 'Tap at least one region before starting — what part of you is uncovered?';
@@ -266,51 +288,27 @@ export async function openStartSunSessionDialog() {
setTimeout(() => hint.classList.remove('sun-silhouette-hint-error'), 2500);
return;
}
- const coords = activeDeps.getSunCoords();
- const id = await activeDeps.startSession({ regions, eyeMode, lensTint, glassBetween, posture, surfaceAlbedo, rotatedSides, location: coords });
+ const id = await activeDeps.startSession({ regions, eyeMode: modeledEyeMode, lensTint, glassBetween, posture, surfaceAlbedo, rotatedSides: false, location: startCoords });
closeDialog();
showNotification(_buildStartSessionToast({
regionCount: regions.length,
uvi: latestPreflightUvi,
psmTier: state.importedData?.sunDefaults?.photosensitiveMeds,
- eyeMode,
+ eyeMode: modeledEyeMode,
}), 'success', 4500);
activeDeps.refreshSurfaces();
ensureActiveTicker();
return id;
});
+ return true;
}
-function _plainStopSummary(sess, dur) {
- if (!sess) return `Session saved — ${dur} min`;
- const parts = [`Saved · ${dur} min outside`];
- const fitz = sess.safety?.fitzpatrick || 'III';
- const uvi = sess.atmosphere?.uvIndex;
- const vitDAu = sess.doses?.vitamin_d || 0;
- if (vitDAu > 0 && activeDeps.vitaminDIU) {
- const bf = sess.bodyExposure?.fraction;
- const iu = (Number.isFinite(bf) && bf > 0 && typeof activeDeps.vitaminDIUPerSession === 'function')
- ? activeDeps.vitaminDIUPerSession(vitDAu, fitz, uvi, !!sess.bodyExposure?.rotatedSides, state.importedData?.genetics || null, bf)
- : activeDeps.vitaminDIU(vitDAu, fitz, uvi, !!sess.bodyExposure?.rotatedSides, state.importedData?.genetics || null);
- if (iu >= 100) {
- const lo = Math.round(iu * 0.6 / 50) * 50;
- const hi = Math.round(iu * 1.5 / 50) * 50;
- parts.push(`~${lo}–${hi} IU vitamin D`);
- }
- } else if (sess.bodyExposure?.glassBetween) {
- parts.push('no vitamin D — glass blocks UVB');
- } else if (uvi != null && uvi < 2) {
- parts.push(`no vitamin D — UVI too low (${uvi.toFixed(1)})`);
- }
- const med = sess.safety?.medFraction || 0;
- if (med >= 1.0) {
- parts.push('over your burn threshold — no more sun today');
- } else if (med >= 0.7) {
- parts.push(`burn dose ${Math.round(med * 100)}% — close to limit, ease up`);
- } else if (med >= 0.3) {
- parts.push(`burn dose ${Math.round(med * 100)}% — well within safe range`);
- }
- return parts.join(' · ');
+function _plainStopSummary(session, durationMin) {
+ return plainStopSummary(session, durationMin, {
+ vitaminDIU: activeDeps.vitaminDIU,
+ vitaminDIUPerSession: activeDeps.vitaminDIUPerSession,
+ genetics: state.importedData?.genetics,
+ });
}
let _activeTicker = null;
@@ -323,16 +321,6 @@ export function setSunLiveState(id, patch) {
}
export function clearSunLiveState(id) { _liveState.delete(id); }
-export function _formatElapsed(ms) {
- const totalSec = Math.max(0, Math.floor(ms / 1000));
- const h = Math.floor(totalSec / 3600);
- const m = Math.floor((totalSec % 3600) / 60);
- const s = totalSec % 60;
- const pad = (n) => String(n).padStart(2, '0');
- if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
- return `${m}:${pad(s)}`;
-}
-
async function _snapshotActiveRate(sess) {
const cur = _getLiveState(sess.id);
if (cur && cur.ratePerMin) return cur;
@@ -371,34 +359,46 @@ async function _snapshotActiveRate(sess) {
ozoneDU: atm.ozoneDU ?? 300,
altitudeM: coords.altitudeM ?? 0,
cloudCover: (atm.cloudCover ?? 0) / 100,
+ aod: atm?.airQuality?.aod ?? null,
+ targetUVI: atm.uvIndex ?? null,
});
const liveBodyModifiers = {
glassBetween: !!sess.bodyExposure?.glassBetween,
sunscreenSPF: sess.bodyExposure?.sunscreenSPF || 0,
};
+ const modeledEyeExposure = liveBodyModifiers.glassBetween && sess.eyeExposure?.mode === 'direct'
+ ? { ...sess.eyeExposure, mode: 'glass-window' }
+ : sess.eyeExposure;
const ratePerMin = computeChannelDoses({
spectrum,
durationMin: 1,
bodyExposureFraction: sess.bodyExposure?.fraction ?? 0,
- eyeExposure: sess.eyeExposure,
+ skinIrradianceMultiplier: Math.max(0, Math.min(2,
+ (POSTURE_MULTIPLIERS[sess.posture] ?? 1.0)
+ * (1 + (SURFACE_ALBEDO[sess.surfaceAlbedo] ?? 0) * 0.5))),
+ eyeExposure: modeledEyeExposure,
bodyModifiers: liveBodyModifiers,
});
const sedPerMin = erythemalSED({
spectrum,
durationMin: 1,
bodyExposureFraction: sess.bodyExposure?.fraction ?? 0,
+ skinIrradianceMultiplier: Math.max(0, Math.min(2,
+ (POSTURE_MULTIPLIERS[sess.posture] ?? 1.0)
+ * (1 + (SURFACE_ALBEDO[sess.surfaceAlbedo] ?? 0) * 0.5))),
bodyModifiers: liveBodyModifiers,
});
const lcSkin = state.importedData?.lightCircadian?.skinType;
const lcRoman = lcSkin && activeDeps.skinTypeToFitzpatrick(lcSkin);
- const fitzpatrick = state.importedData?.sunDefaults?.fitzpatrick || lcRoman || 'III';
+ const configuredFitzpatrick = state.importedData?.sunDefaults?.fitzpatrick || lcRoman || null;
+ const fitzpatrick = configuredFitzpatrick || 'I';
const psmTier = activeDeps.normalizePSMTier(state.importedData?.sunDefaults?.photosensitiveMeds);
const medScale = activeDeps.photosensitiveMedScale(psmTier);
const existing = _getLiveState(sess.id) || {};
const isReSnapshot = !!existing.committedDoses;
const sliceStart = isReSnapshot ? Date.now() : sess.startedAt;
setSunLiveState(sess.id, {
- ratePerMin, sedPerMin, fitzpatrick, medScale, psmTier, atm, zenith,
+ ratePerMin, sedPerMin, fitzpatrick, fitzpatrickAssumed: !configuredFitzpatrick, medScale, psmTier, atm, zenith,
baselineZenith: existing.baselineZenith ?? zenith,
snapshotAt: sliceStart,
committedDoses: existing.committedDoses || {},
@@ -447,7 +447,7 @@ function _rateAtInstant(sess, instantMs) {
const baseFraction = sess.bodyExposure?.fraction ?? 0;
const postureMult = POSTURE_MULTIPLIERS[sess.posture] ?? 1.0;
const albedoMult = 1 + (SURFACE_ALBEDO[sess.surfaceAlbedo] ?? 0) * 0.5;
- const effFraction = baseFraction * postureMult * albedoMult;
+ const skinIrradianceMultiplier = Math.max(0, Math.min(2, postureMult * albedoMult));
const zenith = solarZenithAngle(when, coords.lat, coords.lon);
const spectrum = reconstructSpectrum({
@@ -456,47 +456,39 @@ function _rateAtInstant(sess, instantMs) {
altitudeM: coords.altitudeM ?? 0,
cloudCover: (atmAtT.cloudCover ?? 0) / 100,
aod: atmAtT?.airQuality?.aod ?? null,
+ targetUVI: atmAtT.uvIndex ?? null,
});
const bodyModifiers = {
glassBetween: !!sess.bodyExposure?.glassBetween,
sunscreenSPF: sess.bodyExposure?.sunscreenSPF || 0,
};
+ const modeledEyeExposure = bodyModifiers.glassBetween && sess.eyeExposure?.mode === 'direct'
+ ? { ...sess.eyeExposure, mode: 'glass-window' }
+ : sess.eyeExposure;
const rate = computeChannelDoses({
spectrum,
durationMin: 1,
- bodyExposureFraction: effFraction,
- eyeExposure: sess.eyeExposure,
+ bodyExposureFraction: baseFraction,
+ skinIrradianceMultiplier,
+ eyeExposure: modeledEyeExposure,
bodyModifiers,
});
const sedPerMin = erythemalSED({
spectrum,
durationMin: 1,
- bodyExposureFraction: effFraction,
+ bodyExposureFraction: baseFraction,
+ skinIrradianceMultiplier,
bodyModifiers,
});
- let retinalUVPerMin = 0;
- if (sess.eyeExposure?.mode === 'direct') {
- const elev = 90 - zenith;
- let gate = 1.0;
- if (elev <= 5) gate = 0;
- else if (elev < 10) gate = (elev - 5) / 5;
- retinalUVPerMin = _retinalUVPerMin(spectrum) * gate;
- }
+ const retinalUVPerMin = activeDeps.ocularActinicUVdose({
+ spectrum,
+ eyeExposure: { ...(modeledEyeExposure || {}), durationSec: 60 },
+ zenithDeg: zenith,
+ glassBetween: bodyModifiers.glassBetween,
+ });
return { rate, sedPerMin, retinalUVPerMin };
}
-function _retinalUVPerMin(spectrum) {
- if (!spectrum) return 0;
- const dlambda = 5;
- let uv = 0;
- for (let i = 0; i < spectrum.irradiance.length; i++) {
- const nm = spectrum.wavelengths[i];
- if (nm > 400) break;
- uv += spectrum.irradiance[i] * dlambda;
- }
- return uv * 60;
-}
-
function _integrateSlice(sess, startMs, endMs) {
const durationMin = Math.max(0, (endMs - startMs) / 60000);
if (durationMin <= 0) return { doses: {}, sed: 0, retinalUV: 0 };
@@ -519,10 +511,10 @@ function _integrateSlice(sess, startMs, endMs) {
export function commitSunLiveSlice(sess) {
const live = _getLiveState(sess?.id);
- if (!live || !live.ratePerMin || !live.snapshotAt) return;
+ if (!live || !live.ratePerMin || !live.snapshotAt) return null;
const sliceStart = live.snapshotAt;
const sliceEnd = Date.now();
- if (sliceEnd <= sliceStart) return;
+ if (sliceEnd <= sliceStart) return null;
const { doses, sed, retinalUV } = _integrateSlice(sess, sliceStart, sliceEnd);
const committedDoses = { ...(live.committedDoses || {}) };
for (const [k, v] of Object.entries(doses)) {
@@ -530,7 +522,24 @@ export function commitSunLiveSlice(sess) {
}
const committedSED = (live.committedSED || 0) + sed;
const committedRetinalUV = (live.committedRetinalUV || 0) + retinalUV;
- setSunLiveState(sess.id, { committedDoses, committedSED, committedRetinalUV });
+ const segment = {
+ startedAt: sliceStart,
+ endedAt: sliceEnd,
+ durationMin: (sliceEnd - sliceStart) / 60000,
+ doses: { ...doses },
+ sed,
+ ocularActinicUV: retinalUV,
+ bodyExposure: { ...(sess.bodyExposure || {}), regions: [...(sess.bodyExposure?.regions || [])] },
+ eyeExposure: { ...(sess.eyeExposure || {}) },
+ posture: sess.posture || 'standing',
+ surfaceAlbedo: sess.surfaceAlbedo || 'grass',
+ atmosphere: live.atm ? { ...live.atm } : null,
+ zenith: live.zenith ?? null,
+ };
+ if (!Array.isArray(sess.exposureSegments)) sess.exposureSegments = [];
+ sess.exposureSegments.push(segment);
+ setSunLiveState(sess.id, { committedDoses, committedSED, committedRetinalUV, snapshotAt: sliceEnd });
+ return segment;
}
export function liveDosesFor(sess) {
@@ -541,7 +550,7 @@ export function liveDosesFor(sess) {
const sed = live.committedSED || 0;
const retinalUV = live.committedRetinalUV || 0;
const medFraction = live.fractionOfMEDFn ? live.fractionOfMEDFn({ sed, fitzpatrick: live.fitzpatrick, medScale: live.medScale ?? 1.0 }) : 0;
- return { doses: { ...committed }, sed, retinalUV, medFraction, fitzpatrick: live.fitzpatrick, psmTier: live.psmTier, atm: live.atm, paused: true };
+ return { doses: { ...committed }, sed, retinalUV, medFraction, fitzpatrick: live.fitzpatrick, fitzpatrickAssumed: live.fitzpatrickAssumed, psmTier: live.psmTier, atm: live.atm, paused: true };
}
if (!live.ratePerMin) return null;
const sliceStart = live.snapshotAt || sess.startedAt;
@@ -555,60 +564,75 @@ export function liveDosesFor(sess) {
const sed = (live.committedSED || 0) + sliceSed;
const retinalUV = (live.committedRetinalUV || 0) + sliceRetinalUV;
const medFraction = live.fractionOfMEDFn ? live.fractionOfMEDFn({ sed, fitzpatrick: live.fitzpatrick, medScale: live.medScale ?? 1.0 }) : 0;
- return { doses, sed, retinalUV, medFraction, fitzpatrick: live.fitzpatrick, psmTier: live.psmTier, atm: live.atm };
+ return { doses, sed, retinalUV, medFraction, fitzpatrick: live.fitzpatrick, fitzpatrickAssumed: live.fitzpatrickAssumed, psmTier: live.psmTier, atm: live.atm };
}
function _renderActiveCardBody(sess) {
- const elapsed = _formatElapsed(Date.now() - sess.startedAt);
+ const elapsed = _formatElapsed(_activeElapsedMs(sess));
const live = liveDosesFor(sess);
let medStr = '';
if (live && Number.isFinite(live.medFraction)) {
const pct = Math.round(live.medFraction * 100);
- let label = 'safe', cls = '';
+ let label = 'low modeled dose', cls = '';
if (live.medFraction >= 1) { label = 'over threshold'; cls = 'over'; }
else if (live.medFraction >= 0.7) { label = 'high'; cls = 'warn'; }
else if (live.medFraction >= 0.3) { label = 'moderate'; cls = ''; }
- medStr = `${pct}% burn dose · ${escapeHTML(label)}`;
+ const medCaution = live.psmTier && live.psmTier !== 'none'
+ ? ' Medication photosensitivity is not numerically included; your actual threshold may be lower.'
+ : '';
+ const skinAssumption = live.fitzpatrickAssumed ? ' Conservative Type I is assumed because skin type is unset.' : '';
+ medStr = `${pct}% base burn dose · ${escapeHTML(label)}${(skinAssumption || medCaution) ? ' ⚠' : ''}`;
}
const channelChips = live?.doses ? renderChannelChips(live.doses, sess) : '';
let vitaminDStr = '';
if (live && live.doses?.vitamin_d > 0) {
- const elapsedMin = Math.max(0, (Date.now() - sess.startedAt) / 60000);
- const fitz = live.fitzpatrick || sess.safety?.fitzpatrick || 'III';
+ const elapsedMin = _activeElapsedMs(sess) / 60000;
+ const fitz = live.fitzpatrick || sess.safety?.fitzpatrick || 'I';
const uvi = live.atm?.uvIndex ?? sess.atmosphere?.uvIndex ?? null;
const rotated = !!sess.bodyExposure?.rotatedSides;
const bf = sess.bodyExposure?.fraction;
const iu = (Number.isFinite(bf) && bf > 0 && typeof activeDeps.vitaminDIUPerSession === 'function')
? activeDeps.vitaminDIUPerSession(live.doses.vitamin_d, fitz, uvi, rotated, state.importedData?.genetics || null, bf)
: activeDeps.vitaminDIU(live.doses.vitamin_d, fitz, uvi, rotated, state.importedData?.genetics || null);
- const ratePerMin = elapsedMin > 0 ? iu / elapsedMin : 0;
- if (iu >= 50) {
- const iuLabel = iu >= 10000 ? '~' + (iu / 1000).toFixed(1).replace(/\.0$/, '') + 'k IU'
- : iu >= 1000 ? '~' + Math.round(iu / 100) * 100 + ' IU'
- : '~' + Math.round(iu / 10) * 10 + ' IU';
- const rateLabel = ratePerMin >= 100 ? `${Math.round(ratePerMin / 10) * 10} IU/min` : `${Math.round(ratePerMin)} IU/min`;
- vitaminDStr = `☀ ~${iuLabel} vit D · ${rateLabel}`;
+ if (Number.isFinite(iu) && iu > 0) {
+ const ratePerMin = elapsedMin > 0 ? iu / elapsedMin : 0;
+ const iuLabel = iu >= 10000 ? `~${(iu / 1000).toFixed(1).replace(/\.0$/, '')}k IU-eq`
+ : iu >= 1000 ? `~${Math.round(iu / 100) * 100} IU-eq`
+ : iu >= 100 ? `~${Math.round(iu / 10) * 10} IU-eq`
+ : iu >= 10 ? `~${Math.round(iu)} IU-eq`
+ : '<10 IU-eq';
+ const rateLabel = ratePerMin >= 100 ? `~${Math.round(ratePerMin / 10) * 10} IU-eq/min avg`
+ : ratePerMin >= 1 ? `~${Math.round(ratePerMin)} IU-eq/min avg`
+ : ratePerMin > 0 ? '<1 IU-eq/min avg'
+ : 'rate pending';
+ vitaminDStr = `☀ Vitamin D estimate${iuLabel}${rateLabel}`;
+ } else {
+ vitaminDStr = `☀ Vitamin D estimateEstimate unavailable`;
}
+ } else if (live) {
+ vitaminDStr = `☀ Vitamin D estimateNo modeled UVB dose yet`;
+ } else {
+ vitaminDStr = `☀ Vitamin D estimateCalculating…`;
}
let heatStr = '';
const tempC = live?.atm?.temperatureC ?? null;
- const elapsedMin = (Date.now() - sess.startedAt) / 60000;
+ const elapsedMin = _activeElapsedMs(sess) / 60000;
if (Number.isFinite(tempC) && tempC > 30 && elapsedMin > 30) {
- heatStr = `🌡 ${Math.round(tempC)}°C · take a break`;
+ heatStr = `🌡 ${Math.round(tempC)}°C · cool down`;
}
let retinalStr = '';
- if (live && sess.eyeExposure?.mode === 'direct' && Number.isFinite(live.retinalUV) && live.retinalUV > 3) {
+ if (live && Number.isFinite(live.retinalUV) && live.retinalUV > 3) {
const ruv = live.retinalUV;
const ruvDisplay = ruv >= 10 ? Math.round(ruv) : ruv.toFixed(1);
const cls = ruv >= 15 ? ' warn' : '';
- const label = ruv >= 30 ? 'at ICNIRP daily limit' : ruv >= 15 ? 'half the daily limit' : 'building';
- retinalStr = `👁 ${ruvDisplay} J/m² eye UV`;
+ const label = ruv >= 30 ? 'at the ICNIRP 8-hour reference' : ruv >= 15 ? 'half the ICNIRP 8-hour reference' : 'building';
+ retinalStr = `👁 ${ruvDisplay} J/m² ocular actinic UV`;
}
- return { elapsed, medStr, vitaminDStr, channelChips, heatStr, retinalStr };
+ const liveReadouts = [vitaminDStr, medStr, retinalStr, heatStr].filter(Boolean).join('');
+ return { elapsed, liveReadouts, channelChips };
}
let _lastChannelRefreshAt = 0;
-const RETINAL_ALERT_GRACE_MS = 10 * 60 * 1000;
function _tickActiveCards() {
const sessions = activeDeps.getSessions().filter(s => !s.endedAt);
if (sessions.length === 0) {
@@ -632,38 +656,32 @@ function _tickActiveCards() {
const cur = _getLiveState(sess.id) || {};
if (med >= 1.0 && !cur.alertedOver) {
setSunLiveState(sess.id, { alertedOver: true });
- showNotification(_jargonPrefix('med') + 'Burn threshold reached. Move to shade or cover up. Hydrate, no more direct sun today — damage from here is cumulative.', 'error', 10000);
+ showNotification(_jargonPrefix('med') + 'The base skin-type MED reference is reached. Stop UV exposure and move to shade or cover up; this model is not a personal threshold.', 'error', 10000);
} else if (med >= 0.7 && !cur.alerted70) {
setSunLiveState(sess.id, { alerted70: true });
- showNotification(_jargonPrefix('med') + '70% of your burn dose. Best move: head into shade for ~10 min, then decide. If you stay, watch for skin warmth or pinkness.', 'warning', 8000);
+ showNotification(_jargonPrefix('med') + '70% of the base skin-type MED reference. Move to shade or cover up, and stop before warmth, tenderness, or pinkness.', 'warning', 8000);
}
}
- if (liveDoses && Number.isFinite(liveDoses.retinalUV) && sess.eyeExposure?.mode === 'direct') {
+ if (liveDoses && Number.isFinite(liveDoses.retinalUV)) {
const ruv = liveDoses.retinalUV;
const cur = _getLiveState(sess.id) || {};
- const elapsedMs = Date.now() - sess.startedAt;
- if (elapsedMs < RETINAL_ALERT_GRACE_MS) {
- setSunLiveState(sess.id, {
- alertedRetinal500: cur.alertedRetinal500 || ruv >= 15,
- alertedRetinalOver: cur.alertedRetinalOver || ruv >= 30,
- });
- } else if (ruv >= 30 && !cur.alertedRetinalOver) {
+ if (ruv >= 30 && !cur.alertedRetinalOver) {
setSunLiveState(sess.id, { alertedRetinalOver: true, alertedRetinal500: true });
- showNotification('Eye UV is high. Put on UV-blocking sunglasses or take a shade break.', 'warning', 8000);
+ showNotification('Ocular actinic UV reached the ICNIRP 8-hour reference. Use UV-blocking sunglasses or move to shade. Never look at the sun.', 'warning', 8000);
} else if (ruv >= 15 && !cur.alertedRetinal500) {
setSunLiveState(sess.id, { alertedRetinal500: true });
- showNotification('Eye UV is building. Sunglasses or look-down breaks are a good idea.', 'warning', 6500);
+ showNotification('Ocular actinic UV is building. Use UV-blocking sunglasses or take a shade break; never look at the sun.', 'warning', 6500);
}
}
const tempC = liveDoses?.atm?.temperatureC ?? null;
- const elapsedMinNow = (Date.now() - sess.startedAt) / 60000;
+ const elapsedMinNow = _activeElapsedMs(sess) / 60000;
if (Number.isFinite(tempC) && tempC > 30 && elapsedMinNow > 30) {
const cur = _getLiveState(sess.id) || {};
if (!cur.alertedHeat) {
setSunLiveState(sess.id, { alertedHeat: true });
- showNotification(`${tempC.toFixed(0)}°C ambient — drink water, take a 10-min shade break. Heat exhaustion ramps faster than UV burn at this temperature.`, 'warning', 8000);
+ showNotification(`${tempC.toFixed(0)}°C ambient — heat risk is separate from UV dose. Move to a cool or shaded place, hydrate, and stop if you feel unwell.`, 'warning', 8000);
}
}
@@ -674,7 +692,7 @@ function _tickActiveCards() {
continue;
}
- const elapsedFmt = _formatElapsed(Date.now() - sess.startedAt);
+ const elapsedFmt = _formatElapsed(_activeElapsedMs(sess));
document.querySelectorAll(`[data-live-elapsed-for="${CSS.escape(sess.id)}"]`).forEach(el => {
el.textContent = elapsedFmt;
});
@@ -682,47 +700,29 @@ function _tickActiveCards() {
const cards = document.querySelectorAll(`[data-id="${CSS.escape(sess.id)}"]`);
if (!cards.length) continue;
const body = _renderActiveCardBody(sess);
- const patchChip = (el, html) => {
- if (!html) { el.remove(); return; }
- const tmpl = document.createElement('template');
- tmpl.innerHTML = html.trim();
- const fresh = tmpl.content.firstElementChild;
- if (!fresh) return;
- if (el.className !== fresh.className) el.className = fresh.className;
- const newTitle = fresh.getAttribute('title') || '';
- if (el.getAttribute('title') !== newTitle) el.setAttribute('title', newTitle);
- const newText = fresh.textContent;
- if (el.textContent !== newText) el.textContent = newText;
- };
cards.forEach(card => {
const durEl = card.querySelector('.sun-session-duration');
if (durEl) durEl.textContent = body.elapsed;
- const medEl = card.querySelector('.sun-session-med');
- if (medEl) patchChip(medEl, body.medStr);
- else if (body.medStr) {
- const head = card.querySelector('.sun-session-head .sun-session-duration');
- if (head) head.insertAdjacentHTML('afterend', body.medStr);
- }
- const vitdEl = card.querySelector('.sun-session-vitd');
- if (vitdEl) patchChip(vitdEl, body.vitaminDStr);
- else if (body.vitaminDStr) {
- const after = card.querySelector('.sun-session-med') || card.querySelector('.sun-session-duration');
- if (after) after.insertAdjacentHTML('afterend', body.vitaminDStr);
- }
- const heatEl = card.querySelector('.sun-session-heat');
- if (heatEl) patchChip(heatEl, body.heatStr);
- else if (body.heatStr) {
- const after = card.querySelector('.sun-session-vitd') || card.querySelector('.sun-session-med') || card.querySelector('.sun-session-duration');
- if (after) after.insertAdjacentHTML('afterend', body.heatStr);
+ const legacyHeadReadouts = card.querySelectorAll('.sun-session-head > .sun-session-med, .sun-session-head > .sun-session-vitd, .sun-session-head > .sun-session-heat, .sun-session-head > .sun-session-retinal');
+ legacyHeadReadouts.forEach(el => el.remove());
+ let liveReadoutsEl = card.querySelector('.sun-session-live-readouts');
+ if (!liveReadoutsEl) {
+ const readoutAnchor = card.querySelector('.sun-session-meta') || card.querySelector('.sun-session-head');
+ if (readoutAnchor) {
+ readoutAnchor.insertAdjacentHTML('afterend', '');
+ liveReadoutsEl = card.querySelector('.sun-session-live-readouts');
+ }
}
- const retinalEl = card.querySelector('.sun-session-retinal');
- if (retinalEl) patchChip(retinalEl, body.retinalStr);
- else if (body.retinalStr) {
- const after = card.querySelector('.sun-session-heat') || card.querySelector('.sun-session-vitd') || card.querySelector('.sun-session-med') || card.querySelector('.sun-session-duration');
- if (after) after.insertAdjacentHTML('afterend', body.retinalStr);
+ if (liveReadoutsEl && liveReadoutsEl.innerHTML !== body.liveReadouts) {
+ liveReadoutsEl.innerHTML = body.liveReadouts;
}
const oldChips = card.querySelector('.sun-channel-chips');
- if (oldChips) oldChips.outerHTML = body.channelChips || '';
+ if (oldChips) {
+ const wasExpanded = oldChips.classList.contains('sun-chips-expanded');
+ oldChips.outerHTML = body.channelChips || '';
+ const freshChips = card.querySelector('.sun-channel-chips');
+ if (freshChips) setSunChannelChipsExpanded(freshChips, wasExpanded);
+ }
else if (body.channelChips) card.insertAdjacentHTML('beforeend', body.channelChips);
});
}
diff --git a/js/sun-ai-analysis.js b/js/sun-ai-analysis.js
index 5cc98949..d110196b 100644
--- a/js/sun-ai-analysis.js
+++ b/js/sun-ai-analysis.js
@@ -10,26 +10,23 @@
// naturally via the per-row CRDT and the row template can read it
// without a side-channel cache.
-import { state } from './state.js';
import { escapeHTML, escapeAttr } from './utils.js';
import { hasAIProvider } from './api.js';
import { getSunDefaults } from './sun-defaults.js';
-import { getSessions, formatChannelUnit, CHANNEL_DISPLAY, channelTier, tierLabel } from './sun.js';
-import { vitaminDIU } from './sun-spectrum.js';
+import { getSessions, formatChannelUnit, CHANNEL_DISPLAY } from './sun.js';
import { solarZenithAngle } from './sun-uvdata.js';
import { createAIVerdict, hashString, dotPrefix } from './ai-verdict-engine.js';
-import { formatHealthGoalsText } from './health-goals-utils.js';
import { aiActionAttrs, registerAIActionHandler } from './ai-action-delegates.js';
// ─── Fingerprint ───────────────────────────────────────────────────────
//
// Hash of the session fields that, when changed, should invalidate a
-// previously-cached analysis. Deliberately excludes id and startedAt
-// (cosmetic) and includes the dose / safety / coverage / weather snapshot
-// since those are what the verdict actually keys on.
+// previously-cached analysis. Timing and location are biological inputs here,
+// not cosmetic metadata: together they determine solar elevation and phase.
function getSessionFingerprint(sess) {
if (!sess) return '';
const parts = [
+ sess.startedAt || 0,
sess.endedAt || 0,
Math.round((sess.durationMin || 0) * 10) / 10,
sess.bodyExposure?.preset || '',
@@ -40,10 +37,20 @@ function getSessionFingerprint(sess) {
sess.eyeExposure?.mode || '',
sess.eyeExposure?.lensTint || '',
Math.round((sess.eyeExposure?.durationSec || 0) / 30),
+ sess.posture || '',
+ sess.surfaceAlbedo || '',
+ sess.location?.lat != null ? Math.round(sess.location.lat * 10000) : '',
+ sess.location?.lon != null ? Math.round(sess.location.lon * 10000) : '',
sess.atmosphere?.uvIndex != null ? Math.round(sess.atmosphere.uvIndex * 10) : '',
sess.atmosphere?.cloudCover != null ? Math.round(sess.atmosphere.cloudCover) : '',
+ sess.atmosphere?.ozoneDU != null ? Math.round(sess.atmosphere.ozoneDU) : '',
+ sess.atmosphere?.source || '',
sess.safety?.fitzpatrick || '',
Math.round((sess.safety?.medFraction || 0) * 100),
+ sess.safety?.fitzpatrickAssumed ? 1 : 0,
+ sess.safety?.medicationThresholdUnknown ? 1 : 0,
+ sess.safety?.ocularActinicUV != null ? Math.round(sess.safety.ocularActinicUV * 10) : '',
+ sess.calculationStatus || '',
];
if (sess.doses) {
for (const k of Object.keys(sess.doses).sort()) {
@@ -61,6 +68,14 @@ function _formatNumber(n, digits = 1) {
return Number(n).toFixed(digits).replace(/\.0$/, '');
}
+function _localDateKey(date) {
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '—';
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, '0');
+ const d = String(date.getDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+}
+
// Tells the AI what part of the solar cycle the session covered.
// Sunrise + the non-UVA → UVA transition have specific biology that
// midday sessions don't, and the model needs that signal explicitly
@@ -89,50 +104,15 @@ function _classifySolarPhase(startElev, endElev) {
return 'midday peak (near-zenith sun)';
}
-function _sevenDayRollup(currentSess) {
- const sessions = getSessions().filter(s => s.endedAt && s.id !== currentSess?.id);
- const cutoff = (currentSess?.endedAt || Date.now()) - 7 * 86400000;
- const recent = sessions.filter(s => s.endedAt >= cutoff);
- if (!recent.length) return null;
- const genetics = state.importedData?.genetics || null;
- let totalMin = 0, totalVitDIU = 0, maxMed = 0;
- const daysWithSession = new Set();
- for (const s of recent) {
- totalMin += s.durationMin || 0;
- const rawVitD = s.doses?.vitamin_d || 0;
- if (rawVitD > 0) {
- const iu = vitaminDIU(
- rawVitD,
- s.safety?.fitzpatrick || 'III',
- s.atmosphere?.uvIndex ?? null,
- !!s.bodyExposure?.rotatedSides,
- genetics,
- );
- if (Number.isFinite(iu)) totalVitDIU += iu;
- }
- if ((s.safety?.medFraction || 0) > maxMed) maxMed = s.safety.medFraction;
- daysWithSession.add(new Date(s.endedAt).toISOString().slice(0, 10));
- }
- return {
- sessionCount: recent.length,
- daysWithSession: daysWithSession.size,
- totalMin: Math.round(totalMin),
- totalVitDIU: Math.round(totalVitDIU),
- maxMedPct: Math.round(maxMed * 100),
- };
-}
-
export function buildSingleSessionContext(sess) {
if (!sess) return '';
const sd = getSunDefaults() || {};
- const lc = state.importedData?.lightCircadian || {};
- const goals = formatHealthGoalsText(state.importedData?.healthGoals);
const lines = [];
lines.push('### Session');
const start = new Date(sess.startedAt || Date.now());
const end = sess.endedAt ? new Date(sess.endedAt) : null;
- lines.push(`Date: ${start.toISOString().slice(0, 10)}`);
+ lines.push(`Local date: ${_localDateKey(start)}`);
lines.push(`Time: ${start.toTimeString().slice(0, 5)}${end ? '–' + end.toTimeString().slice(0, 5) : ' (in progress)'}`);
lines.push(`Duration: ${_formatNumber(sess.durationMin)} min`);
@@ -178,45 +158,17 @@ export function buildSingleSessionContext(sess) {
if (v == null || v === 0) continue;
const meta = CHANNEL_DISPLAY[k] || { label: k };
let display = formatChannelUnit(k, v, dur, fitz, uvi, zenith, rotated, sess.bodyExposure?.fraction || null);
- if (!display) {
- const t = channelTier(v, k);
- const tlabel = tierLabel(t);
- const target = meta.dailyTarget || 0;
- const pct = (target > 0 && v > 0) ? Math.round(100 * v / target) : null;
- display = pct != null ? `${tlabel} (${pct}% of daily target)` : tlabel;
- }
+ if (!display) display = 'sunlight signal logged';
parts.push(`${meta.label || k}: ${display}`);
}
- if (parts.length) lines.push('Doses (as displayed to user):');
+ if (parts.length) lines.push('Modeled light signals:');
for (const p of parts) lines.push(' - ' + p);
}
if (sess.safety) {
- lines.push(`Burn dose: ${Math.round((sess.safety.medFraction || 0) * 100)}% of MED (Fitzpatrick ${sess.safety.fitzpatrick || sd.fitzpatrick || 'III'})`);
- }
-
- lines.push('');
- lines.push('### User profile');
- if (sd.fitzpatrick) lines.push(`Skin type: Fitzpatrick ${sd.fitzpatrick}`);
- else if (lc.skinType) lines.push(`Skin type: ${lc.skinType}`);
- if (sd.photosensitiveMeds && sd.photosensitiveMeds !== 'none') lines.push(`Photosensitizing meds: ${sd.photosensitiveMeds}`);
- if (sd.dailyVitDTargetIU) lines.push(`Vit-D daily target: ${sd.dailyVitDTargetIU} IU`);
- if (goals) lines.push(`Health goals: ${String(goals).slice(0, 200)}`);
-
- try {
- const entries = (state.importedData?.entries || []).slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''));
- for (const e of entries) {
- const v = e?.values?.hormones?.['25-oh-vitamin-d'] ?? e?.values?.lipids?.['25-oh-vitamin-d'];
- if (v != null) { lines.push(`Latest 25-OH-D: ${v} (${e.date})`); break; }
- }
- } catch (_) {}
-
- const rollup = _sevenDayRollup(sess);
- if (rollup) {
- lines.push('');
- lines.push('### Last 7 days (excluding this session)');
- lines.push(`Sessions: ${rollup.sessionCount} across ${rollup.daysWithSession} days · ${rollup.totalMin} min total`);
- lines.push(`Vit-D total: ~${rollup.totalVitDIU} IU · max burn dose: ${rollup.maxMedPct}%`);
+ lines.push(`Modeled burn dose: ${Math.round((sess.safety.medFraction || 0) * 100)}% of Fitzpatrick ${sess.safety.fitzpatrick || sd.fitzpatrick || 'I'} base MED (not a personal threshold; sunscreen not credited as extra safe time)`);
+ if (sess.safety.fitzpatrickAssumed) lines.push('Skin type status: conservative Fitzpatrick I assumption was used because skin type was unset.');
+ if (sess.safety.medicationThresholdUnknown) lines.push('Medication photosensitivity: caution flag only; no numeric threshold adjustment was invented.');
}
return lines.join('\n');
@@ -229,17 +181,17 @@ const SYSTEM_PROMPT = [
'Return ONLY valid JSON with three keys: {"dot":"green|yellow|red|gray","tip":"string","detail":"string"}.',
'',
'dot:',
- ' green = the session was worthwhile relative to the user\'s goals AND stayed safely under burn / eye-strain thresholds',
- ' yellow = useful but with a caveat (e.g. low yield, near-MED, eye exposure with shaded eyes, or single-side rotation)',
- ' red = counterproductive (over MED, eye damage risk, or prolonged glass / heavy clothing wasted the session)',
+ ' green = the record is complete and no deterministic warning is present; describe the strongest modeled signals without calling the exposure sufficient, beneficial, or medically safe',
+ ' yellow = a recorded caution or material uncertainty is present (for example near-MED, assumed skin type, medication photosensitivity, or incomplete exposure context); low channel output alone is not a problem',
+ ' red = the supplied deterministic data records base MED reached or exceeded; never invent an ocular limit or other threshold that is not supplied',
' gray = not enough info (no doses computed, no weather, no body or eye data)',
'',
'Solar phase matters. Different parts of the solar cycle carry distinct biology:',
- ' • sunrise / civil dawn: blue+violet light pre-horizon clears pineal melatonin and triggers cortisol awakening; the moment the sun crosses the horizon and UVA begins to register (~3° elevation) drives nitric-oxide release from skin/mucosa and is the strongest natural circadian phase-advance signal of the day. Eye exposure during this transition is uniquely valuable and is ~1000× safer than direct gaze later in the arc.',
- ' • sunset / civil dusk: mirror — phase-delaying signal, melatonin onset preparation. UVA fadeout still gives a final NO/POMC bump.',
+ ' • sunrise / civil dawn: retain the blue/violet circadian and UVA/NO wellness hypotheses, but discuss ambient open-sky light only. Never recommend direct solar gaze at any elevation.',
+ ' • sunset / civil dusk: timing context may support evening phase signaling; describe fading UVA/UVB without claiming an endocrine outcome.',
' • midday near-zenith: peak UVB → vitamin D, peak burn risk, weakest circadian phase signal.',
'',
- 'When "Solar phase" flags a sunrise/sunset transition or twilight window, the verdict MUST address that biology, even if every dose channel shows 0%. Heavy cloud cover dampens the spectral dose model but does NOT erase the value of the session: the visual brightening cue alone entrains the suprachiasmatic master clock, the photic zeitgeber works through retinal melanopsin which saturates at modest illuminance (~100-1000 lux), and being outdoors at this solar phase is qualitatively different from staying indoors. Vitamin-D yield will be near zero (UVB requires elevation > ~10°) and that is NEVER a yellow flag for a sunrise/sunset session — the value lives in circadian + NO + POMC + cortisol awakening, not in UVB-dependent channels. A green verdict is appropriate when the user attended the transition, even with cloud-suppressed doses.',
+ 'When "Solar phase" flags sunrise/sunset or twilight, address the timing signal even when UV-weighted channels are small. Treat modeled channels as wellness hypotheses, not proof of an endocrine outcome. Low vitamin-D-effective UV is expected at low solar elevation and is not a reason to extend exposure. Never recommend looking at the sun.',
'',
'tip: one sentence, max 14 words. Reference specific numbers + the solar phase when relevant. Direct, no preamble.',
'detail: 1–2 sentences. Explain the why, naming the specific dose / MED% / channel / solar phase that drove the verdict. No restating the data verbatim.',
@@ -258,8 +210,11 @@ const engine = createAIVerdict({
buildContext: buildSingleSessionContext,
systemPrompt: SYSTEM_PROMPT,
maxTokens: 400,
- canAnalyze: (s) => !!s?.endedAt,
- shouldAutoFire: (s) => !!s?.endedAt,
+ canAnalyze: (s) => !!s?.endedAt && !!s?.doses && !!s?.safety && (!s.calculationStatus || s.calculationStatus === 'computed'),
+ // Session interpretation is intentionally on-demand in the detail dialog.
+ // Today and Weekly Review already provide automatic synthesis; auto-firing
+ // here would duplicate them for every saved row.
+ shouldAutoFire: () => false,
getAllTargets: getSessions,
});
@@ -270,8 +225,15 @@ export const maybeAnalyzeSessionAfterFinish = engine.maybeAfterFinish;
// ─── Render helpers ────────────────────────────────────────────────────
+function _hasCompleteModeledSession(sess) {
+ return !!sess?.endedAt
+ && !!sess?.doses
+ && !!sess?.safety
+ && (!sess.calculationStatus || sess.calculationStatus === 'computed');
+}
+
export function renderSessionAIInline(sess) {
- if (!sess?.endedAt) return '';
+ if (!_hasCompleteModeledSession(sess)) return '';
// Render cached verdict even when no provider — pre-populated demos +
// cross-device-synced verdicts shouldn't disappear just because the
// current device hasn't configured an AI key. Provider-gate only the
@@ -309,7 +271,7 @@ export function renderSessionAIInline(sess) {
}
export function renderSessionAIDetail(sess) {
- if (!sess?.endedAt) return '';
+ if (!_hasCompleteModeledSession(sess)) return '';
// Render cached verdict even when no provider — pre-populated demos +
// cross-device-synced verdicts shouldn't disappear just because the
// current device hasn't configured an AI key. Provider-gate only the
diff --git a/js/sun-channel-metrics.js b/js/sun-channel-metrics.js
index 36a087e4..9e39ef87 100644
--- a/js/sun-channel-metrics.js
+++ b/js/sun-channel-metrics.js
@@ -75,15 +75,15 @@ export function formatChannelUnit(channelKey, channelAu, durationMin, fitzpatric
const central = useSessionCap
? vitaminDIUPerSession(channelAu, fitzpatrick, uvi, rotatedSides, state.importedData?.genetics || null, bodyFraction)
: vitaminDIU(channelAu, fitzpatrick, uvi, rotatedSides, state.importedData?.genetics || null);
- if (central === 0) return 'below UVI threshold';
+ if (central === 0) return 'negligible modeled UVB';
const fmt = (n) => {
if (n >= 10000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
if (n >= 1000) return Math.round(n / 100) * 100;
if (n >= 100) return Math.round(n / 10) * 10;
return Math.round(n);
};
- if (central >= VITD_SAT_FLAG) return `~${fmt(central)} IU (saturated)`;
- return `~${fmt(central)} IU`;
+ if (central >= VITD_SAT_FLAG) return `~${fmt(central)} IU-eq (reporting ceiling)`;
+ return `~${fmt(central)} IU-eq`;
}
if (channelKey === 'nir_solar' || channelKey === 'pbm_red' || channelKey === 'pbm_nir') {
const j = pbmJoulesPerCm2(channelAu);
@@ -93,9 +93,9 @@ export function formatChannelUnit(channelKey, channelAu, durationMin, fitzpatric
}
if (channelKey === 'circadian' && durationMin > 0) {
const lux = circadianMelanopicLux(channelAu, durationMin);
- if (lux >= 1000) return '~' + (lux / 1000).toFixed(1).replace(/\.0$/, '') + 'k M-EDI lux';
- if (lux >= 100) return '~' + Math.round(lux / 10) * 10 + ' M-EDI lux';
- return '~' + Math.round(lux) + ' M-EDI lux';
+ if (lux >= 1000) return '~' + (lux / 1000).toFixed(1).replace(/\.0$/, '') + 'k estimated melanopic-equivalent lx';
+ if (lux >= 100) return '~' + Math.round(lux / 10) * 10 + ' estimated melanopic-equivalent lx';
+ return '~' + Math.round(lux) + ' estimated melanopic-equivalent lx';
}
return '';
}
@@ -260,12 +260,18 @@ function dailySupplementVitaminDIU() {
export function vitaminDBudgetStatus() {
const supplementIU = dailySupplementVitaminDIU();
const sunIU = cumulativeVitaminDIUToday();
- const total = supplementIU + sunIU;
const supplementUL = 4000;
return {
supplementIU,
+ // This optical model's sunlight IU-equivalent is a comparison aid, not
+ // ingested vitamin D. It must never be added to oral intake or tested
+ // against a dietary tolerable upper intake level.
sunIU,
- total,
+ sunIUEquivalent: sunIU,
+ totalIntakeIU: supplementIU,
+ // Backward-compatible field: now intentionally means modeled oral
+ // supplement intake only instead of an invalid oral+sunlight sum.
+ total: supplementIU,
supplementUL,
exceedsSupplementUL: supplementIU > supplementUL,
};
diff --git a/js/sun-context-environment.js b/js/sun-context-environment.js
index ac61011a..443ae91e 100644
--- a/js/sun-context-environment.js
+++ b/js/sun-context-environment.js
@@ -6,6 +6,7 @@ import {
getRoomEveningHoursAfterSunset,
roomUsesEveningAfterSunset,
} from './light-env-evening.js';
+import { isQuantitativeDarknessMeasurement } from './light-env-model.js';
import {
sunContextDeps,
_debugWarn,
@@ -29,8 +30,6 @@ export function lightEnvironmentBlock() {
if (eveningRooms.length > 0) {
s += `; ${eveningRooms.length} used after sunset`;
}
- const blueBlocked = rooms.filter(r => r.blueBlocker).length;
- if (blueBlocked > 0) s += `; ${blueBlocked} with blue-blocker`;
s += '\n';
// Per-room one-liner: name, primary source, hours/day, severity.
for (const r of rooms) {
@@ -39,23 +38,24 @@ export function lightEnvironmentBlock() {
const evHr = getRoomEveningHoursAfterSunset(r);
const evening = evHr ? `${evHr}h after sunset` : '';
const severity = r.aiAnalysis?.dot ? ` · AI verdict: ${r.aiAnalysis.dot}` : '';
- const parts = [src, hrs, evening].filter(Boolean).join(', ');
+ const daylight = r.daylightLevel && r.daylightLevel !== 'unknown' ? `${r.daylightLevel} daylight` : '';
+ const parts = [src, daylight, hrs, evening].filter(Boolean).join(', ');
s += ` - ${_safeText(r.name) || 'Room'} (${parts})${severity}\n`;
}
}
if (screens.length > 0) {
const evening = screens.filter(sc => sc.eveningUseAfterSunset).length;
- const blueOff = screens.filter(sc => sc.eveningUseAfterSunset && !sc.blueBlocker).length;
+ const blueOff = screens.filter(sc => sc.eveningUseAfterSunset && !sc.blueBlockerEnabled).length;
s += `- Screens tracked: ${screens.length}`;
if (evening > 0) s += `; ${evening} used after sunset`;
- if (blueOff > 0) s += ` (${blueOff} without blue-blocker — direct retinal melatonin suppression)`;
+ if (blueOff > 0) s += `; ${blueOff} without a recorded blue-reduction measure`;
s += '\n';
// Per-screen one-liner: device type, hours, evening use, blocker status.
for (const sc of screens) {
const hours = sc.hoursPerDay ? `${sc.hoursPerDay}h/day` : '';
const eveHr = sc.eveningUseAfterSunset || 0;
const eve = eveHr > 0 ? `${eveHr}h after sunset` : 'daytime only';
- const blocker = sc.blueBlockerEnabled ? '✓ blocker' : '✗ no blocker';
+ const blocker = sc.blueBlockerEnabled ? 'blue reduction noted (not zero exposure)' : 'no blue reduction noted';
const parts = [hours, eve, blocker].filter(Boolean).join(', ');
s += ` - ${sc.device || 'screen'} (${parts})\n`;
}
@@ -106,29 +106,32 @@ export function lightEnvironmentBlock() {
const prior = priorByRoom[roomId] || {};
const lux = formatMetric(
byTool.lux?.value, prior.lux?.value,
- value => `${Math.round(value)} lux`,
+ value => `${Math.round(value)} photopic lux`,
(current, previous) => {
const delta = Math.round(current - previous);
return (delta > 0 ? '+' : '') + delta + ' lux';
});
const cct = formatMetric(
byTool.cct?.value, prior.cct?.value,
- value => `${Math.round(value)}K`,
+ value => `~${Math.round(value / 100) * 100}K camera estimate`,
(current, previous) => {
const delta = Math.round(current - previous);
return (delta > 0 ? '+' : '') + delta + 'K';
});
const flicker = formatMetric(
byTool.flicker?.value, prior.flicker?.value,
- value => `flicker ${Math.round(value)}`,
+ value => `camera banding score ${Math.round(value)}`,
(current, previous) => {
const delta = Math.round(current - previous);
return (delta > 0 ? '+' : '') + delta;
});
const darkness = formatMetric(
byTool.darkness?.value, prior.darkness?.value,
- value => `darkness ${Number(value).toFixed(1)} lux`,
+ value => byTool.darkness?.extra?.method === 'camera-relative'
+ ? `sleep-light camera check ${byTool.darkness.extra?.levelLabel || 'qualitative'}`
+ : `sleep-time meter ${Number(value).toFixed(1)} photopic lux`,
(current, previous) => {
+ if (byTool.darkness?.extra?.method === 'camera-relative' || prior.darkness?.extra?.method === 'camera-relative') return null;
const delta = Number((current - previous).toFixed(1));
return (delta > 0 ? '+' : '') + delta + ' lux';
});
@@ -152,13 +155,13 @@ export function lightEnvironmentBlock() {
try {
const burden = sunContextDeps.computeIndoorBurden();
if (burden && typeof burden === 'object') {
- const burdenLabel = burden.label || ['Light load', 'Moderate load', 'Heavy load'][burden.tier] || 'unknown';
- let line = `- Indoor light burden: ${burdenLabel} (tier ${burden.tier}/2 · 0=light, 2=heavy across screens/sleep/daylight)`;
+ const burdenLabel = burden.label || ['Generally aligned', 'Mixed signals', 'Needs attention'][burden.tier] || 'unknown';
+ let line = `- Indoor light screening picture: ${burdenLabel} (tier ${burden.tier}/2; heuristic context, not measured dose)`;
if (typeof sunContextDeps.computeDeficitAxes === 'function') {
try {
const axes = sunContextDeps.computeDeficitAxes();
if (axes && (axes.d2 != null || axes.d3 != null)) {
- line += ` · d2=${(axes.d2 ?? 0).toFixed(2)} (intensity gap, 0=no gap, 5+=severe) · d3=${(axes.d3 ?? 0).toFixed(2)} (after-sunset blue, 0=clean, 3+=heavy)`;
+ line += ` · d2=${(axes.d2 ?? 0).toFixed(2)} and d3=${(axes.d3 ?? 0).toFixed(2)} (bounded 0–10 screening scores, not hours/dose) · daylight evidence ${axes.daylightKnown || 0}, evening evidence ${axes.eveningKnown || 0}`;
}
} catch (e) {
_debugWarn('[sun-context] computeDeficitAxes failed', e);
@@ -184,13 +187,14 @@ export function lightEnvironmentBlock() {
const warnings = [];
for (const measurement of recent) {
if (measurement.tool === 'flicker' && Number.isFinite(measurement.value) && measurement.value >= 2) {
- warnings.push(`flicker score ${measurement.value} (visible PWM)${roomTag(measurement.roomId)}`);
- } else if (measurement.tool === 'darkness' && Number.isFinite(measurement.value) && measurement.value > 1) {
- warnings.push(`bedroom too bright at the pillow (${measurement.value.toFixed(1)} lux; WHO threshold for full melatonin = <1 lux)${roomTag(measurement.roomId)}`);
+ warnings.push(`camera banding score ${measurement.value} (rolling-shutter pattern; no frequency inferred)${roomTag(measurement.roomId)}`);
+ } else if (isQuantitativeDarknessMeasurement(measurement) && measurement.value > 1) {
+ warnings.push(`sleep-time meter entry ${measurement.value.toFixed(1)} photopic lux (spectrum and melanopic EDI unknown)${roomTag(measurement.roomId)}`);
} else if (measurement.tool === 'cct' && Number.isFinite(measurement.value) && measurement.value > 3500) {
- const hour = measurement.takenAt ? new Date(measurement.takenAt).getHours() : null;
+ const capturedAt = measurement.capturedAt || measurement.takenAt;
+ const hour = capturedAt ? new Date(capturedAt).getHours() : null;
if (hour != null && hour >= 19) {
- warnings.push(`after-sunset CCT ${measurement.value}K (>3500K = still cool/blue when sun has set)${roomTag(measurement.roomId)}`);
+ warnings.push(`after-sunset camera warm/cool estimate ~${Math.round(measurement.value / 100) * 100}K (not spectrum or melanopic EDI)${roomTag(measurement.roomId)}`);
}
}
}
diff --git a/js/sun-context-hooks.js b/js/sun-context-hooks.js
index b7fc6f7b..e01ad1bf 100644
--- a/js/sun-context-hooks.js
+++ b/js/sun-context-hooks.js
@@ -22,17 +22,24 @@ import {
computeIndoorBurdenForEnvironment,
} from './light-env-model.js';
import { getEnvironment, isActiveToday } from './light-env-store.js';
+import { state } from './state.js';
import { configureDataContextDependencies } from './data.js';
import { configureLabContext, invalidateLabContextCache } from './lab-context.js';
import { isDebugMode } from './utils.js';
import { buildSunContext, configureSunContext } from './sun-context.js';
function computeDeficitAxes() {
- return computeDeficitAxesForEnvironment(getEnvironment(), { isActiveToday });
+ return computeDeficitAxesForEnvironment(getEnvironment(), {
+ isActiveToday,
+ getMeasurementsForRoom: roomId => (state.importedData?.lightMeasurements || []).filter(m => m?.roomId === roomId),
+ });
}
function computeIndoorBurden() {
- return computeIndoorBurdenForEnvironment(getEnvironment(), { isActiveToday });
+ return computeIndoorBurdenForEnvironment(getEnvironment(), {
+ isActiveToday,
+ getMeasurementsForRoom: roomId => (state.importedData?.lightMeasurements || []).filter(m => m?.roomId === roomId),
+ });
}
configureLabContext({ buildSunContext });
diff --git a/js/sun-context-session-tools.js b/js/sun-context-session-tools.js
index 25be27c2..08822660 100644
--- a/js/sun-context-session-tools.js
+++ b/js/sun-context-session-tools.js
@@ -29,7 +29,7 @@ function projectSession(sess, fields) {
sed: s.sed != null ? +s.sed.toFixed(2) : null,
medFraction: s.medFraction != null ? +s.medFraction.toFixed(2) : null,
fitzpatrick: s.fitzpatrick || null,
- retinalUV: s.retinalUV != null ? +s.retinalUV.toFixed(1) : null,
+ ocularActinicUV: (s.ocularActinicUV ?? s.retinalUV) != null ? +(s.ocularActinicUV ?? s.retinalUV).toFixed(1) : null,
};
}
if (fields.includes('atmosphere') && sess.atmosphere) {
diff --git a/js/sun-context.js b/js/sun-context.js
index f0250a9f..97e8723a 100644
--- a/js/sun-context.js
+++ b/js/sun-context.js
@@ -2,7 +2,7 @@
// sun-context.js — buildSunContext({ tier }) for AI integration.
// Two-tier prompt blob; per-session detail moved to a tool-call API.
//
-// tier: 'always' ~520 tok — Lifelight summary + 7d rolling + active deficits
+// tier: 'always' ~520 tok — Lifelight summary + 7d source signals
// + indoor environment (every chat)
// tier: 'standard' +1200 tok — + 30-day session table + biomarker correlations
// (auto-escalated when chat keywords trigger)
@@ -23,8 +23,6 @@ import {
import { lightEnvironmentBlock } from './sun-context-environment.js';
import { isLightSunContextEnabled } from './lab-context.js';
-const DEFAULT_TIER_LABELS = ['none', 'low', 'moderate', 'good', 'strong'];
-
export { configureSunContext } from './sun-context-runtime.js';
export { getSunSessionsSlice, getSunSessionDetail } from './sun-context-session-tools.js';
@@ -180,14 +178,10 @@ function _trimToBudget(ctx, budget, aggressive = false) {
// ─── Tier: always (~520 tok) ───────────────────────────────────────────
function alwaysTierBlock(sessions) {
- // Combine outdoor sun + indoor device contributions — channels reflect the
- // full biological state, not just one source class.
+ // Keep sunlight and devices separate. These totals describe recorded light
+ // inputs, not biological completion or a single combined light score.
const sunTot7 = (typeof sunContextDeps.rollingChannelTotals === 'function' ? sunContextDeps.rollingChannelTotals(7) : null) || {};
- const sunTot30 = (typeof sunContextDeps.rollingChannelTotals === 'function' ? sunContextDeps.rollingChannelTotals(30) : null) || {};
const devTot7 = (typeof sunContextDeps.rollingDeviceTotals === 'function' ? sunContextDeps.rollingDeviceTotals(7) : null) || {};
- const devTot30 = (typeof sunContextDeps.rollingDeviceTotals === 'function' ? sunContextDeps.rollingDeviceTotals(30) : null) || {};
- const totals7d = mergeTotalsCtx(sunTot7, devTot7);
- const totals30d = mergeTotalsCtx(sunTot30, devTot30);
const medToday = typeof sunContextDeps.cumulativeMEDToday === 'function' ? sunContextDeps.cumulativeMEDToday() : 0;
const lastSession = sessions.filter(s => s.endedAt).slice(-1)[0];
const activeSession = sessions.find(s => !s.endedAt);
@@ -239,34 +233,19 @@ function alwaysTierBlock(sessions) {
}).join('\n')
: '';
- // Active session + most-recent session lines drop when null. Verbose
- // 30-day channel totals were dropped from always-tier output — they're
- // computed for deficit detection but the 7-day totals are the
- // recency-relevant signal in chat. Standard tier reintroduces the
- // 30-day breakdown.
+ // Active session + most-recent session lines drop when null. The short
+ // source summary deliberately avoids grades and targets; missing logs are
+ // a measurement gap, not proof of missing biology.
let block = `### Lifelight summary
- Outdoor sessions: ${sessions.length} · device sessions: ${devSessions.length} · devices in library: ${devices.length}${baselineLine}${deviceListLine}
-- Today's cumulative MED: ${(medToday * 100).toFixed(0)}% (% of personal daily Min Erythemal Dose)${medToday > 1 ? ' (over MED — exposure risk)' : ''}
+- Today's modeled erythemal dose: ${(medToday * 100).toFixed(0)}% of a Fitzpatrick base MED reference (not a personal threshold; sunscreen is not credited as extra safe time)${medToday > 1 ? ' (base MED reached — stop UV exposure)' : ''}
${activeSession ? `- ACTIVE SESSION in progress (started ${formatRelative(activeSession.startedAt)})\n` : ''}${lastSession ? `- Most recent outdoor session: ${formatRelative(lastSession.endedAt)} (${Math.round(lastSession.durationMin || 0)} min)\n` : ''}
-### 7-day rollup (sun + devices combined; ●●●●=hit weekly target, ●●●○=good, ●●○○=moderate, ●○○○=low, ○○○○=none)
-${formatChannelTotals(totals7d)}
-
-`;
-
- // Deficit detection — flag channels at <10% of literature reference (rough
- // heuristic). Gated behind a real baseline window so a brand-new user with
- // zero exposure logs isn't told they have 6 simultaneous deficits — that's
- // a measurement gap, not a signal. Once they've logged ≥7 events of any
- // kind we have enough to distinguish "user doesn't expose" from "user
- // hasn't logged yet."
- const baselineCount = sessions.length + devSessions.length;
- const deficits = baselineCount >= 7 ? detectDeficits(totals30d) : [];
- if (deficits.length > 0) {
- block += `### Active light deficits
-${deficits.map(d => `- ${d.label}: ${d.note}`).join('\n')}
+### Light-responsive signals — last 7 days
+- Sunlight: ${formatLoggedSignals(sunTot7)}
+- Devices, kept separate: ${formatLoggedSignals(devTot7)}
+- These are recorded light inputs, not daily requirements or measured body responses.
`;
- }
// Indoor light environment — rooms, screens, audits. Most users
// spend 8-14 h/day under indoor lights, so the AI needs the picture
@@ -333,30 +312,6 @@ function calibrationLine() {
return `\n### Calibration anchor (model vs ground truth)\n- ${parts.join(' · ')}\n\n`;
}
-function detectDeficits(totals30d) {
- const out = [];
- // Empty channels = clear deficit signal
- if ((totals30d.vitamin_d || 0) === 0) {
- out.push({ label: 'Channel 1 (vit D)', note: 'no UVB exposure logged in 30d — supplement-only path or geographic UVB unavailability' });
- }
- if ((totals30d.circadian || 0) === 0) {
- out.push({ label: 'Channel 5 (circadian)', note: 'no eye-exposure outdoor light logged in 30d — SCN entrainment likely deficient (Hattar/Huberman literature suggests minimum AM dose)' });
- }
- if ((totals30d.nir_solar || 0) === 0) {
- out.push({ label: 'Channel 6 (NIR-solar)', note: 'no broadband NIR logged in 30d — Wunsch/Jeffery optical-tissue-window not active; consider solar exposure or PBM panel' });
- }
- if ((totals30d.no_cv || 0) === 0) {
- out.push({ label: 'Channel 3 (NO/cardiovascular)', note: 'no UVA exposure logged in 30d — Liu/Oplander photolabile NO release pathway not engaged' });
- }
- if ((totals30d.pbm_red || 0) === 0) {
- out.push({ label: 'Channel 7 (PBM red 660nm)', note: 'no narrowband red-light therapy logged in 30d — Hamblin PBM cytochrome-c-oxidase + ATP-cascade pathway not engaged from device sources' });
- }
- if ((totals30d.pbm_nir || 0) === 0) {
- out.push({ label: 'Channel 8 (PBM NIR 810/850nm)', note: 'no narrowband near-IR therapy logged in 30d — deeper-tissue Hamblin PBM not engaged from device sources' });
- }
- return out;
-}
-
// ─── Tier: standard ────────────────────────────────────────────────────
//
// Pre-2026-05-10: emitted per-session tables for outdoor sun (last 30) +
@@ -383,15 +338,14 @@ function standardTierBlock(sessions) {
return _correlationsBlock();
}
- // 6-week trend per channel. Bucket by 7-day windows ending now;
- // bucket[5] = last 7d, bucket[0] = 35–42d ago. Sum channel-au across
- // both sun + device sessions per bucket so the AI sees the combined
- // shape, then convert to user-facing units (IU / lux·h / J/cm²).
+ // 6-week trend per channel. Keep outdoor and device records in separate
+ // buckets so a targeted device never reads as full-spectrum sunlight.
const WEEKS = 6;
const now = Date.now();
- const all = [...sun, ...dev];
const channels = ['vitamin_d', 'circadian', 'nir_solar', 'pbm_red', 'pbm_nir', 'no_cv', 'pomc'];
- const buckets = Object.fromEntries(channels.map(k => [k, new Array(WEEKS).fill(0)]));
+ const makeBuckets = () => Object.fromEntries(channels.map(k => [k, new Array(WEEKS).fill(0)]));
+ const sunBuckets = makeBuckets();
+ const deviceBuckets = makeBuckets();
// Same per-session cap path the always-tier 7d rollup uses, so the
// weekly trend integrates correctly for high-output device sessions
// (without this, raw channel-au sums to nonsense for vit-D).
@@ -406,29 +360,28 @@ function standardTierBlock(sessions) {
}
return s.bodyArea ? (_broadFracs[s.bodyArea] ?? null) : null;
};
- for (const s of all) {
- const weekIdx = Math.floor((now - s.endedAt) / (7 * 86400 * 1000));
- if (weekIdx < 0 || weekIdx >= WEEKS) continue;
- const slot = WEEKS - 1 - weekIdx;
- const isSun = !!s.location || s.atmosphere || s.bodyExposure;
- const fitz = isSun ? (s.safety?.fitzpatrick || 'III') : _fitzForDevice;
- const uvi = isSun ? s.atmosphere?.uvIndex : null;
- const rotated = !!s.bodyExposure?.rotatedSides;
- const bf = isSun ? s.bodyExposure?.fraction : _devBodyFrac(s);
- for (const k of channels) {
- const au = s.doses?.[k];
- if (!Number.isFinite(au) || au <= 0) continue;
- // Vit-D goes through the cap; everything else is raw channel-au
- // (correctly, per sun-spectrum.js — only vit-D has biological
- // saturation; circadian / NIR / PBM / NO / POMC accumulate
- // linearly in their respective windows).
- if (k === 'vitamin_d' && _perSession) {
- buckets[k][slot] += _perSession(au, fitz, uvi, rotated, _genetics, bf);
- } else {
- buckets[k][slot] += au;
+ const addSessions = (sourceSessions, buckets, isSun) => {
+ for (const s of sourceSessions) {
+ const weekIdx = Math.floor((now - s.endedAt) / (7 * 86400 * 1000));
+ if (weekIdx < 0 || weekIdx >= WEEKS) continue;
+ const slot = WEEKS - 1 - weekIdx;
+ const fitz = isSun ? (s.safety?.fitzpatrick || 'III') : _fitzForDevice;
+ const uvi = isSun ? s.atmosphere?.uvIndex : null;
+ const rotated = isSun && !!s.bodyExposure?.rotatedSides;
+ const bf = isSun ? s.bodyExposure?.fraction : _devBodyFrac(s);
+ for (const k of channels) {
+ const au = s.doses?.[k];
+ if (!Number.isFinite(au) || au <= 0) continue;
+ if (k === 'vitamin_d' && _perSession) {
+ buckets[k][slot] += _perSession(au, fitz, uvi, rotated, _genetics, bf);
+ } else {
+ buckets[k][slot] += au;
+ }
}
}
- }
+ };
+ addSessions(sun, sunBuckets, true);
+ addSessions(dev, deviceBuckets, false);
// Render: only emit channels with non-zero buckets so empty channels
// don't bloat the block. Format depends on channel: IU for vit-D,
@@ -452,40 +405,44 @@ function standardTierBlock(sessions) {
const labels = {
vitamin_d: 'Vit-D (IU)',
circadian: 'Body clock (lux·h)',
- nir_solar: 'Cellular repair (J/cm²)',
+ nir_solar: 'Cell energy & repair (J/cm²)',
pbm_red: 'Red 660nm (J/cm²)',
pbm_nir: 'NIR 810/850 (J/cm²)',
no_cv: 'Cardiovascular (au)',
pomc: 'Mood/hormones (au)',
};
- const lines = [];
- for (const k of channels) {
- const b = buckets[k];
- if (b.every(v => v === 0)) continue;
- let formatted;
- if (k === 'vitamin_d') {
- formatted = b.map(v => v > 0 ? fmtIUCompact(v) : '0').join('→');
- } else if (k === 'circadian') {
- formatted = b.map(v => v > 0 ? fmtIUCompact(_luxHFromAu(v)) : '0').join('→');
- } else if (k === 'nir_solar' || k === 'pbm_red' || k === 'pbm_nir') {
- formatted = b.map(v => {
- if (v <= 0) return '0';
- const j = typeof sunContextDeps.pbmJoulesPerCm2 === 'function' ? sunContextDeps.pbmJoulesPerCm2(v) : v / 10000;
- return fmtJ(j);
- }).join('→');
- } else {
- // no_cv / pomc — raw channel-au, compact
- formatted = b.map(v => v > 0 ? fmtIUCompact(v) : '0').join('→');
+ const renderBucketLines = (buckets) => {
+ const lines = [];
+ for (const k of channels) {
+ const b = buckets[k];
+ if (b.every(v => v === 0)) continue;
+ let formatted;
+ if (k === 'vitamin_d') {
+ formatted = b.map(v => v > 0 ? fmtIUCompact(v) : '0').join('→');
+ } else if (k === 'circadian') {
+ formatted = b.map(v => v > 0 ? fmtIUCompact(_luxHFromAu(v)) : '0').join('→');
+ } else if (k === 'nir_solar' || k === 'pbm_red' || k === 'pbm_nir') {
+ formatted = b.map(v => {
+ if (v <= 0) return '0';
+ const j = typeof sunContextDeps.pbmJoulesPerCm2 === 'function' ? sunContextDeps.pbmJoulesPerCm2(v) : v / 10000;
+ return fmtJ(j);
+ }).join('→');
+ } else {
+ formatted = b.map(v => v > 0 ? fmtIUCompact(v) : '0').join('→');
+ }
+ lines.push(` ${labels[k]}: ${formatted}`);
}
- lines.push(` ${labels[k]}: ${formatted}`);
- }
+ return lines;
+ };
+ const sunLines = renderBucketLines(sunBuckets);
+ const deviceLines = renderBucketLines(deviceBuckets);
let block = '';
- if (lines.length > 0) {
- // Header parallels buildWearableContext's "Weekly trend (last 6w)"
- // exactly so an agent reading both sections sees the same shape
- // language for both lenses.
- block += `### Weekly trend (last 6w, oldest→newest)\n${lines.join('\n')}\n\n`;
+ if (sunLines.length > 0 || deviceLines.length > 0) {
+ block += '### Weekly light trend (last 6w, oldest→newest; sources kept separate)\n';
+ if (sunLines.length > 0) block += `Sunlight:\n${sunLines.join('\n')}\n`;
+ if (deviceLines.length > 0) block += `Devices:\n${deviceLines.join('\n')}\n`;
+ block += '\n';
}
// Session counts — the only per-event detail the always-on payload
@@ -531,151 +488,17 @@ const CHANNEL_LABELS = {
no_cv: 'NO/cardiovascular',
violet_eye: 'Violet/outdoor-eye',
circadian: 'Circadian (melanopic)',
- nir_solar: 'NIR-solar broadband',
+ nir_solar: 'Cell energy & repair',
pbm_red: 'PBM red',
pbm_nir: 'PBM near-IR',
};
-// 7-day rollup in user-meaningful units rather than opaque channel-au.
-// `channel-au` is fine for correlations + tier math, but the AI was
-// reporting raw numbers ("Vit-D synthesis: 104", "Circadian: 1,005,928")
-// that don't ground to anything users can act on. This translates each
-// channel to its native unit + a tier label (none/low/moderate/good/strong)
-// against the literature-derived weekly target (= 7 × dailyTarget).
-//
-// Conventions per channel:
-// vitamin_d: sum of per-session IU equivalents (Holick + Fitzpatrick gating)
-// circadian: sum of melanopic lux·hours at the eye
-// nir_solar / pbm_red / pbm_nir: sum of J/cm²
-// pomc / no_cv / violet_eye: tier label only (no defensible single SI unit)
-//
-// `totals` carries the channel-au sums for tier classification; per-unit
-// rollups walk `sessions` directly so UVI gating + Fitzpatrick scaling +
-// saturation caps apply per-session (they're non-linear, can't post-hoc).
-function formatChannelTotals(totals) {
- // Targets are daily; rolling window is 7d, so weekly target is ×7. Use
- // the canonical weeklyChannelTier so the AI rollup, the dashboard
- // strip, and the per-channel drill-down all agree.
- const tierLabel = typeof sunContextDeps.tierLabel === 'function'
- ? sunContextDeps.tierLabel
- : (t) => DEFAULT_TIER_LABELS[t] || 'none';
- const channelTier = typeof sunContextDeps.weeklyChannelTier === 'function'
- ? sunContextDeps.weeklyChannelTier
- : ((v, k) => {
- const meta = (sunContextDeps.channelDisplay || {})[k];
- if (!meta || !meta.dailyTarget) return 0;
- const target = meta.dailyTarget * 7;
- if (!Number.isFinite(v) || v <= 0) return 0;
- const r = v / target;
- if (r < 0.20) return 1;
- if (r < 0.55) return 2;
- if (r < 1.00) return 3;
- return 4;
- });
-
- // Per-unit rollup helpers. Walk recent sessions/devices so
- // per-session conversions (UVI gate, Fitzpatrick, IU saturation) apply
- // correctly. Sum afterwards rather than scaling the channel-au total.
- const cutoff = Date.now() - 7 * 86400 * 1000;
- const sunSessions = (state.importedData?.sunSessions || []).filter(s => s.endedAt && s.endedAt >= cutoff);
- const deviceSessions = (state.importedData?.deviceSessions || []).filter(s => s.endedAt && s.endedAt >= cutoff);
-
- // Three-cap rollup (matches rollingVitaminDIU in sun.js): per-session
- // body-fraction cap → per-day saturation cap → sum capped days. Both
- // functions are user-visible 7-day totals and must agree.
- const _gx = state.importedData?.genetics || null;
- const _perSession = typeof sunContextDeps.vitaminDIUPerSession === 'function' ? sunContextDeps.vitaminDIUPerSession : null;
- const _cap = Number.isFinite(sunContextDeps.vitaminDDailySaturationIU) ? sunContextDeps.vitaminDDailySaturationIU : 20000;
- const _localDayKey = (ts) => {
- const d = new Date(ts);
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
- };
- const _dayTotals = {};
- const _add = (key, iu) => { _dayTotals[key] = (_dayTotals[key] || 0) + iu; };
- for (const s of sunSessions) {
- const au = s.doses?.vitamin_d;
- if (!Number.isFinite(au) || au <= 0) continue;
- const _bodyFrac = s.bodyExposure?.fraction;
- if (_perSession) {
- _add(_localDayKey(s.endedAt), _perSession(au, s.safety?.fitzpatrick || 'III', s.atmosphere?.uvIndex, !!s.bodyExposure?.rotatedSides, _gx, _bodyFrac));
- } else {
- _add(_localDayKey(s.endedAt), au * 60);
- }
- }
- // UVB device sessions. uvi=null (device IS the UVB source);
- // rotatedSides=false (devices track skin% on bodyAreas, not anatomical sides).
- const _fitzForDevice = state.importedData?.sunDefaults?.fitzpatrick || 'III';
- const _fracByKey = _bodyRegionFractionByKey();
- const _broadFracs = { face: 0.04, arms: 0.10, torso: 0.13, legs: 0.30, 'whole-body': 0.92, targeted: 0.05 };
- for (const s of deviceSessions) {
- const au = s.doses?.vitamin_d;
- if (!Number.isFinite(au) || au <= 0) continue;
- let _bodyFrac = null;
- if (Array.isArray(s.bodyAreas) && s.bodyAreas.length > 0) {
- _bodyFrac = s.bodyAreas.reduce((acc, k) => acc + (_fracByKey[k] || 0), 0);
- } else if (s.bodyArea) {
- _bodyFrac = _broadFracs[s.bodyArea] ?? null;
- }
- if (_perSession) {
- _add(_localDayKey(s.endedAt), _perSession(au, _fitzForDevice, null, false, _gx, _bodyFrac));
- } else {
- _add(_localDayKey(s.endedAt), au * 60);
- }
+function formatLoggedSignals(totals) {
+ const labels = [];
+ for (const [key, label] of Object.entries(CHANNEL_LABELS)) {
+ if (Number.isFinite(totals?.[key]) && totals[key] > 0) labels.push(label);
}
- let totalIU = 0;
- for (const iu of Object.values(_dayTotals)) totalIU += Math.min(iu, _cap);
-
- let totalLuxHours = 0;
- for (const s of [...sunSessions, ...deviceSessions]) {
- const au = s.doses?.circadian;
- const dur = s.durationMin || 0;
- if (!Number.isFinite(au) || au <= 0 || dur <= 0) continue;
- if (typeof sunContextDeps.circadianMelanopicLux === 'function') {
- const lux = sunContextDeps.circadianMelanopicLux(au, dur);
- totalLuxHours += lux * (dur / 60);
- }
- }
-
- const pbmJ = (k) => {
- let j = 0;
- for (const s of [...sunSessions, ...deviceSessions]) {
- const au = s.doses?.[k];
- if (!Number.isFinite(au) || au <= 0) continue;
- j += typeof sunContextDeps.pbmJoulesPerCm2 === 'function' ? sunContextDeps.pbmJoulesPerCm2(au) : au / 10000;
- }
- return j;
- };
- const totalNirJ = pbmJ('nir_solar');
- const totalRedJ = pbmJ('pbm_red');
- const totalNirPbmJ = pbmJ('pbm_nir');
-
- const fmtIU = (n) => n >= 1000 ? `~${(Math.round(n / 100) * 100).toLocaleString()} IU` : `~${Math.round(n / 10) * 10} IU`;
- const fmtLuxH = (n) => n >= 1000 ? `~${(Math.round(n / 100) * 100).toLocaleString()} lux·h` : `~${Math.round(n)} lux·h`;
- const fmtJ = (n) => n >= 10 ? `${Math.round(n)} J/cm²` : n >= 1 ? `${n.toFixed(1)} J/cm²` : `${n.toFixed(2)} J/cm²`;
- const tier = (k) => {
- const t = channelTier(totals[k] || 0, k);
- return `${tierLabel(t)}`;
- };
- const dot = (k) => {
- const t = channelTier(totals[k] || 0, k);
- return ['○○○○','●○○○','●●○○','●●●○','●●●●'][t] || '○○○○';
- };
-
- // Channel labels are source-agnostic. `pbm_red` and `pbm_nir`
- // accumulate from both sun (broadband solar contains red + near-IR)
- // and therapy panels — calling them "therapy" was misleading when
- // the user has logged sun but no devices.
- const rows = [
- `- Vitamin D synthesis: ${tier('vitamin_d')} ${dot('vitamin_d')} (${totalIU > 0 ? fmtIU(totalIU) : 'none'})`,
- `- Mood & hormones (POMC / β-endorphin): ${tier('pomc')} ${dot('pomc')}`,
- `- Cardiovascular (UVA / nitric oxide): ${tier('no_cv')} ${dot('no_cv')}`,
- `- Outdoor eye light (violet / UV-A at the eye): ${tier('violet_eye')} ${dot('violet_eye')}`,
- `- Body clock (melanopic light at the eye): ${tier('circadian')} ${dot('circadian')} (${totalLuxHours > 0 ? fmtLuxH(totalLuxHours) : 'none'})`,
- `- Cellular repair (broadband near-IR, 600-1400nm): ${tier('nir_solar')} ${dot('nir_solar')} (${totalNirJ > 0 ? fmtJ(totalNirJ) : 'none'})`,
- `- Red wavelengths (~660nm, sun + any panels): ${tier('pbm_red')} ${dot('pbm_red')} (${totalRedJ > 0 ? fmtJ(totalRedJ) : 'none'})`,
- `- Near-IR wavelengths (~810/850nm, sun + any panels): ${tier('pbm_nir')} ${dot('pbm_nir')} (${totalNirPbmJ > 0 ? fmtJ(totalNirPbmJ) : 'none'})`,
- ];
- return rows.join('\n');
+ return labels.length > 0 ? `${labels.join(', ')} logged` : 'no signals logged';
}
function formatCorrelations(pairs) {
@@ -690,12 +513,6 @@ function formatCorrelations(pairs) {
return lines.join('\n');
}
-function mergeTotalsCtx(a, b) {
- const out = { ...a };
- for (const [k, v] of Object.entries(b || {})) out[k] = (out[k] || 0) + v;
- return out;
-}
-
function formatRelative(ts) {
if (!ts) return '?';
const diff = Date.now() - ts;
diff --git a/js/sun-defaults-model.js b/js/sun-defaults-model.js
index ac4b9a45..eb26e030 100644
--- a/js/sun-defaults-model.js
+++ b/js/sun-defaults-model.js
@@ -10,7 +10,7 @@ export const FITZPATRICK_OPTIONS = [
{ key: 'III', label: 'III — sometimes burns, tans gradually (medium)' },
{ key: 'IV', label: 'IV — rarely burns, tans easily (olive/Mediterranean)' },
{ key: 'V', label: 'V — very rarely burns, tans deeply (brown)' },
- { key: 'VI', label: 'VI — never burns (deeply pigmented)' },
+ { key: 'VI', label: 'VI — rarely burns, deeply pigmented (UV damage is still possible)' },
];
export const FITZPATRICK_DESCRIPTOR = [
@@ -19,7 +19,7 @@ export const FITZPATRICK_DESCRIPTOR = [
'sometimes burns, tans gradually',
'rarely burns, tans easily',
'very rarely burns, tans deeply',
- 'never burns, deeply pigmented',
+ 'rarely burns; UV damage is still possible',
];
export const HOME_LIGHT_OPTIONS = [
@@ -42,13 +42,16 @@ export const EYEWEAR_OPTIONS = [
];
export const PHOTOSENSITIVE_OPTIONS = [
- { key: 'none', label: 'None', sub: 'No known photosensitizers' },
- { key: 'mild', label: 'Mild', sub: 'Antihistamines or light NSAID use' },
- { key: 'moderate', label: 'Moderate', sub: "NSAIDs, thiazides, sulfa, St. John's Wort, topical retinol" },
- { key: 'severe', label: 'Severe', sub: 'Tetracyclines, oral retinoids, amiodarone, citrus oils on skin' },
+ { key: 'unknown', label: 'Not reviewed', sub: 'Check medicine, supplement, and topical-product labels' },
+ { key: 'none', label: 'No known warning', sub: 'No sunlight or photosensitivity warning known' },
+ { key: 'mild', label: 'Possible warning', sub: 'A product may increase sunlight sensitivity' },
+ { key: 'moderate', label: 'Known warning', sub: 'A label or clinician advises sun precautions' },
+ { key: 'severe', label: 'Prior reaction', sub: 'Prior phototoxic/photoallergic reaction or strict avoidance advice' },
];
-// Each "yes" is a documented light-environment gap and adds one burden point.
+// Each "yes" records a timing or spectrum-context pattern. This is an
+// educational context map, not a validated clinical scale: several items have
+// strong circadian support, while the ocular-UV/POMC item remains preclinical.
// Reference basis for the ten questions:
// 1. Morning light: Brown et al. 2022 CIE recommendations; Münch et al.
// JCEM 2017 — outdoor light within about one hour of waking entrains SCN.
@@ -62,31 +65,32 @@ export const PHOTOSENSITIVE_OPTIONS = [
// 9. Outdoor sunglasses: Lambert / Hattar on eye-mediated signaling.
// 10. Outdoor time: Stein et al. on myopia, vitamin D, and circadian amplitude.
export const OTT_QUESTIONS = [
- { key: 'morning-light-deficit', text: 'Do you get less than 5 minutes of outdoor daylight within an hour of waking?',
- why: 'Morning daylight at the eye sets your central body clock — without it, sleep timing drifts.' },
+ { key: 'morning-light-deficit', text: 'Do you usually get little or no outdoor daylight in the first 1–2 hours after waking?',
+ why: 'Morning light can help anchor circadian timing; the response depends on timing, intensity, duration, schedule, and individual sensitivity.' },
{ key: 'glass-mediated-daytime', text: 'Do you spend most of your daytime hours behind window glass (office, home, car)?',
- why: 'Window glass blocks UVB almost entirely — no vitamin D, no nitric-oxide release through the skin.' },
- { key: 'dim-workspace', text: 'Is your daytime workspace below office-bright (under ~500 lux at eye-level)?',
- why: 'Dim daytime light fails to reinforce the wake signal — the contrast with night collapses.' },
- { key: 'cool-led-evening', text: 'Are most of your indoor lights after sunset cool / daylight-white (4000K+)?',
- why: 'Cool / blue-rich light after sunset suppresses melatonin even at modest indoor intensities.' },
+ why: 'Ordinary glass strongly reduces UVB and alters UVA and visible-light transmission; daylight at the eye is also usually much dimmer indoors than outdoors.' },
+ { key: 'dim-workspace', text: 'Is your daytime workspace dim, with little daylight, for much of the day?',
+ why: 'Brighter daytime light supports day–night contrast. Ordinary lux and bulb color are only rough proxies for melanopic light at the eye.' },
+ { key: 'cool-led-evening', text: 'Is your evening light both bright and cool / blue-enriched for long periods?',
+ why: 'Circadian response depends on intensity, spectrum, duration, and timing—not color temperature alone.' },
{ key: 'evening-screens', text: 'Do you regularly use bright screens (phone, laptop, TV) in the 2 hours before bed?',
- why: 'Backlit screen reading before bed delays melatonin onset by ~90 minutes (Chang et al. AJCN 2015).' },
- { key: 'bright-after-sunset', text: 'Do you keep overhead room lights on at full brightness after sunset?',
- why: 'Overhead light after sunset shifts your circadian phase and shortens deep sleep.' },
- { key: 'sleep-not-dark', text: 'Is your bedroom not fully dark while you sleep (LED indicators, streetlight, partner\'s screen)?',
- why: 'Even <5 lux at the pillow degrades overnight insulin sensitivity (Cain et al. JCSM 2020).' },
+ why: 'Controlled studies show that prolonged, bright evening screen exposure can delay circadian timing; device, brightness, distance, and duration matter.' },
+ { key: 'bright-after-sunset', text: 'Do you keep bright room or overhead lights on during the 3 hours before intended sleep?',
+ why: 'Bright evening light can delay biological night. The effect depends on melanopic light at the eye and personal timing.' },
+ { key: 'sleep-not-dark', text: 'Does light reach your eyes while you sleep (room light, streetlight, or a nearby screen)?',
+ why: 'A dark sleep environment supports biological night; laboratory findings under room light do not mean every tiny indicator light causes metabolic harm.' },
{ key: 'sunscreen-blocks-uvb', text: 'Do you apply sunscreen on most sun-exposed days, including brief outdoor time?',
- why: 'Chemical sunscreen above ~SPF 8 blocks the UVB wavelengths required for vitamin D synthesis.' },
+ why: 'Sunscreen deliberately filters UV; spectrum and transmission vary by formulation and application. Record it for skin-dose modeling—not as a reason to extend exposure or remove protection.' },
{ key: 'sunglasses-outside', text: 'Do you wear sunglasses outdoors more often than not?',
- why: 'Sunglasses block the eye-mediated α-MSH cascade — your skin and mood lose a key signal.' },
+ why: 'Eyewear changes the spectrum reaching the eye. Ocular-UV activation of POMC / α-MSH has been shown in mice; a human skin-protection effect is unproven, so eye safety takes priority.' },
{ key: 'low-outdoor-time', text: 'Is your total outdoor time under 30 minutes on a typical day?',
- why: 'Under 30 min/day outdoors correlates with low vitamin D, myopia, and a blunted circadian amplitude.' },
+ why: 'Outdoor light is usually far brighter than indoor light. This cutoff is a simple habit screen, not a biological threshold or diagnosis.' },
];
export function photosensitiveTierOf(raw) {
if (raw === true) return 'moderate';
- if (raw === false || raw == null) return 'none';
+ if (raw === false) return 'none';
+ if (raw == null || raw === '') return 'unknown';
return String(raw);
}
@@ -100,14 +104,15 @@ export function skinTypeToFitzpatrick(skinType) {
return match ? match[1] : null;
}
-// Higher scores mean more indoor-light burden.
+// Higher scores mean more context patterns selected. The tiers only organize
+// the educational review; they are not a health, risk, or alignment grade.
export function ottScoreToLabel(score) {
if (typeof score !== 'number') return { label: '—', tier: 0 };
- if (score <= 1) return { label: 'well-aligned light environment', tier: 0 };
- if (score <= 3) return { label: 'mostly aligned, minor gaps', tier: 1 };
- if (score <= 5) return { label: 'moderate light burden', tier: 2 };
- if (score <= 7) return { label: 'significant light burden', tier: 3 };
- return { label: 'severe indoor-light burden', tier: 4 };
+ if (score === 0) return { label: 'no patterns selected', tier: 0 };
+ if (score <= 3) return { label: 'a few patterns to explore', tier: 1 };
+ if (score <= 5) return { label: 'several patterns to explore', tier: 2 };
+ if (score <= 7) return { label: 'many patterns to review', tier: 3 };
+ return { label: 'broad light-context mismatch', tier: 4 };
}
export const lightBurdenToLabel = ottScoreToLabel;
diff --git a/js/sun-defaults-runtime.js b/js/sun-defaults-runtime.js
index 61cc42f2..f5b6ed87 100644
--- a/js/sun-defaults-runtime.js
+++ b/js/sun-defaults-runtime.js
@@ -3,12 +3,13 @@
import { getProfileLocation } from './profile.js';
-/** @type {{ getProfileLocation: AnyFunction, getSunCoords: AnyFunction | null, navigate: AnyFunction | null, requestPreciseLocation: AnyFunction | null, openProfileLocationEditor: AnyFunction | null, openClientList: AnyFunction | null }} */
+/** @type {{ getProfileLocation: AnyFunction, getSunCoords: AnyFunction | null, navigate: AnyFunction | null, requestPreciseLocation: AnyFunction | null, clearCurrentLocation: AnyFunction | null, openProfileLocationEditor: AnyFunction | null, openClientList: AnyFunction | null }} */
const sunDefaultsRuntimeDeps = {
getProfileLocation,
getSunCoords: null,
navigate: null,
requestPreciseLocation: null,
+ clearCurrentLocation: null,
openProfileLocationEditor: null,
openClientList: null,
};
@@ -16,7 +17,7 @@ const sunDefaultsRuntimeDeps = {
export function configureSunDefaultsRuntimeDeps(deps = {}) {
const previous = { ...sunDefaultsRuntimeDeps };
if (typeof deps.getProfileLocation === 'function') sunDefaultsRuntimeDeps.getProfileLocation = deps.getProfileLocation;
- for (const name of ['getSunCoords', 'navigate', 'requestPreciseLocation', 'openProfileLocationEditor', 'openClientList']) {
+ for (const name of ['getSunCoords', 'navigate', 'requestPreciseLocation', 'clearCurrentLocation', 'openProfileLocationEditor', 'openClientList']) {
if (name in deps) {
sunDefaultsRuntimeDeps[name] = typeof deps[name] === 'function' ? deps[name] : null;
}
@@ -66,6 +67,16 @@ export function requestSunSetupPreciseLocationRuntime() {
}
}
+export function clearSunSetupCurrentLocationRuntime() {
+ try {
+ if (!sunDefaultsRuntimeDeps.clearCurrentLocation) return false;
+ sunDefaultsRuntimeDeps.clearCurrentLocation();
+ return true;
+ } catch {
+ return false;
+ }
+}
+
/** @param {string} route */
export function navigateSunDefaultsRoute(route) {
sunDefaultsRuntimeDeps.navigate?.(route);
diff --git a/js/sun-defaults-setup-renderer.js b/js/sun-defaults-setup-renderer.js
index aa530dff..df1f6144 100644
--- a/js/sun-defaults-setup-renderer.js
+++ b/js/sun-defaults-setup-renderer.js
@@ -88,7 +88,7 @@ function renderSavedSummary() {
const homeAccent = homeAccentMap[defaults.homeLight] || 'neutral';
const homeShort = (homeMeta?.label || defaults.homeLight || 'Not set').replace(/\s*\(.*\)/, '');
const eyewearIconMap = {
- 'none': '👁', 'sunglasses': '🕶', 'clear-prescription': '👓',
+ 'none': '👁', 'sunglasses': '🕶', 'clear-glasses': '👓',
'both': '🕶', 'contacts-uv': '👀',
};
const eyewearIcon = eyewearIconMap[defaults.eyewear] || '👁';
@@ -98,19 +98,19 @@ function renderSavedSummary() {
let burdenChip;
if (typeof defaults.ottScore === 'number') {
const { label, tier } = ottScoreToLabel(defaults.ottScore);
- burdenChip = `
+ burdenChip = `
☀
-
Light burden
+
Light patterns
${escapeHTML(label)}
-
${defaults.ottScore}/10 burden score
+
${defaults.ottScore}/10 selected
`;
} else if (defaults.skipped) {
burdenChip = `
⏭
-
Light burden
+
Light patterns
Skipped
tap Edit to fill in
@@ -119,7 +119,7 @@ function renderSavedSummary() {
burdenChip = `
⚠ ${escapeHTML(psmCopy.label)} — burn time remains an unadjusted base estimate; follow the label or clinician.
`
: '';
return `
@@ -177,14 +178,17 @@ export function renderSetupCard() {
}
function renderSetupPrompt() {
+ const deferred = !!getSunDefaults()?.setupPromptDismissedAt;
return `
- Set up your light assumptions
-
Skin type, home lighting, and eyewear drive burn math and channel estimates.
+ ${deferred ? 'Light setup not finished' : 'Make light part of your health picture'}
+
${deferred
+ ? 'Confirm skin type before starting sun or device sessions; add the rest whenever you are ready.'
+ : 'A short setup connects skin, daylight, indoor lighting, eyewear, and spectrum patterns to your Light context.'}
- Later
- Set up
+ ${deferred ? '' : `Later`}
+ Personalize Light
Step 1 of 2. Calibrate the assumptions that drive burn threshold, indoor-light context, and eye-channel estimates. The next step asks the 10 light-score questions.
+
Step 1 of 2. Connect skin, location, typical indoor lighting, and eyewear to one shared Light baseline. These answers shape context and estimates; they do not guarantee a safe exposure.
${renderSetupLocationStatus()}
-
Sets your burn threshold (MED) and how much UV you can take before getting red.
+
Required before sun or device sessions. It selects the rough Fitzpatrick base-MED reference used by UV estimates; it is not a personal safe-time guarantee.
Lowers your sunburn threshold so burn alerts trigger sooner. AAD list →
+
Records a separate caution. The app does not invent a universal burn multiplier because drug, dose, formulation, and reaction type matter. Review examples →
Home lighting
-
Shapes your indoor melanopic dose — what the AI sees for the half of your day spent inside.
+
Adds qualitative indoor-spectrum context. A bulb category alone cannot determine melanopic dose without intensity, distance, timing, and spectrum.
Eyewear changes the spectrum reaching the eye. Circadian input is mainly visible light; ocular UV–POMC / α-MSH signaling is an exploratory mouse finding, not a reason to expose eyes to UV or remove protection.
Flag the light-environment gaps that are true for you
+
Your typical light day
+
Select the timing and spectrum patterns that are true for you
-
Step 2 of 2. Tapped cards count as gaps. Leave a card unselected when the statement is not true for you.
+
Step 2 of 2. This is an educational context map, not a clinical score. Sunscreen and eyewear record spectral filtering; selecting them is not advice to stop protection.
For sessions that already happened. Tap each body region that was uncovered.${lastUsed ? ' Body regions, eyewear, and lens tint default to your last session.' : ''}
+
+
${renderBodySilhouette(lastRegions)}
+
Tap any body region to toggle whether it was uncovered.
+
+
+
+
+
Choose a protected-eye option only when the lenses are labeled UV-blocking. Dark tint alone does not prove UV protection.
+
Duration: 15 min
+
+
+
+ Behind glass (window / car / sunroom)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+ Save session
+
+
+
`;
+}
+
+export function bindPastSessionRegionPicker(overlay, initialRegions) {
+ const selected = new Set(initialRegions);
+ const slot = overlay.querySelector('#sun-silhouette-slot');
+ const hint = overlay.querySelector('#sun-silhouette-hint');
+ const updateHint = () => {
+ if (!hint) return;
+ const fraction = Array.from(selected).reduce((sum, key) => sum + (BODY_REGIONS.find(region => region.key === key)?.fraction || 0), 0);
+ if (selected.size === 0) hint.textContent = 'Tap any body region to toggle whether it was uncovered.';
+ else {
+ const labels = Array.from(selected).map(key => BODY_REGIONS.find(region => region.key === key)?.label || key).join(', ');
+ hint.textContent = `${selected.size} region${selected.size === 1 ? '' : 's'} exposed (${(fraction * 100).toFixed(0)}% of skin) — ${labels}`;
+ }
+ };
+ bindBodySilhouette(slot, selected, updateHint);
+ updateHint();
+ return selected;
+}
+
+export function bindPastSessionDurationHint(overlay) {
+ const start = /** @type {HTMLInputElement | null} */ (overlay.querySelector('#det-started-at'));
+ const end = /** @type {HTMLInputElement | null} */ (overlay.querySelector('#det-ended-at'));
+ const hint = /** @type {HTMLElement | null} */ (overlay.querySelector('#det-duration-hint'));
+ const update = () => {
+ if (!start || !end || !hint) return;
+ const startMs = new Date(start.value).getTime();
+ const endMs = new Date(end.value).getTime();
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) hint.textContent = 'Duration: —';
+ else {
+ const minutes = Math.round((endMs - startMs) / 60000);
+ if (minutes <= 0) hint.textContent = `Ended must be after Started (currently ${minutes} min)`;
+ else if (minutes > 240) hint.textContent = `Duration: ${minutes} min — over 4 hours, double-check the times`;
+ else hint.textContent = `Duration: ${minutes} min`;
+ }
+ };
+ start?.addEventListener('input', update);
+ end?.addEventListener('input', update);
+ update();
+}
diff --git a/js/sun-session-model.js b/js/sun-session-model.js
index 4c867595..f6ff5c12 100644
--- a/js/sun-session-model.js
+++ b/js/sun-session-model.js
@@ -4,40 +4,41 @@
// Keep these constants out of UI/store modules so the active-session ticker,
// persisted session store, and public sun.js facade all use one source.
-// Photosensitizing medication scale tiers — used by fractionOfMED() in
-// place of the legacy boolean flag. MED multipliers from AAD/Mayo Clinic
-// guidance: severe drugs (tetracyclines, retinoids systemic, amiodarone)
-// shift erythemal threshold ~4×; moderate (NSAIDs, thiazides, sulfa) ~2.5×;
-// mild (some antihistamines) ~1.5×.
+// Photosensitizing medication flags. Drug, dose, formulation, reaction type,
+// and individual response differ too much for a universal MED multiplier.
+// Tiers control caution prominence only; numeric burn estimates remain the
+// base skin-type estimate and explicitly exclude medication effects.
export const PHOTOSENSITIVE_MED_TIERS = [
+ { key: 'unknown', label: 'Not reviewed', medScale: null, examples: 'medicine, supplement, and topical-product warnings have not been reviewed' },
{ key: 'none', label: 'None', medScale: 1.0, examples: '' },
- { key: 'mild', label: 'Mild', medScale: 0.7, examples: 'antihistamines (most), some NSAIDs' },
- { key: 'moderate', label: 'Moderate', medScale: 0.4, examples: 'NSAIDs, thiazide diuretics, sulfa antibiotics, St. John\'s Wort, topical retinol' },
- { key: 'severe', label: 'Severe', medScale: 0.25, examples: 'tetracyclines (doxycycline), oral retinoids (isotretinoin), amiodarone, citrus essential oils on skin' },
+ { key: 'mild', label: 'Possible', medScale: null, examples: 'a medicine or topical product with a possible sunlight warning' },
+ { key: 'moderate', label: 'Known warning', medScale: null, examples: 'a medicine labeled for photosensitivity or sun precautions' },
+ { key: 'severe', label: 'Prior reaction / strong warning', medScale: null, examples: 'a prior phototoxic/photoallergic reaction or clinician-directed strict avoidance' },
];
-// Map tier key to multiplier; default to none (no scaling) on unknown.
+// Map tier key to a multiplier. Non-numeric caution tiers intentionally
+// return null so callers keep the base estimate and explain the uncertainty.
export function photosensitiveMedScale(tier) {
const t = PHOTOSENSITIVE_MED_TIERS.find(x => x.key === tier);
- return t ? t.medScale : 1.0;
+ return t ? t.medScale : null;
}
// Normalize legacy boolean photosensitiveMeds storage into a tier key.
-// boolean true → 'moderate' (the previous fixed-0.4 multiplier semantically
-// matches moderate); boolean false / null / undefined → 'none'.
+// boolean true → 'moderate' for legacy caution display; it no longer implies
+// a universal numeric threshold reduction.
export function _normalizePSMTier(raw) {
if (raw === true) return 'moderate';
- if (raw === false || raw == null) return 'none';
+ if (raw === false) return 'none';
+ if (raw == null || raw === '') return 'unknown';
if (typeof raw === 'string' && PHOTOSENSITIVE_MED_TIERS.some(t => t.key === raw)) return raw;
- return 'none';
+ return 'unknown';
}
// Standard quick-presets for the speed log. Fractions reflect a SINGLE
// position (front-only OR back-only at any one moment) — capped at the
-// anatomical max of ~0.55. Use the in-session "🔄 Flip" button (or the
-// `rotatedSides` toggle in the start dialog) to log that you exposed
-// both sides over the session; that doubles the effective body dose
-// the same way dminder's "100% naked" assumes alternating sides.
+// anatomical max of ~0.55. Use the in-session Side change button at the
+// moment of turning to record a boundary between timed segments; rotation
+// itself never multiplies a dose.
//
// Cite: fractions derive from the Wallace rule of nines + Lund-Browder
// (1944) chart, then halved (anterior face only). Face + hands ≈ 4.5%
diff --git a/js/sun-session-ui.js b/js/sun-session-ui.js
index e56fb276..812d7fcb 100644
--- a/js/sun-session-ui.js
+++ b/js/sun-session-ui.js
@@ -1,14 +1,12 @@
// @ts-check
// sun-session-ui.js — UI rendering/editing for saved sun sessions.
-// Core session storage, dose hydration, and sun math stay in sun.js. This
-// module receives those core operations through configureSunSessionUI() so
-// the UI layer can stay separate without importing sun.js and creating a cycle.
import { state } from './state.js';
import { bindDetachedModalSyncRefresh, escapeHTML, escapeAttr, formatDate, showNotification, showPromptDialog, showConfirmDialog } from './utils.js';
import { openAppendedModalOverlay, removeModalOverlay } from './modal-lifecycle.js';
-import { BODY_REGIONS, renderBodySilhouette, bindBodySilhouette } from './sun-body-silhouette.js';
+import { BODY_REGIONS } from './sun-body-silhouette.js';
import { installSunSessionActionDelegates, sunSessionActionAttrs } from './sun-session-actions.js';
+import { bindPastSessionDurationHint, bindPastSessionRegionPicker, renderPastSessionLogModal } from './sun-session-log-modal.js';
/**
* @typedef {object} SunSessionUIDeps
@@ -40,9 +38,9 @@ import { installSunSessionActionDelegates, sunSessionActionAttrs } from './sun-s
* @property {() => Promise | any} setOzoneOverrideMidSession
* @property {(id: any) => Promise | any} forgotStopPrompt
* @property {(channel: string) => void} openChannelOnLightPage
- * @property {(sess: any) => string} renderSessionAIInline
* @property {(sess: any) => string} renderSessionAIDetail
* @property {(route: string, data?: any) => void} navigate
+ * @property {() => void} openLightSetup
* Runtime math hooks are also configured here; defaults are no-ops.
*/
@@ -76,9 +74,9 @@ const uiDeps = {
setOzoneOverrideMidSession: () => {},
forgotStopPrompt: () => {},
openChannelOnLightPage: () => {},
- renderSessionAIInline: () => '',
renderSessionAIDetail: () => '',
navigate: () => {},
+ openLightSetup: () => {},
solarZenithAngle: null, reconstructSpectrum: null,
geneticVitaminDMultiplier: () => ({ mult: 1.0, contributors: [] }),
vitaminDIU: null, vitaminDIUPerSession: null,
@@ -89,6 +87,7 @@ const sunSessionDelegateActions = {
openSunSessionDetail,
deleteSunSession,
editSunSessionDuration,
+ retrySunSessionCalculation,
quickLogSunSession: () => uiDeps.quickLogSunSession(),
pauseSunSession: id => uiDeps.pauseSunSession(id),
resumeSunSession: id => uiDeps.resumeSunSession(id),
@@ -115,29 +114,102 @@ function refreshLightView() {
// ─── UI: Sessions list (used by the dedicated Light & Sun page) ────────
-// Render a single sun-session row. Extracted so the unified
-// sun+device sessions list (views.js renderUnifiedSessionsList) can
-// reuse the same rich treatment instead of rebuilding a stripped-down
-// row from scratch — channel chips + burn-risk meta + click-to-open
-// detail modal stay consistent whether the user owns devices or not.
+function resolvedSessionDurationMin(sess) {
+ const stored = sess?.durationMin == null ? Number.NaN : Number(sess.durationMin);
+ if (Number.isFinite(stored) && stored >= 0) return stored;
+ const start = Number(sess?.startedAt);
+ const end = Number(sess?.endedAt);
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
+ return (end - start) / 60000;
+ }
+ return null;
+}
+
+function localSessionStamp(timestamp) {
+ const date = new Date(timestamp);
+ if (Number.isNaN(date.getTime())) return { date: 'Date unavailable', time: '' };
+ const localKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+ return {
+ date: formatDate(localKey),
+ time: date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }),
+ };
+}
+
+function renderCompletedSunSessionRow(sess) {
+ const stamp = localSessionStamp(sess.startedAt);
+ const durationMin = resolvedSessionDurationMin(sess);
+ const dur = durationMin != null ? `${Math.round(durationMin)} min` : 'duration unavailable';
+ const vitaminD = sess.doses?.vitamin_d
+ ? _sessionChipValue('vitamin_d', sess.doses.vitamin_d, sess)
+ : '';
+ const status = sess.calculationStatus;
+ let statusBadge = '';
+ if (status && status !== 'computed') {
+ const statusLabels = {
+ pending: 'Updating estimates…',
+ 'needs-location': 'Location needed for estimates',
+ 'atmosphere-unavailable': 'Conditions unavailable — retry',
+ 'calculation-error': 'Calculation failed — retry',
+ };
+ statusBadge = `${escapeHTML(statusLabels[status] || 'Estimates unavailable')}`;
+ } else {
+ const med = Number(sess.safety?.medFraction);
+ if (Number.isFinite(med) && med >= 1) {
+ statusBadge = 'Base burn threshold reached';
+ } else if (Number.isFinite(med) && med >= 0.7) {
+ statusBadge = 'High modeled burn dose';
+ } else if (sess.safety?.fitzpatrickAssumed || sess.safety?.medicationThresholdUnknown) {
+ statusBadge = 'Review safety assumptions';
+ }
+ }
+ const ariaLabel = `Open ${stamp.date}${stamp.time ? ` at ${stamp.time}` : ''} outdoor sun session details`;
+ return `
+ ☀
+
+
OutdoorSunlight
+
+ ${escapeHTML(stamp.date)}
+ ${stamp.time ? `${escapeHTML(stamp.time)}` : ''}
+ ${escapeHTML(dur)}
+ ${vitaminD ? `Vitamin D est. ${escapeHTML(vitaminD)}` : ''}
+
';
@@ -303,15 +371,6 @@ export function openSunSessionDetail(id) {
}
} catch (e) {}
const altStr = (loc?.altitudeM ?? 0) > 0 ? `${Math.round(loc.altitudeM)} m` : 'sea level';
- // UVA / UVB split — reconstruct the actual spectrum at session
- // midpoint and integrate over each band:
- // UVB: 280–320 nm (vit-D synthesis + sunburn)
- // UVA: 320–400 nm (NO release, POMC, photoaging)
- // Surfaces both the absolute irradiance (W/m²) and the percent split
- // so users can see the real numbers, not a hand-waved fallback. No
- // more `~5%` placeholder when ozoneDU is missing — Bird-Riordan
- // already substitutes 300 DU internally so the spectrum is computed
- // either way.
let uvSplitStr = '';
try {
if (loc && uiDeps.reconstructSpectrum && uiDeps.solarZenithAngle && atm.uvIndex != null) {
@@ -324,6 +383,7 @@ export function openSunSessionDetail(id) {
altitudeM: loc.altitudeM ?? 0,
cloudCover: (atm.cloudCover ?? 0) / 100,
aod: atm?.airQuality?.aod ?? null,
+ targetUVI: atm.uvIndex ?? null,
});
const dl = 5;
let uvb = 0, uva = 0;
@@ -344,10 +404,10 @@ export function openSunSessionDetail(id) {
}
} catch (e) {}
// Source label: pretty-print the raw provider key.
- const sourceLabels = { open_meteo: 'Open-Meteo', cams: 'CAMS', noaa_nws: 'NOAA NWS', selfhost: 'Self-hosted', manual: 'Manual entry' };
+ const sourceLabels = { open_meteo: 'Open-Meteo', open_meteo_cams: 'Open-Meteo + CAMS context', cams: 'CAMS', cams_satellite: 'CAMS + satellite clouds', noaa_nws: 'NOAA NWS', selfhost: 'Self-hosted', manual: 'Manual entry' };
const sourceStr = sourceLabels[atm.source] || atm.source || 'unknown';
atmHtml = `
-
UVI${atm._uvOverridden ? ' (manual)' : ''}${uvi}
+
UVI${uvi}
Ozone${ozoneStr}
Cloud${cloud}
PM2.5${aqPm25}
@@ -360,7 +420,7 @@ export function openSunSessionDetail(id) {
// Location summary string (uses `loc` declared above).
const locStr = loc
- ? `${loc.lat.toFixed(2)}°, ${loc.lon.toFixed(2)}° · ${escapeHTML(loc.source || 'unknown')}`
+ ? `${loc.lat.toFixed(2)}°, ${loc.lon.toFixed(2)}° · ${loc.source || 'unknown'}`
: 'Location not recorded';
const overlay = document.createElement('div');
@@ -382,6 +442,19 @@ export function openSunSessionDetail(id) {
const surfLabel = (uiDeps.surfaceOptions.find(s => s.key === sess.surfaceAlbedo) || {}).label;
if (surfLabel) modifierBits.push(surfLabel.split(' (')[0]); // drop the "(~25%)" suffix
}
+ const aiDetailHtml = uiDeps.renderSessionAIDetail(sess);
+ const calculationMessages = {
+ pending: 'Estimates are being recalculated. Previous derived values are hidden until the new calculation finishes.',
+ 'needs-location': 'A location is needed to reconstruct conditions and calculate this session.',
+ 'atmosphere-unavailable': 'Conditions could not be loaded for this session. No dose or burn estimate is being shown.',
+ 'calculation-error': 'The session could not be calculated. No stale estimate is being shown.',
+ };
+ const calculationMessage = calculationMessages[sess.calculationStatus] || '';
+ const canRetryCalculation = ['needs-location', 'atmosphere-unavailable', 'calculation-error'].includes(sess.calculationStatus);
+ const calculationStateHtml = calculationMessage ? `
Estimated stimulation from this session. These signals are not daily targets or proof of an endocrine outcome.
${channelRows}
-
-
- ${atmHtml ? `
-
-
Conditions during this session
- ${atmHtml}
+
+
+
+ Conditions and model inputs
+
Technical inputs retained so the estimate can be audited without crowding the session summary.
+ ${atmHtml || '
Conditions were not available for this session.
'}
+
+ Approx. model location
+ ${escapeHTML(locStr)}
- ` : ''}
-
-
-
Location
-
${locStr}
-
+
${sess.notes ? `
@@ -463,16 +533,14 @@ export function openSunSessionDetail(id) {
// the chip. Channel-aware so units match what the user expects:
// vitamin_d → IU
// nir_solar → J/cm²
-// circadian → ~k M-EDI lux (peak melanopic during the session)
-// no_cv / pomc / violet_eye → percent of daily target
-// Returns '' when the value is sub-meaningful so chips for low channels
-// stay tight (icon + label only).
+// circadian → estimated melanopic-equivalent illuminance for modeled SPDs
+// no_cv / pomc / violet_eye → no invented percentage; label only
+// Returns '' when a compact chip should use its plain signal label.
function _sessionChipValue(channelKey, channelAu, sess) {
if (!Number.isFinite(channelAu) || channelAu <= 0) return '';
- const meta = uiDeps.channelDisplay[channelKey] || {};
- const fitz = sess?.safety?.fitzpatrick || 'III';
+ const fitz = sess?.safety?.fitzpatrick || 'I';
const uvi = sess?.atmosphere?.uvIndex ?? null;
- const dur = sess?.durationMin || 0;
+ const dur = resolvedSessionDurationMin(sess) || 0;
// Mirror formatChannelUnit's too-short gate: short sessions get the
// icon + label only, no spurious value. Keeps the chip readable
// without misleading numbers.
@@ -486,8 +554,8 @@ function _sessionChipValue(channelKey, channelAu, sess) {
? uiDeps.vitaminDIUPerSession(channelAu, fitz, uvi, !!sess?.bodyExposure?.rotatedSides, state.importedData?.genetics || null, bf)
: uiDeps.vitaminDIU(channelAu, fitz, uvi, !!sess?.bodyExposure?.rotatedSides, state.importedData?.genetics || null);
if (iu < 30) return '';
- if (iu >= 1000) return `~${(iu / 1000).toFixed(1).replace(/\.0$/, '')}k IU`;
- return `~${Math.round(iu / 10) * 10} IU`;
+ if (iu >= 1000) return `~${(iu / 1000).toFixed(1).replace(/\.0$/, '')}k IU-eq`;
+ return `~${Math.round(iu / 10) * 10} IU-eq`;
}
if (channelKey === 'nir_solar' && typeof uiDeps.pbmJoulesPerCm2 === 'function') {
const j = uiDeps.pbmJoulesPerCm2(channelAu);
@@ -498,23 +566,10 @@ function _sessionChipValue(channelKey, channelAu, sess) {
if (channelKey === 'circadian' && dur > 0 && typeof uiDeps.circadianMelanopicLux === 'function') {
const lux = uiDeps.circadianMelanopicLux(channelAu, dur);
if (lux < 100) return '';
- // Round aggressively at this magnitude — peak M-EDI lux is a big
- // number and chip-width-readable form beats decimal precision.
- if (lux >= 10000) return `~${Math.round(lux / 1000)}k lux`;
- if (lux >= 1000) return `~${(lux / 1000).toFixed(1)}k lux`;
- return `~${Math.round(lux / 10) * 10} lux`;
- }
- // Unitless channels — percent-of-daily-target. Past hit-target the
- // exact number is noise (the user got more than enough); collapse
- // anything ≥ 200% to "✓ over" so the chip stays informative without
- // a 4-digit percentage that adds nothing actionable.
- const target = meta.dailyTarget || 0;
- if (target > 0) {
- const pct = Math.round(100 * channelAu / target);
- if (pct < 5) return '';
- if (pct >= 200) return '✓ over';
- if (pct >= 100) return `✓ ${pct}%`;
- return `${pct}%`;
+ // Round aggressively at this magnitude; this is an SPD-model estimate.
+ if (lux >= 10000) return `~${Math.round(lux / 1000)}k est. mel lx`;
+ if (lux >= 1000) return `~${(lux / 1000).toFixed(1)}k est. mel lx`;
+ return `~${Math.round(lux / 10) * 10} est. mel lx`;
}
return '';
}
@@ -524,29 +579,35 @@ export function renderChannelChips(doses, sess = null) {
const order = ['vitamin_d', 'pomc', 'no_cv', 'violet_eye', 'circadian', 'nir_solar'];
// Top-3 contributing channels for at-a-glance reading. Full grid lives on
// the Light & Sun page; per-row noise is what the v1.7.0a UX review flagged.
- const ranked = order
- .map(key => ({ key, v: doses[key] || 0, tier: uiDeps.channelTier(doses[key] || 0, key) }))
- .sort((a, b) => b.tier - a.tier || b.v - a.v);
- const showAll = ranked.filter(r => r.tier > 0).length > 3;
- const visible = showAll ? ranked.slice(0, 3) : ranked;
+ const logged = order
+ .map(key => ({ key, v: doses[key] || 0 }))
+ .filter(row => Number.isFinite(row.v) && row.v > 0);
+ const showAll = logged.length > 3;
+ const visible = showAll ? logged.slice(0, 3) : logged;
const chipFor = (r, extraClass = '') => {
const meta = uiDeps.channelDisplay[r.key];
const label = meta?.label || r.key.replace('_', ' ');
const valueStr = _sessionChipValue(r.key, r.v, sess);
const tip = valueStr
- ? `${meta?.what || ''} — this session: ${valueStr}`
- : `${meta?.what || ''} (level: ${uiDeps.tierLabel(r.tier)})`;
- return `
+ ? `${meta?.what || ''} — this session: ${valueStr}. Open channel details.`
+ : `${meta?.what || ''} — sunlight signal logged. Open channel details.`;
+ const ariaLabel = `${label}${valueStr ? `, this session ${valueStr}` : ', sunlight signal logged'}. Open channel details.`;
+ return `${meta?.icon || '·'}${escapeHTML(label)}
${valueStr ? `${escapeHTML(valueStr)}` : ''}
- `;
+ `;
};
let html = `
`;
for (const r of visible) html += chipFor(r);
if (showAll) {
- html += `+ ${ranked.length - 3} more`;
- for (const r of ranked.slice(3)) html += chipFor(r, ' sun-chip-extra');
+ const hiddenCount = logged.length - 3;
+ const channelWord = hiddenCount === 1 ? 'channel' : 'channels';
+ html += `
+ + ${hiddenCount} more ${channelWord}
+ Show fewer
+ `;
+ for (const r of logged.slice(3)) html += chipFor(r, ' sun-chip-extra');
}
html += `
`;
return html;
@@ -555,6 +616,16 @@ export function renderChannelChips(doses, sess = null) {
// ─── UI: detailed session log (anatomical regions + sunscreen + glass) ─
export function openDetailedSessionDialog() {
+ const configuredFitz = state.importedData?.sunDefaults?.fitzpatrick || null;
+ if (!/^(I|II|III|IV|V|VI)$/.test(String(configuredFitz || ''))) {
+ showNotification(
+ 'Confirm your Fitzpatrick skin type in Light setup before logging a sun session.',
+ 'info',
+ 7000,
+ );
+ uiDeps.openLightSetup();
+ return false;
+ }
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
const lastUsed = uiDeps.getSessions().filter(s => s.endedAt).slice(-1)[0];
@@ -579,122 +650,23 @@ export function openDetailedSessionDialog() {
// silhouette per the v1.7.0a UX review. Each chip shows the region label
// and toggles on click. Free-form, accessible, mobile-friendly.
- overlay.innerHTML = `
-
-
Log a past session
- ×
-
-
-
For sessions that already happened. Tap each body region that was uncovered.${lastUsed ? ' Body regions, eyewear, and lens tint default to your last session.' : ''}
-
-
-
${renderBodySilhouette(lastRegions)}
-
Tap any body region to toggle whether it was uncovered.