diff --git a/MODULE_MAP.md b/MODULE_MAP.md index 8fcf4e41..708ae8db 100644 --- a/MODULE_MAP.md +++ b/MODULE_MAP.md @@ -9,8 +9,8 @@ The human-maintained architecture contract is in [`ARCHITECTURE.md`](ARCHITECTUR | Metric | Current | | --- | ---: | -| Modules | 656 | -| Internal import edges | 2745 | +| Modules | 661 | +| Internal import edges | 2758 | | Dynamic internal edges | 73 | | Modules participating in cycles | 0 | | Cyclic components | 0 | @@ -56,12 +56,12 @@ High fan-in modules have many dependants; high fan-out modules coordinate many d | High fan-in | Dependants | High fan-out | Imports | | --- | ---: | --- | ---: | -| [`js/utils.js`](js/utils.js) | 265 | [`js/app-shell-hooks.js`](js/app-shell-hooks.js) | 74 | -| [`js/state.js`](js/state.js) | 184 | [`js/app-light-sun-modules.js`](js/app-light-sun-modules.js) | 36 | +| [`js/utils.js`](js/utils.js) | 267 | [`js/app-shell-hooks.js`](js/app-shell-hooks.js) | 74 | +| [`js/state.js`](js/state.js) | 182 | [`js/app-light-sun-modules.js`](js/app-light-sun-modules.js) | 36 | | [`js/caught-error.js`](js/caught-error.js) | 83 | [`js/chat-send.js`](js/chat-send.js) | 28 | -| [`js/data.js`](js/data.js) | 77 | [`js/sync-configure.js`](js/sync-configure.js) | 27 | +| [`js/data.js`](js/data.js) | 75 | [`js/sync-configure.js`](js/sync-configure.js) | 27 | | [`js/modal-lifecycle.js`](js/modal-lifecycle.js) | 75 | [`js/pdf-import.js`](js/pdf-import.js) | 26 | -| [`js/api.js`](js/api.js) | 67 | [`js/settings.js`](js/settings.js) | 26 | +| [`js/api.js`](js/api.js) | 66 | [`js/settings.js`](js/settings.js) | 26 | | [`js/profile.js`](js/profile.js) | 48 | [`js/wearables-connect.js`](js/wearables-connect.js) | 25 | | [`js/schema.js`](js/schema.js) | 37 | [`js/views.js`](js/views.js) | 22 | | [`js/crypto.js`](js/crypto.js) | 33 | [`js/lab-context.js`](js/lab-context.js) | 21 | @@ -70,7 +70,7 @@ High fan-in modules have many dependants; high fan-out modules coordinate many d | [`js/utils-runtime.js`](js/utils-runtime.js) | 20 | [`js/marker-schema/index.js`](js/marker-schema/index.js) | 20 | | [`js/chat-runtime.js`](js/chat-runtime.js) | 19 | [`js/biology-scores.js`](js/biology-scores.js) | 19 | | [`js/marker-analysis.js`](js/marker-analysis.js) | 19 | [`js/export.js`](js/export.js) | 19 | -| [`js/marker-placement.js`](js/marker-placement.js) | 19 | [`js/app-chat-hooks.js`](js/app-chat-hooks.js) | 17 | +| [`js/marker-placement.js`](js/marker-placement.js) | 19 | [`js/sun.js`](js/sun.js) | 18 | ## Existing cyclic components @@ -619,30 +619,31 @@ Native browser modules shipped with the static application. -
light family — 52 modules +
light family — 54 modules - [`js/light-ai-save-hooks.js`](js/light-ai-save-hooks.js) → [`js/api.js`](js/api.js), [`js/chat-loader.js`](js/chat-loader.js), [`js/light-audit-ai-analysis.js`](js/light-audit-ai-analysis.js), [`js/light-env-ai-analysis.js`](js/light-env-ai-analysis.js), [`js/light-env-audits.js`](js/light-env-audits.js), [`js/light-env.js`](js/light-env.js), [`js/light-screen-ai-analysis.js`](js/light-screen-ai-analysis.js), [`js/light-tools-ai-analysis.js`](js/light-tools-ai-analysis.js), [`js/light-tools.js`](js/light-tools.js), [`js/sun-defaults.js`](js/sun-defaults.js), [`js/sun-onboarding-ai.js`](js/sun-onboarding-ai.js), [`js/sun-sessions-store.js`](js/sun-sessions-store.js), [`js/sun-uvdata.js`](js/sun-uvdata.js), [`js/sun.js`](js/sun.js) -- [`js/light-audit-ai-analysis.js`](js/light-audit-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/light-env-evening.js`](js/light-env-evening.js), [`js/lighting-hardware-caveats.js`](js/lighting-hardware-caveats.js), [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) +- [`js/light-audit-ai-analysis.js`](js/light-audit-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/light-env-evening.js`](js/light-env-evening.js), [`js/light-env-model.js`](js/light-env-model.js), [`js/lighting-hardware-caveats.js`](js/lighting-hardware-caveats.js), [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) - [`js/light-burden-ai-analysis.js`](js/light-burden-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/light-env-evening.js`](js/light-env-evening.js), [`js/light-env.js`](js/light-env.js), [`js/lighting-hardware-caveats.js`](js/lighting-hardware-caveats.js), [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) - [`js/light-channel-view-hooks.js`](js/light-channel-view-hooks.js) → [`js/light-channel-view.js`](js/light-channel-view.js), [`js/light-devices-store.js`](js/light-devices-store.js), [`js/light-devices.js`](js/light-devices.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/sun.js`](js/sun.js) - [`js/light-channel-view-ui-hooks.js`](js/light-channel-view-ui-hooks.js) → [`js/light-channel-view.js`](js/light-channel-view.js), [`js/views.js`](js/views.js) - [`js/light-channel-view.js`](js/light-channel-view.js) → [`js/light-conditions-now.js`](js/light-conditions-now.js), [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) - [`js/light-channels-ai-analysis.js`](js/light-channels-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/light-devices-store.js`](js/light-devices-store.js), [`js/state.js`](js/state.js), [`js/sun-sessions-store.js`](js/sun-sessions-store.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) - [`js/light-conditions-interpretation.js`](js/light-conditions-interpretation.js) → no in-scope imports -- [`js/light-conditions-now-hooks.js`](js/light-conditions-now-hooks.js) → [`js/data.js`](js/data.js), [`js/light-conditions-now.js`](js/light-conditions-now.js), [`js/sun-uvdata.js`](js/sun-uvdata.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) -- [`js/light-conditions-now.js`](js/light-conditions-now.js) → [`js/caught-error.js`](js/caught-error.js), [`js/light-conditions-interpretation.js`](js/light-conditions-interpretation.js), [`js/light-conditions-renderer.js`](js/light-conditions-renderer.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) -- [`js/light-conditions-renderer.js`](js/light-conditions-renderer.js) → [`js/light-conditions-interpretation.js`](js/light-conditions-interpretation.js), [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) -- [`js/light-device-ai-analysis.js`](js/light-device-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/light-devices-store.js`](js/light-devices-store.js), [`js/state.js`](js/state.js), [`js/sun-defaults.js`](js/sun-defaults.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) +- [`js/light-conditions-now-hooks.js`](js/light-conditions-now-hooks.js) → [`js/light-conditions-now.js`](js/light-conditions-now.js), [`js/sun-uvdata.js`](js/sun-uvdata.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) +- [`js/light-conditions-now.js`](js/light-conditions-now.js) → [`js/caught-error.js`](js/caught-error.js), [`js/light-conditions-interpretation.js`](js/light-conditions-interpretation.js), [`js/light-conditions-renderer.js`](js/light-conditions-renderer.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/utils.js`](js/utils.js) +- [`js/light-conditions-renderer.js`](js/light-conditions-renderer.js) → [`js/light-conditions-interpretation.js`](js/light-conditions-interpretation.js), [`js/sun-uvdata-atmosphere.js`](js/sun-uvdata-atmosphere.js), [`js/utils.js`](js/utils.js) +- [`js/light-device-ai-analysis.js`](js/light-device-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/light-devices-store.js`](js/light-devices-store.js), [`js/sun-defaults.js`](js/sun-defaults.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) - [`js/light-device-modal-loader.js`](js/light-device-modal-loader.js) → [`js/light-device-session-modal.js`](js/light-device-session-modal.js) *(dynamic)*, [`js/light-device-setup-modal.js`](js/light-device-setup-modal.js) *(dynamic)*, [`js/utils.js`](js/utils.js) - [`js/light-device-session-engine.js`](js/light-device-session-engine.js) → [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-spectrum.js`](js/sun-spectrum.js) -- [`js/light-device-session-modal.js`](js/light-device-session-modal.js) → [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/utils.js`](js/utils.js) +- [`js/light-device-session-modal.js`](js/light-device-session-modal.js) → [`js/light-device-session-engine.js`](js/light-device-session-engine.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/utils.js`](js/utils.js) - [`js/light-device-setup-modal.js`](js/light-device-setup-modal.js) → [`js/api.js`](js/api.js), [`js/caught-error.js`](js/caught-error.js), [`js/image-utils.js`](js/image-utils.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/utils-runtime.js`](js/utils-runtime.js), [`js/utils.js`](js/utils.js) +- [`js/light-device-view-formatters.js`](js/light-device-view-formatters.js) → [`js/utils.js`](js/utils.js) - [`js/light-devices-actions.js`](js/light-devices-actions.js) → no in-scope imports - [`js/light-devices-runtime.js`](js/light-devices-runtime.js) → [`js/recommendations-runtime.js`](js/recommendations-runtime.js), [`js/state.js`](js/state.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) -- [`js/light-devices-store.js`](js/light-devices-store.js) → [`js/data-merge.js`](js/data-merge.js), [`js/data.js`](js/data.js), [`js/light-device-session-engine.js`](js/light-device-session-engine.js), [`js/light-sun-analysis-runtime.js`](js/light-sun-analysis-runtime.js), [`js/state.js`](js/state.js), [`js/unique-id.js`](js/unique-id.js) -- [`js/light-devices.js`](js/light-devices.js) → [`js/light-device-modal-loader.js`](js/light-device-modal-loader.js), [`js/light-devices-actions.js`](js/light-devices-actions.js), [`js/light-devices-runtime.js`](js/light-devices-runtime.js), [`js/light-devices-store.js`](js/light-devices-store.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) +- [`js/light-devices-store.js`](js/light-devices-store.js) → [`js/data-merge.js`](js/data-merge.js), [`js/data.js`](js/data.js), [`js/light-device-session-engine.js`](js/light-device-session-engine.js), [`js/light-sun-analysis-runtime.js`](js/light-sun-analysis-runtime.js), [`js/state.js`](js/state.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/unique-id.js`](js/unique-id.js) +- [`js/light-devices.js`](js/light-devices.js) → [`js/light-device-modal-loader.js`](js/light-device-modal-loader.js), [`js/light-device-session-engine.js`](js/light-device-session-engine.js), [`js/light-device-view-formatters.js`](js/light-device-view-formatters.js), [`js/light-devices-actions.js`](js/light-devices-actions.js), [`js/light-devices-runtime.js`](js/light-devices-runtime.js), [`js/light-devices-store.js`](js/light-devices-store.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-defaults.js`](js/sun-defaults.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) - [`js/light-env-actions.js`](js/light-env-actions.js) → [`js/utils.js`](js/utils.js) -- [`js/light-env-ai-analysis.js`](js/light-env-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/light-env-evening.js`](js/light-env-evening.js), [`js/lighting-hardware-caveats.js`](js/lighting-hardware-caveats.js), [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) +- [`js/light-env-ai-analysis.js`](js/light-env-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/light-env-evening.js`](js/light-env-evening.js), [`js/light-env-model.js`](js/light-env-model.js), [`js/lighting-hardware-caveats.js`](js/lighting-hardware-caveats.js), [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) - [`js/light-env-audits.js`](js/light-env-audits.js) → [`js/data-merge.js`](js/data-merge.js), [`js/data.js`](js/data.js), [`js/light-env-actions.js`](js/light-env-actions.js), [`js/state.js`](js/state.js), [`js/unique-id.js`](js/unique-id.js), [`js/utils.js`](js/utils.js) - [`js/light-env-editor.js`](js/light-env-editor.js) → [`js/light-env-model.js`](js/light-env-model.js), [`js/light-env-screen-ui.js`](js/light-env-screen-ui.js), [`js/light-env-store.js`](js/light-env-store.js), [`js/utils.js`](js/utils.js) - [`js/light-env-evening.js`](js/light-env-evening.js) → no in-scope imports @@ -671,8 +672,9 @@ Native browser modules shipped with the static application. - [`js/light-tool-lux-meter.js`](js/light-tool-lux-meter.js) → [`js/light-tool-camera-modal-runtime.js`](js/light-tool-camera-modal-runtime.js), [`js/light-tool-camera.js`](js/light-tool-camera.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/utils-runtime.js`](js/utils-runtime.js), [`js/utils.js`](js/utils.js) - [`js/light-tool-spectrum-classifier.js`](js/light-tool-spectrum-classifier.js) → [`js/light-tool-camera-modal-runtime.js`](js/light-tool-camera-modal-runtime.js), [`js/light-tool-camera.js`](js/light-tool-camera.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/utils.js`](js/utils.js) - [`js/light-tools-ai-analysis.js`](js/light-tools-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/lighting-hardware-caveats.js`](js/lighting-hardware-caveats.js), [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) +- [`js/light-tools-solar-time.js`](js/light-tools-solar-time.js) → no in-scope imports - [`js/light-tools-ui-hooks.js`](js/light-tools-ui-hooks.js) → [`js/light-tools.js`](js/light-tools.js), [`js/views.js`](js/views.js) -- [`js/light-tools.js`](js/light-tools.js) → [`js/data-merge.js`](js/data-merge.js), [`js/data.js`](js/data.js), [`js/light-tool-camera-modals.js`](js/light-tool-camera-modals.js) *(dynamic)*, [`js/light-tool-camera.js`](js/light-tool-camera.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/unique-id.js`](js/unique-id.js), [`js/utils.js`](js/utils.js) +- [`js/light-tools.js`](js/light-tools.js) → [`js/data-merge.js`](js/data-merge.js), [`js/data.js`](js/data.js), [`js/light-tool-camera-modals.js`](js/light-tool-camera-modals.js) *(dynamic)*, [`js/light-tool-camera.js`](js/light-tool-camera.js), [`js/light-tools-solar-time.js`](js/light-tools-solar-time.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/unique-id.js`](js/unique-id.js), [`js/utils.js`](js/utils.js)
@@ -846,7 +848,7 @@ Native browser modules shipped with the static application. - [`js/profile-share.js`](js/profile-share.js) → [`js/caught-error.js`](js/caught-error.js), [`js/export.js`](js/export.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/profile.js`](js/profile.js), [`js/state.js`](js/state.js), [`js/utils-runtime.js`](js/utils-runtime.js), [`js/utils.js`](js/utils.js) - [`js/profile-storage-cleanup.js`](js/profile-storage-cleanup.js) → [`js/blob-storage.js`](js/blob-storage.js), [`js/crypto.js`](js/crypto.js), [`js/cycle-store.js`](js/cycle-store.js), [`js/profile-storage-key.js`](js/profile-storage-key.js), [`js/wearables-store.js`](js/wearables-store.js) - [`js/profile-storage-key.js`](js/profile-storage-key.js) → no in-scope imports -- [`js/profile.js`](js/profile.js) → [`js/api.js`](js/api.js), [`js/constants.js`](js/constants.js), [`js/crypto.js`](js/crypto.js), [`js/profile-data-migrations.js`](js/profile-data-migrations.js), [`js/profile-list-store.js`](js/profile-list-store.js), [`js/profile-storage-cleanup.js`](js/profile-storage-cleanup.js), [`js/profile-storage-key.js`](js/profile-storage-key.js), [`js/state.js`](js/state.js), [`js/unique-id.js`](js/unique-id.js), [`js/utils.js`](js/utils.js) +- [`js/profile.js`](js/profile.js) → [`js/constants.js`](js/constants.js), [`js/crypto.js`](js/crypto.js), [`js/profile-data-migrations.js`](js/profile-data-migrations.js), [`js/profile-list-store.js`](js/profile-list-store.js), [`js/profile-storage-cleanup.js`](js/profile-storage-cleanup.js), [`js/profile-storage-key.js`](js/profile-storage-key.js), [`js/state.js`](js/state.js), [`js/unique-id.js`](js/unique-id.js), [`js/utils.js`](js/utils.js)
@@ -973,15 +975,16 @@ Native browser modules shipped with the static application. -
sun family — 32 modules +
sun family — 34 modules -- [`js/sun-active-session.js`](js/sun-active-session.js) → [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-session-model.js`](js/sun-session-model.js), [`js/sun-session-ui.js`](js/sun-session-ui.js), [`js/utils.js`](js/utils.js) -- [`js/sun-ai-analysis.js`](js/sun-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/state.js`](js/state.js), [`js/sun-defaults.js`](js/sun-defaults.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/sun-uvdata.js`](js/sun-uvdata.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) +- [`js/sun-active-session-format.js`](js/sun-active-session-format.js) → no in-scope imports +- [`js/sun-active-session.js`](js/sun-active-session.js) → [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/sun-active-session-format.js`](js/sun-active-session-format.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-session-actions.js`](js/sun-session-actions.js), [`js/sun-session-model.js`](js/sun-session-model.js), [`js/sun-session-ui.js`](js/sun-session-ui.js), [`js/utils.js`](js/utils.js) +- [`js/sun-ai-analysis.js`](js/sun-ai-analysis.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/sun-defaults.js`](js/sun-defaults.js), [`js/sun-uvdata.js`](js/sun-uvdata.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) - [`js/sun-body-silhouette-runtime.js`](js/sun-body-silhouette-runtime.js) → [`js/profile.js`](js/profile.js) - [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js) → [`js/silhouette-paths.js`](js/silhouette-paths.js), [`js/sun-body-silhouette-runtime.js`](js/sun-body-silhouette-runtime.js), [`js/utils.js`](js/utils.js) - [`js/sun-channel-metrics.js`](js/sun-channel-metrics.js) → [`js/state.js`](js/state.js), [`js/sun-active-session.js`](js/sun-active-session.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-sessions-store.js`](js/sun-sessions-store.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/supplement-impact.js`](js/supplement-impact.js), [`js/supplement-medication-domain.js`](js/supplement-medication-domain.js) -- [`js/sun-context-environment.js`](js/sun-context-environment.js) → [`js/light-env-evening.js`](js/light-env-evening.js), [`js/state.js`](js/state.js), [`js/sun-context-runtime.js`](js/sun-context-runtime.js) -- [`js/sun-context-hooks.js`](js/sun-context-hooks.js) → [`js/data.js`](js/data.js), [`js/lab-context.js`](js/lab-context.js), [`js/light-devices-store.js`](js/light-devices-store.js), [`js/light-env-model.js`](js/light-env-model.js), [`js/light-env-store.js`](js/light-env-store.js), [`js/sun-context.js`](js/sun-context.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/sun-uvdata.js`](js/sun-uvdata.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) +- [`js/sun-context-environment.js`](js/sun-context-environment.js) → [`js/light-env-evening.js`](js/light-env-evening.js), [`js/light-env-model.js`](js/light-env-model.js), [`js/state.js`](js/state.js), [`js/sun-context-runtime.js`](js/sun-context-runtime.js) +- [`js/sun-context-hooks.js`](js/sun-context-hooks.js) → [`js/data.js`](js/data.js), [`js/lab-context.js`](js/lab-context.js), [`js/light-devices-store.js`](js/light-devices-store.js), [`js/light-env-model.js`](js/light-env-model.js), [`js/light-env-store.js`](js/light-env-store.js), [`js/state.js`](js/state.js), [`js/sun-context.js`](js/sun-context.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/sun-uvdata.js`](js/sun-uvdata.js), [`js/sun.js`](js/sun.js), [`js/utils.js`](js/utils.js) - [`js/sun-context-runtime.js`](js/sun-context-runtime.js) → no in-scope imports - [`js/sun-context-session-tools.js`](js/sun-context-session-tools.js) → [`js/state.js`](js/state.js), [`js/sun-context-runtime.js`](js/sun-context-runtime.js) - [`js/sun-context.js`](js/sun-context.js) → [`js/lab-context.js`](js/lab-context.js), [`js/state.js`](js/state.js), [`js/sun-context-environment.js`](js/sun-context-environment.js), [`js/sun-context-runtime.js`](js/sun-context-runtime.js), [`js/sun-context-session-tools.js`](js/sun-context-session-tools.js), [`js/sun-correlations.js`](js/sun-correlations.js) @@ -991,14 +994,15 @@ Native browser modules shipped with the static application. - [`js/sun-defaults-setup-renderer.js`](js/sun-defaults-setup-renderer.js) → [`js/constants.js`](js/constants.js), [`js/state.js`](js/state.js), [`js/sun-defaults-model.js`](js/sun-defaults-model.js), [`js/sun-defaults-runtime.js`](js/sun-defaults-runtime.js), [`js/utils.js`](js/utils.js) - [`js/sun-defaults-setup-ui.js`](js/sun-defaults-setup-ui.js) → [`js/constants.js`](js/constants.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/sun-defaults-model.js`](js/sun-defaults-model.js), [`js/sun-defaults-runtime.js`](js/sun-defaults-runtime.js), [`js/sun-defaults-setup-renderer.js`](js/sun-defaults-setup-renderer.js), [`js/utils.js`](js/utils.js) - [`js/sun-defaults.js`](js/sun-defaults.js) → [`js/constants.js`](js/constants.js), [`js/data.js`](js/data.js), [`js/state.js`](js/state.js), [`js/sun-defaults-model.js`](js/sun-defaults-model.js), [`js/sun-defaults-setup-ui.js`](js/sun-defaults-setup-ui.js) -- [`js/sun-location.js`](js/sun-location.js) → [`js/constants.js`](js/constants.js), [`js/data.js`](js/data.js), [`js/profile.js`](js/profile.js), [`js/state.js`](js/state.js), [`js/sun-runtime.js`](js/sun-runtime.js), [`js/utils.js`](js/utils.js) -- [`js/sun-onboarding-ai.js`](js/sun-onboarding-ai.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/lighting-hardware-caveats.js`](js/lighting-hardware-caveats.js), [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) +- [`js/sun-location.js`](js/sun-location.js) → [`js/constants.js`](js/constants.js), [`js/profile.js`](js/profile.js), [`js/state.js`](js/state.js), [`js/sun-runtime.js`](js/sun-runtime.js), [`js/utils.js`](js/utils.js) +- [`js/sun-onboarding-ai.js`](js/sun-onboarding-ai.js) → [`js/ai-action-delegates.js`](js/ai-action-delegates.js), [`js/ai-verdict-engine.js`](js/ai-verdict-engine.js), [`js/api.js`](js/api.js), [`js/health-goals-utils.js`](js/health-goals-utils.js), [`js/lighting-hardware-caveats.js`](js/lighting-hardware-caveats.js), [`js/state.js`](js/state.js), [`js/sun-defaults-runtime.js`](js/sun-defaults-runtime.js), [`js/utils.js`](js/utils.js) - [`js/sun-runtime.js`](js/sun-runtime.js) → [`js/state.js`](js/state.js), [`js/utils.js`](js/utils.js) - [`js/sun-session-actions.js`](js/sun-session-actions.js) → [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/utils.js`](js/utils.js) - [`js/sun-session-ai-render-hooks.js`](js/sun-session-ai-render-hooks.js) → [`js/sun-ai-analysis.js`](js/sun-ai-analysis.js), [`js/sun-session-ui.js`](js/sun-session-ui.js) +- [`js/sun-session-log-modal.js`](js/sun-session-log-modal.js) → [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-session-actions.js`](js/sun-session-actions.js), [`js/utils.js`](js/utils.js) - [`js/sun-session-model.js`](js/sun-session-model.js) → no in-scope imports - [`js/sun-session-ui-hooks.js`](js/sun-session-ui-hooks.js) → [`js/sun-session-ui.js`](js/sun-session-ui.js), [`js/views.js`](js/views.js) -- [`js/sun-session-ui.js`](js/sun-session-ui.js) → [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-session-actions.js`](js/sun-session-actions.js), [`js/utils.js`](js/utils.js) +- [`js/sun-session-ui.js`](js/sun-session-ui.js) → [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/state.js`](js/state.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-session-actions.js`](js/sun-session-actions.js), [`js/sun-session-log-modal.js`](js/sun-session-log-modal.js), [`js/utils.js`](js/utils.js) - [`js/sun-sessions-store.js`](js/sun-sessions-store.js) → [`js/caught-error.js`](js/caught-error.js), [`js/data-merge.js`](js/data-merge.js), [`js/data.js`](js/data.js), [`js/light-sun-analysis-runtime.js`](js/light-sun-analysis-runtime.js), [`js/state.js`](js/state.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-session-model.js`](js/sun-session-model.js), [`js/unique-id.js`](js/unique-id.js) - [`js/sun-spectrum-actions.js`](js/sun-spectrum-actions.js) → no in-scope imports - [`js/sun-spectrum-device.js`](js/sun-spectrum-device.js) → no in-scope imports @@ -1006,7 +1010,7 @@ Native browser modules shipped with the static application. - [`js/sun-uvdata-atmosphere.js`](js/sun-uvdata-atmosphere.js) → no in-scope imports - [`js/sun-uvdata-config.js`](js/sun-uvdata-config.js) → [`js/crypto.js`](js/crypto.js) - [`js/sun-uvdata.js`](js/sun-uvdata.js) → [`js/caught-error.js`](js/caught-error.js), [`js/sun-runtime.js`](js/sun-runtime.js), [`js/sun-uvdata-atmosphere.js`](js/sun-uvdata-atmosphere.js), [`js/sun-uvdata-config.js`](js/sun-uvdata-config.js), [`js/url-safety.js`](js/url-safety.js) -- [`js/sun.js`](js/sun.js) → [`js/ai-verdict-engine-runtime.js`](js/ai-verdict-engine-runtime.js), [`js/data.js`](js/data.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/profile-context.js`](js/profile-context.js), [`js/state.js`](js/state.js), [`js/sun-active-session.js`](js/sun-active-session.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-channel-metrics.js`](js/sun-channel-metrics.js), [`js/sun-defaults-runtime.js`](js/sun-defaults-runtime.js), [`js/sun-location.js`](js/sun-location.js), [`js/sun-runtime.js`](js/sun-runtime.js), [`js/sun-session-model.js`](js/sun-session-model.js), [`js/sun-session-ui.js`](js/sun-session-ui.js), [`js/sun-sessions-store.js`](js/sun-sessions-store.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/sun-uvdata.js`](js/sun-uvdata.js), [`js/utils.js`](js/utils.js) +- [`js/sun.js`](js/sun.js) → [`js/ai-verdict-engine-runtime.js`](js/ai-verdict-engine-runtime.js), [`js/data.js`](js/data.js), [`js/modal-lifecycle.js`](js/modal-lifecycle.js), [`js/profile-context.js`](js/profile-context.js), [`js/state.js`](js/state.js), [`js/sun-active-session.js`](js/sun-active-session.js), [`js/sun-body-silhouette.js`](js/sun-body-silhouette.js), [`js/sun-channel-metrics.js`](js/sun-channel-metrics.js), [`js/sun-defaults-runtime.js`](js/sun-defaults-runtime.js), [`js/sun-defaults.js`](js/sun-defaults.js), [`js/sun-location.js`](js/sun-location.js), [`js/sun-runtime.js`](js/sun-runtime.js), [`js/sun-session-model.js`](js/sun-session-model.js), [`js/sun-session-ui.js`](js/sun-session-ui.js), [`js/sun-sessions-store.js`](js/sun-sessions-store.js), [`js/sun-spectrum.js`](js/sun-spectrum.js), [`js/sun-uvdata.js`](js/sun-uvdata.js), [`js/utils.js`](js/utils.js)
@@ -1277,9 +1281,15 @@ Vercel request handlers and hosted API entry points.
+
postal family — 1 module + +- [`api/postal-geocode.js`](api/postal-geocode.js) → [`lib/proxy-upstream.js`](lib/proxy-upstream.js) + +
+
proxy family — 1 module -- [`api/proxy.js`](api/proxy.js) → [`lib/error-utils.js`](lib/error-utils.js), [`lib/proxy-policy.js`](lib/proxy-policy.js), [`lib/proxy-rate-limit.js`](lib/proxy-rate-limit.js) *(dynamic)*, [`lib/proxy-upstream.js`](lib/proxy-upstream.js) +- [`api/proxy.js`](api/proxy.js) → [`api/postal-geocode.js`](api/postal-geocode.js), [`lib/error-utils.js`](lib/error-utils.js), [`lib/proxy-policy.js`](lib/proxy-policy.js), [`lib/proxy-rate-limit.js`](lib/proxy-rate-limit.js) *(dynamic)*, [`lib/proxy-upstream.js`](lib/proxy-upstream.js)
diff --git a/api/postal-geocode.js b/api/postal-geocode.js new file mode 100644 index 00000000..4b5d1934 --- /dev/null +++ b/api/postal-geocode.js @@ -0,0 +1,155 @@ +// @ts-check + +import { fetchWithValidatedRedirects, readResponseTextWithCap } from '../lib/proxy-upstream.js'; + +const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const CACHE_MAX = 512; +const DEFAULT_QUEUE_MAX = 8; +const cache = new Map(); +let requestQueue = Promise.resolve(); +let lastRequestStartedAt = 0; +let queuedRequests = 0; + +class PostalQueueFullError extends Error {} + +function postalAbortReason(signal) { + return signal?.reason instanceof Error + ? signal.reason + : new DOMException('Postal lookup aborted', 'AbortError'); +} + +function waitForPostalThrottle(waitMs, signal) { + if (!waitMs) return Promise.resolve(); + if (signal?.aborted) return Promise.reject(postalAbortReason(signal)); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener?.('abort', onAbort); + resolve(); + }, waitMs); + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener?.('abort', onAbort); + reject(postalAbortReason(signal)); + }; + signal?.addEventListener?.('abort', onAbort, { once: true }); + }); +} + +function settlePostalRequestForCaller(pending, signal, releaseSlot) { + if (!signal?.addEventListener) return pending.finally(releaseSlot); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + releaseSlot(); + callback(value); + }; + const onAbort = () => finish(reject, postalAbortReason(signal)); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + pending.then( + value => finish(resolve, value), + error => finish(reject, error), + ); + }); +} + +function postalQueueMax() { + const configured = Number.parseInt(process.env.PROXY_POSTAL_QUEUE_MAX || '', 10); + return Number.isFinite(configured) && configured >= 1 && configured <= 64 + ? configured + : DEFAULT_QUEUE_MAX; +} + +function fetchPostalGeocodeUpstream(url, options, signal) { + if (signal?.aborted) throw postalAbortReason(signal); + if (queuedRequests >= postalQueueMax()) { + throw new PostalQueueFullError('Postal lookup queue is full'); + } + queuedRequests++; + let slotReleased = false; + const releaseSlot = () => { + if (slotReleased) return; + slotReleased = true; + queuedRequests--; + }; + const run = async () => { + if (signal?.aborted) throw postalAbortReason(signal); + const waitMs = Math.max(0, 1100 - (Date.now() - lastRequestStartedAt)); + await waitForPostalThrottle(waitMs, signal); + if (signal?.aborted) throw postalAbortReason(signal); + lastRequestStartedAt = Date.now(); + return fetchWithValidatedRedirects(url, options, { signal }); + }; + const pending = requestQueue.then(run, run); + requestQueue = pending.then(() => undefined, () => undefined); + return settlePostalRequestForCaller(pending, signal, releaseSlot); +} + +/** + * @param {Record} payload + * @param {any} req + * @param {{ corsHeaders: (req: any) => Record, proxyUpstreamErrorResponse: (req: any, error: unknown, fallback: string) => Response }} helpers + */ +export async function handlePostalGeocode(payload, req, helpers) { + const country = typeof payload.country === 'string' ? payload.country.trim() : ''; + const postalCode = typeof payload.postalCode === 'string' ? payload.postalCode.trim() : ''; + const responseHeaders = () => ({ ...helpers.corsHeaders(req), 'Content-Type': 'application/json' }); + if (!country || !postalCode || country.length > 80 || postalCode.length > 24) { + return new Response(JSON.stringify({ error: 'Invalid country/postal code' }), { status: 400, headers: responseHeaders() }); + } + if (!/^[\p{L}\p{N} .-]+$/u.test(postalCode)) { + return new Response(JSON.stringify({ error: 'Invalid postal code characters' }), { status: 400, headers: responseHeaders() }); + } + const cacheKey = `${country}|${postalCode}`.toLowerCase(); + const cached = cache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt <= CACHE_TTL_MS) { + return new Response(JSON.stringify(cached.value), { + status: 200, + headers: { ...responseHeaders(), 'Cache-Control': 'no-store' }, + }); + } + const query = new URLSearchParams({ country, postalcode: postalCode, format: 'jsonv2', limit: '3', addressdetails: '1' }); + const url = `https://nominatim.openstreetmap.org/search?${query.toString()}`; + try { + const upstream = await fetchPostalGeocodeUpstream(url, { + headers: { 'Accept': 'application/json', 'User-Agent': 'getbased-health-location-proxy/1.0 (+https://getbased.health)' }, + }, req.signal); + const text = await readResponseTextWithCap(upstream, 64 * 1024); + if (!upstream.ok) { + return new Response(JSON.stringify({ error: 'Location lookup unavailable' }), { status: upstream.status, headers: responseHeaders() }); + } + const results = JSON.parse(text); + const rows = Array.isArray(results) ? results : []; + const normalizedPostal = postalCode.replace(/\s+/g, '').toLowerCase(); + const match = rows.find(item => String(item?.address?.postcode || item?.name || '').replace(/\s+/g, '').toLowerCase() === normalizedPostal) || rows[0]; + const latitude = Number(match?.lat); + const longitude = Number(match?.lon); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return new Response(JSON.stringify({ error: 'Location not found' }), { status: 404, headers: responseHeaders() }); + } + const value = { + latitude: Math.round(latitude * 10) / 10, + longitude: Math.round(longitude * 10) / 10, + accuracyKm: 11, + timezone: null, + label: typeof match.display_name === 'string' ? match.display_name : `${postalCode}, ${country}`, + source: 'postal-area', + resolvedAt: Date.now(), + attribution: '© OpenStreetMap contributors', + }; + cache.set(cacheKey, { cachedAt: Date.now(), value }); + while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value); + return new Response(JSON.stringify(value), { status: 200, headers: { ...responseHeaders(), 'Cache-Control': 'no-store' } }); + } catch (error) { + if (error instanceof PostalQueueFullError) { + return new Response(JSON.stringify({ error: 'Location lookup busy. Try again shortly.' }), { + status: 503, + headers: { ...responseHeaders(), 'Retry-After': '10' }, + }); + } + return helpers.proxyUpstreamErrorResponse(req, error, 'Location lookup unavailable'); + } +} diff --git a/api/proxy.js b/api/proxy.js index 015ef5bf..ac503419 100644 --- a/api/proxy.js +++ b/api/proxy.js @@ -17,6 +17,7 @@ import { readResponseTextWithCap, } from '../lib/proxy-upstream.js'; import { errorCode } from '../lib/error-utils.js'; +import { handlePostalGeocode } from './postal-geocode.js'; const DEFAULT_UVDATA_UPSTREAM = 'https://uvdata.getbased.health'; /** @type {Promise | null} */ @@ -214,6 +215,9 @@ export async function handler(req) { if (payload.meteo === 'cams') { return handleCamsRelay(payload, req); } + if (payload.meteo === 'postal_geocode') { + return handlePostalGeocode(payload, req, { corsHeaders, proxyUpstreamErrorResponse }); + } const { url, headers, body, method: upstreamMethod } = payload; diff --git a/css/light-channels.css b/css/light-channels.css index 58b8847b..9bcc0331 100644 --- a/css/light-channels.css +++ b/css/light-channels.css @@ -20,7 +20,7 @@ justify-self: start; } -/* Channel pill row — qualitative tier indicator */ +/* Channel pill row — source-aware exposure state, not a progress meter. */ .light-pills-row { display: flex; flex-wrap: wrap; gap: 8px; } .light-pill { display: inline-flex; align-items: center; gap: 6px; @@ -32,11 +32,8 @@ } .light-pill-icon { font-size: 13px; } .light-pill-label { font-weight: 500; } -/* 7-day sparkline embedded in the pill — replaces the prior dot - metaphor which implied a fillable container. Bars show daily rhythm - (today = rightmost), color-coded green when day hit target / accent - when meaningful / faint stub when negligible. Reinforces the - daily-beats-banking framing with a real-data visualization. */ +/* Seven-day sparkline embedded in the pill. Solid bars are sunlight and + faded bars are devices; height is relative to this user's own week. */ .light-pill-sparkline { display: inline-block; flex: 0 0 auto; @@ -48,6 +45,7 @@ font-variant-numeric: tabular-nums; color: inherit; opacity: 0.9; min-width: 24px; text-align: right; + white-space: nowrap; } /* Legacy class kept in case anything still references it; renders as zero-width so older cached templates don't blow out the layout. */ @@ -67,6 +65,8 @@ .light-pill-tier-2 { color: var(--text-primary); border-color: var(--text-muted); } .light-pill-tier-3 { color: var(--accent); border-color: var(--accent); } .light-pill-tier-4 { color: var(--green); border-color: var(--green); } +.light-pill-signal-logged { color: var(--text-primary); } +.light-pill-signal-empty { color: var(--text-muted); } .light-channels-section { margin-top: 0; @@ -143,7 +143,7 @@ } .light-channels-section .light-pill-daycount { grid-area: count; - min-width: 34px; + min-width: 0; padding: 3px 7px; border-radius: 999px; background: color-mix(in srgb, var(--channel-accent) 12%, transparent); @@ -172,6 +172,9 @@ .light-channels-section .light-pill-tier-4 { border-color: color-mix(in srgb, var(--channel-accent) 48%, var(--border)); } +.light-channels-section .light-pill-signal-logged { + border-color: color-mix(in srgb, var(--channel-accent) 32%, var(--border)); +} .light-pills-interactive .light-pill-interactive, .light-pill-dashboard { cursor: pointer; @@ -251,8 +254,8 @@ font-size: 13px; line-height: 1.5; color: var(--text-secondary); margin: 0; } -/* Tier pill in the channel-detail hero — state belongs with the weekly - numbers, while the header stays reserved for channel identity. */ +/* Legacy tier-pill styling retained for cached/older markup. The current + source-aware view uses neutral logged/empty states instead. */ .light-channel-detail-tierpill { margin-left: auto; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; @@ -260,16 +263,8 @@ border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-muted); } -/* Tier color scheme aligns with the chart's hit-target semantics - (chart bars: gray <5%, faint accent <30%, accent 30-100%, GREEN at - target, GREEN above target). The header tier pill should match: - - tier 0: muted gray (no exposure) - - tier 1: low / accent muted - - tier 2: moderate / accent - - tier 3: good / accent (approaching hit-target) - - tier 4: strong / GREEN (hit target across the week) - Previously tier 4 was RED which conflicted with the chart's - green-for-hit-target visual: same channel, opposite signal. */ +/* These colors no longer communicate biological sufficiency. New cards use + tier 0 for no log and tier 2 as a compatibility class for signal logged. */ .light-channel-detail-tierpill.tier1 { color: var(--text-secondary); border-color: var(--border); background: var(--bg-secondary); } .light-channel-detail-tierpill.tier2 { color: var(--accent); @@ -287,9 +282,7 @@ background: color-mix(in srgb, var(--green) 12%, transparent); } -/* Hero stat block — the big number you actually want to see (e.g. "~1.8k IU - vitamin D this week"). Lives directly under the header so the eye lands - on it before the prose. */ +/* Hero block leads with a plain source state rather than a score. */ .light-channel-hero { display: flex; flex-direction: column; gap: 2px; padding: 10px 14px; border-radius: var(--radius-sm); @@ -305,9 +298,8 @@ min-width: 0; } .light-channel-hero-primary { - font-size: 24px; font-weight: 700; line-height: 1.1; + font-size: 18px; font-weight: 700; line-height: 1.2; color: var(--text-primary); - font-variant-numeric: tabular-nums; } .light-channel-hero-sub { font-size: 12px; color: var(--text-secondary); line-height: 1.4; @@ -320,8 +312,29 @@ .light-channel-hero-trend.down { color: var(--orange); } .light-channel-hero-trend.flat { color: var(--text-muted); } -/* Sun/device source-mix bar — slim 6px stacked bar + legend below. - Hidden when one source is essentially 100% (no useful "mix"). */ +/* Source labels deliberately avoid a percentage mix: a targeted device is + not presented as a fraction of a sunlight-equivalent biological score. */ +.light-channel-sources { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px 10px; + font-size: 11px; + color: var(--text-secondary); +} +.light-channel-source { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-secondary); +} +.light-channel-source.is-empty { color: var(--text-muted); opacity: 0.7; } +.light-channel-sources small { + flex: 1 1 100%; + color: var(--text-muted); +} .light-channel-mix { display: flex; flex-direction: column; gap: 4px; } @@ -365,10 +378,7 @@ display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 10px; } -/* "Daily beats banking" caption — channel-specific reason daily exposure - matters more than one big day. Sits between the chart and Next-move - so the user reads "what counted as a real day this week" → "here's why - that framing is the one that matches biology" → "what to do next." */ +/* Quiet reading note beneath the chart. */ .light-channel-banking-note { margin: 0; padding: 8px 12px; @@ -462,19 +472,116 @@ } .light-channel-mix-ai .sun-detail-ai { margin-bottom: 0; } .light-channel-mix-ai-cta { - display: block; + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.light-weekly-ai-action-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-top: 10px; +} +.light-weekly-ai-action-status { + color: var(--text-muted); + font-size: 11.5px; + line-height: 1.4; +} +.light-weekly-ai-unavailable { + display: grid; + gap: 2px; + margin-top: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} +.light-weekly-ai-unavailable strong { + color: var(--text-primary); +} + +/* Seven-day comparison. This surface stays neutral: warning colors belong + to the deterministic UV-safety block in Today's Light. */ +.light-weekly-fallback { + display: grid; + gap: 7px; + margin: 0; + padding: 14px 16px; + background: color-mix(in srgb, var(--bg-card) 94%, transparent); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); +} +.light-weekly-period { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; +} +.light-weekly-summary-text, +.light-weekly-comparison, +.light-weekly-next-step, +.light-weekly-disclaimer { + margin: 0; + line-height: 1.5; +} +.light-weekly-summary-text { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; +} +.light-weekly-comparison, +.light-weekly-next-step { + color: var(--text-secondary); + font-size: 12.5px; +} +.light-weekly-log-cta { + justify-self: start; + margin-top: 3px; +} +.light-weekly-disclaimer { + color: var(--text-muted); + font-size: 11.5px; +} +.light-weekly-ai-review { + padding: 14px 16px; + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-card) 94%, transparent); +} +.light-weekly-ai-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.light-weekly-ai-tip { + margin-top: 5px; + color: var(--text-primary); + font-size: 14px; + font-weight: 650; + line-height: 1.4; +} +.light-weekly-ai-detail { margin-top: 8px; - padding: 6px 14px; - background: transparent; - border: 1px dashed var(--border); - border-radius: 6px; - color: var(--accent); + color: var(--text-secondary); font-size: 12.5px; - cursor: pointer; + line-height: 1.5; } -.light-channel-mix-ai-cta:hover { - border-color: var(--accent); - border-style: solid; +.light-weekly-ai-loading { + display: flex; + align-items: center; + gap: 9px; + min-height: 48px; + padding: 12px 14px; + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); } @media (prefers-reduced-motion: reduce) { diff --git a/css/light-conditions-now.css b/css/light-conditions-now.css index 77083b1f..7a9e2d2e 100644 --- a/css/light-conditions-now.css +++ b/css/light-conditions-now.css @@ -24,8 +24,7 @@ @media (pointer: coarse) { .conditions-now-refresh, - .conditions-now-inspect, - .conditions-now-override button { + .conditions-now-inspect { min-height: 44px; min-width: 44px; display: inline-flex; @@ -47,6 +46,32 @@ min-width: 0; max-width: 100%; } +.conditions-now-location-setup { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px dashed color-mix(in srgb, var(--border) 88%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-secondary) 60%, transparent); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} +.conditions-now-location-setup .dashboard-action-btn { + flex-shrink: 0; +} +@media (max-width: 600px) { + .conditions-now-location-setup { + align-items: stretch; + flex-direction: column; + } + .conditions-now-location-setup .dashboard-action-btn { + width: 100%; + justify-content: center; + } +} .light-conditions-now-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 12px; @@ -146,6 +171,13 @@ text-align: left; margin-bottom: 10px; } +.conditions-now-events-scroll-hint { + display: none; + float: right; + color: var(--text-muted); + font-weight: 500; + text-transform: none; +} .conditions-now-events { min-width: 0; max-width: 100%; @@ -304,20 +336,6 @@ margin-left: 1px; } -/* Footnote at the bottom — burn-time estimates depend on more than - skin type. Italic muted, soft border above. */ -.conditions-now-footnote { - margin-top: 10px; padding: 8px 0; - font-size: 11px; color: var(--text-muted); - font-style: italic; line-height: 1.5; - border-top: 1px solid var(--border); -} -.conditions-now-footnote strong { - color: var(--text-secondary); - font-weight: 600; - font-style: normal; -} - /* App tooltip carriers used by custom data attrs. Legacy `title` attributes are picked up by js/touch-tooltip.js without extra markup. */ .conditions-now [data-conditions-tooltip], @@ -504,46 +522,6 @@ button[data-app-tooltip] { .conditions-now-source.is-fresh .conditions-now-source-dot { background: var(--green); box-shadow: 0 0 4px var(--green); } .conditions-now-source.is-stale .conditions-now-source-dot { background: var(--yellow); } .conditions-now-source.is-offline .conditions-now-source-dot { background: var(--orange); } -.conditions-now-override { - display: inline-flex; align-items: center; gap: 6px; - font-size: 11px; color: var(--text-muted); -} -.conditions-now-override input[type="number"] { - width: 56px; padding: 2px 6px; - background: var(--bg-secondary); border: 1px solid var(--border); - border-radius: 4px; font-size: 11px; color: var(--text-primary); - font-family: inherit; -} -.conditions-now-override button { - background: transparent; border: 1px solid var(--border); - border-radius: 4px; padding: 2px 8px; font-size: 11px; - color: var(--text-secondary); cursor: pointer; font-family: inherit; -} -/* Mobile: stack the manual-UVI row onto its own line and grow input + - button so they meet a usable tap target. */ -@media (max-width: 600px) { - .conditions-now-override { - flex-basis: 100%; width: 100%; - margin-top: 4px; - } - .conditions-now-override label { flex: 0 0 auto; } - .conditions-now-override input[type="number"] { - flex: 1; min-width: 0; width: auto; - padding: 8px 10px; font-size: 14px; - } - .conditions-now-override button { - padding: 8px 14px; font-size: 13px; - min-height: 38px; - } -} -.conditions-now-override button:hover { color: var(--text-primary); border-color: var(--text-secondary); } -.conditions-now-override-badge { - display: inline-block; margin-left: 6px; - font-size: 9px; font-weight: 700; letter-spacing: 0.5px; - text-transform: uppercase; - padding: 1px 6px; border-radius: 3px; - background: var(--accent); color: var(--bg); -} .conditions-now-refresh, .conditions-now-inspect { background: color-mix(in srgb, var(--bg-secondary) 64%, transparent); border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); @@ -551,6 +529,7 @@ button[data-app-tooltip] { padding: 3px 8px; font-size: 11px; color: var(--text-secondary); cursor: pointer; font-family: inherit; + min-height: 28px; } .conditions-now-refresh:hover, .conditions-now-inspect:hover { background: var(--bg-secondary); @@ -598,6 +577,22 @@ button[data-app-tooltip] { color: var(--orange); font-size: 11px; cursor: help; } +.conditions-now-attribution { + color: inherit; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); + text-underline-offset: 2px; +} +.conditions-now-attribution:hover { color: var(--text-primary); } +.conditions-now-cell.is-unreliable, +.conditions-now-pill.is-unreliable { + border-color: color-mix(in srgb, var(--orange) 48%, var(--border)); +} +.conditions-now-cell.is-unreliable .conditions-now-value, +.conditions-now-pill.is-unreliable strong, +.conditions-now-cell.is-unreliable .conditions-now-interpretation { + color: var(--orange); +} /* Compact source pill — appears in dashboard Light Today strip */ .conditions-now-source-compact { @@ -617,6 +612,7 @@ button[data-app-tooltip] { } @media (max-width: 600px) { + .conditions-now-events-scroll-hint { display: inline; } /* Conditions-now full variant on the Light page is much taller than the compact dashboard variant. On phones, drop to the compact style for parity. */ diff --git a/css/light-env.css b/css/light-env.css index bdad30f8..ab121cb3 100644 --- a/css/light-env.css +++ b/css/light-env.css @@ -24,6 +24,7 @@ .light-env-assessment-summary-green { border-left-color: var(--green); } .light-env-assessment-summary-orange { border-left-color: var(--orange); } .light-env-assessment-summary-red { border-left-color: var(--red); } +.light-env-assessment-summary-incomplete { border-left-color: var(--text-muted); } .light-env-assessment-status { display: flex; align-items: center; @@ -249,6 +250,14 @@ margin: 0; font-size: 13px; line-height: 1.5; color: var(--text-secondary); } +.light-env-summary-incomplete { border-left-color: var(--text-muted); } +.light-env-picker-help { + display: block; + margin-top: 5px; + color: var(--text-muted); + font-size: 11px; + line-height: 1.35; +} /* Legacy classes used elsewhere — kept for back-compat */ .light-env-summary strong { color: var(--text-primary); } .light-env-summary-sep { opacity: 0.4; } diff --git a/css/light-sun.css b/css/light-sun.css index c19615e7..6ec040a0 100644 --- a/css/light-sun.css +++ b/css/light-sun.css @@ -34,6 +34,30 @@ gap: 14px; min-width: 0; } +.light-device-safety { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--orange) 36%, var(--border)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--orange-bg) 58%, var(--bg-card)); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} +.light-device-safety strong { color: var(--text-primary); } +.light-device-safety.is-over { + border-color: color-mix(in srgb, var(--red) 55%, var(--border)); + background: color-mix(in srgb, var(--red-bg) 68%, var(--bg-card)); +} + +@media (max-width: 720px) { + .light-page { padding-bottom: 88px; } + .light-page small, + .light-page .dashboard-widget-description, + .light-page .light-section-hint, + .light-page .dev-session-hint { font-size: 12px; line-height: 1.45; } +} .dashboard-widget[data-widget-id="light-today"] .dashboard-widget-body { display: grid; gap: 14px; @@ -50,9 +74,9 @@ background: transparent; box-shadow: none; } -.light-page .dashboard-widget[data-widget-id="light-live-session"] .light-active-session-pinned, +.dashboard-widget[data-widget-id="light-live-session"] .light-active-session-pinned, .light-page .dashboard-widget[data-widget-id="light-sessions"] .sun-sessions-list, -.light-page .dashboard-widget[data-widget-id="light-guidance"] .light-med-banner { +.light-page .dashboard-widget[data-widget-id="light-today"] .light-med-banner { margin: 0; } .light-page .dashboard-widget[data-widget-id="light-methods"] .dashboard-widget-body { @@ -271,8 +295,11 @@ /* Top-3 chip expand on session card */ .sun-channel-chips .sun-chip-extra { display: none; } .sun-channel-chips.sun-chips-expanded .sun-chip-extra { display: inline-flex; } -.sun-channel-chips.sun-chips-expanded .sun-chip-more { display: none; } +.sun-chip-more-expanded { display: none; } +.sun-channel-chips.sun-chips-expanded .sun-chip-more-collapsed { display: none; } +.sun-channel-chips.sun-chips-expanded .sun-chip-more-expanded { display: inline; } .sun-chip-more { + order: 99; font-size: 11px; padding: 3px 10px; background: transparent; border: 1px dashed var(--border); border-radius: 12px; color: var(--text-muted); @@ -280,11 +307,8 @@ } .sun-chip-more:hover { color: var(--accent); border-color: var(--accent); } -/* Unified sessions list — sun + device sessions interleaved - chronologically. Row shape mirrors the sun-only list with an extra - leading icon and a "kind" pill so each row is scannable. Falls back - to the existing .sun-session styling for the bulk of the chrome. */ -.light-sessions-list-unified .light-session-row { gap: 6px; } +/* Completed sun + device history uses one compact scan-row. Rich channel, + setup, model-input and AI content is intentionally reserved for details. */ .light-session-row[role="button"] { cursor: pointer; } .sun-sessions-list, .sun-session { @@ -299,17 +323,7 @@ background: color-mix(in srgb, var(--session-accent, var(--accent)) 12%, var(--bg-card)); border: 1px solid color-mix(in srgb, var(--session-accent, var(--accent)) 28%, transparent); } -.light-session-kind { - font-size: 11px; color: var(--text-muted); - text-transform: uppercase; letter-spacing: 0.04em; - padding: 2px 8px; border-radius: 10px; - background: var(--bg-secondary); - margin-left: auto; - max-width: min(100%, 42ch); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} +.light-session-kind { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } .light-session-device .light-session-icon { color: var(--accent); } .light-session-sun .light-session-icon { color: var(--orange); } @@ -331,6 +345,60 @@ border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); } +.light-session-complete { + display: flex; + align-items: center; + gap: 12px; + min-height: 64px; + padding: 11px 14px; + background: color-mix(in srgb, var(--bg-card) 96%, transparent); + border-color: color-mix(in srgb, var(--border) 88%, transparent); + box-shadow: none; +} +.light-session-complete:hover { + border-color: color-mix(in srgb, var(--session-accent) 32%, var(--border)); + box-shadow: 0 5px 14px color-mix(in srgb, var(--text-primary) 6%, transparent); +} +.light-session-complete:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.light-session-summary { flex: 1; min-width: 0; } +.light-session-title { + display: flex; align-items: baseline; gap: 8px; + min-width: 0; + color: var(--text-primary); + font-size: 13.5px; + font-weight: 650; + overflow-wrap: anywhere; +} +.light-session-meta-line { + display: flex; flex-wrap: wrap; align-items: center; gap: 4px 9px; + margin-top: 4px; + color: var(--text-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.light-session-meta-line > span + span::before { + content: '·'; + margin-right: 9px; + color: var(--border-strong, var(--text-muted)); +} +.light-session-outcome { color: var(--text-secondary); font-weight: 600; } +.light-session-warning { + max-width: 190px; + padding: 4px 8px; + border: 1px solid color-mix(in srgb, var(--orange) 35%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--orange-bg) 58%, transparent); + color: var(--orange); + font-size: 10.5px; + line-height: 1.25; + text-align: center; +} +.light-session-warning-danger { + border-color: color-mix(in srgb, var(--red) 45%, var(--border)); + background: color-mix(in srgb, var(--red-bg) 65%, transparent); + color: var(--red); +} +.light-session-chevron { color: var(--text-muted); font-size: 21px; line-height: 1; } /* Reduced motion — strip transforms + transitions for users with the OS preference set. Hover lifts and tier-changed badge flashes feel disorienting @@ -463,6 +531,71 @@ textarea.ctx-input { resize: vertical; min-height: 56px; } .sun-detail-section-value { font-size: 13px; color: var(--text-primary); line-height: 1.5; } +.sun-detail-setup-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; +} +.sun-detail-setup-grid > div { + min-width: 0; + display: grid; + gap: 3px; + padding: 9px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-secondary) 72%, transparent); +} +.sun-detail-setup-grid span, +.sun-detail-input-location span { + color: var(--text-muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.sun-detail-setup-grid strong, +.sun-detail-input-location strong { + color: var(--text-primary); + font-size: 12.5px; + font-weight: 600; + line-height: 1.4; + overflow-wrap: anywhere; +} +.sun-calculation-state { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + margin: 0 0 14px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--orange) 35%, var(--border)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--orange-bg) 52%, var(--bg-card)); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; +} +.sun-calculation-state.is-error { border-color: color-mix(in srgb, var(--red) 48%, var(--border)); } +.sun-calculation-state .import-btn { flex-shrink: 0; } +.sun-detail-disclosure { + margin: 12px 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-secondary) 48%, transparent); + overflow: hidden; +} +.sun-detail-disclosure > summary { + padding: 11px 13px; + color: var(--text-primary); + font-size: 13px; + font-weight: 650; + cursor: pointer; + list-style-position: inside; +} +.sun-detail-disclosure[open] > summary { border-bottom: 1px solid var(--border); } +.sun-detail-disclosure > p, +.sun-detail-disclosure > .sun-detail-channels, +.sun-detail-disclosure > .sun-detail-atm, +.sun-detail-disclosure > .sun-detail-input-location, +.sun-detail-disclosure > .light-device-safety { margin: 10px 12px; } +.sun-detail-disclosure > p { color: var(--text-muted); font-size: 12px; line-height: 1.45; } +.sun-detail-input-location { display: grid; gap: 3px; } .sun-detail-channels { display: flex; flex-direction: column; gap: 6px; } @@ -972,6 +1105,17 @@ svg.sun-silhouette text.sun-silhouette-label { } /* Burn-risk banner on the Light & Sun page */ +.light-today-uv-safety { + display: grid; + gap: 7px; +} +.light-today-safety-label { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; +} .light-med-banner { margin: 16px 0; display: flex; align-items: center; gap: 12px; @@ -982,6 +1126,7 @@ svg.sun-silhouette text.sun-silhouette-label { } .light-med-banner.light-med-warn { border-color: var(--orange); background: var(--orange-bg); } .light-med-banner.light-med-over { border-color: var(--red); background: var(--red-bg); } +.light-med-banner.light-med-info .light-med-icon { color: var(--text-muted); } .light-med-icon { font-size: 20px; } .light-med-banner.light-med-ok .light-med-icon { color: var(--green); } .light-med-banner.light-med-warn .light-med-icon { color: var(--orange); } @@ -1074,7 +1219,7 @@ svg.sun-silhouette text.sun-silhouette-label { align-items: flex-start; gap: 8px; } -.light-page .dashboard-widget[data-widget-id="light-sessions"] .light-session-kind { +.light-page .dashboard-widget[data-widget-id="light-sessions"] .light-session-row:not(.light-session-complete) .light-session-kind { flex: 1 1 100%; margin-left: 38px; max-width: calc(100% - 38px); @@ -1082,7 +1227,7 @@ svg.sun-silhouette text.sun-silhouette-label { overflow-wrap: anywhere; line-height: 1.35; } -.light-page .dashboard-widget[data-widget-id="light-sessions"] .light-session-mode-chip { +.light-page .dashboard-widget[data-widget-id="light-sessions"] .light-session-row:not(.light-session-complete) .light-session-mode-chip { max-width: calc(100% - 38px); white-space: normal; overflow-wrap: anywhere; @@ -1094,18 +1239,51 @@ svg.sun-silhouette text.sun-silhouette-label { .sun-session-head { display: flex; align-items: center; gap: 12px; } .sun-session-date { font-weight: 600; color: var(--text-primary); } .sun-session-duration { color: var(--text-secondary); font-size: 13px; } -/* Live vitamin D synthesis chip — surfaces approximate IU + IU/min on - active sessions. Sits next to the burn-dose chip in the session-row - header. Color stays neutral (it's a positive signal regardless of - tier) to keep the burn-dose chip's color the load-bearing visual. */ +/* Live estimates get their own wrapping row instead of competing with the + timer in the fixed session header. Vitamin D is intentionally first and + gets the widest track; safety/eye/heat chips remain visible beside it. */ +.sun-session-live-readouts { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: 7px; + min-width: 0; + margin-top: 9px; +} +.sun-session-live-readouts > span { + min-width: 0; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; +} +.sun-session-live-readouts .sun-session-med { + margin-left: 0; + display: inline-flex; + align-items: center; +} .sun-session-vitd { - display: inline-flex; align-items: center; + display: inline-flex; + flex: 1 1 300px; + align-items: center; + flex-wrap: wrap; + gap: 3px 8px; font-size: 11px; font-weight: 500; - padding: 2px 8px; border-radius: 10px; + padding: 5px 9px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); color: var(--accent); - white-space: nowrap; +} +.sun-session-vitd strong { + color: var(--text-primary); + font-weight: 650; +} +.sun-session-vitd span + span::before { + content: '·'; + margin-right: 8px; + color: var(--text-muted); +} +.sun-session-vitd-idle { + color: var(--text-secondary); } .sun-session-med { margin-left: auto; @@ -1348,39 +1526,6 @@ svg.sun-silhouette text.sun-silhouette-label { top: 18px; right: 22px; } -.light-sessions-modal-summary { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; - padding: 14px 22px; - border-bottom: 1px solid var(--border); - background: color-mix(in srgb, var(--bg-card) 62%, transparent); -} -.light-sessions-modal-summary > div { - min-width: 0; - padding: 9px 11px; - border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--bg-secondary) 76%, transparent); -} -.light-sessions-modal-summary span { - display: block; - color: var(--text-muted); - font-family: var(--font-mono); - font-size: 10px; - font-weight: 800; - text-transform: uppercase; -} -.light-sessions-modal-summary strong { - display: block; - margin-top: 3px; - color: var(--text-primary); - font-size: 15px; - font-weight: 700; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} .light-sessions-modal-body { min-height: 0; overflow-y: auto; @@ -1522,7 +1667,13 @@ svg.sun-silhouette text.sun-silhouette-label { color: var(--text-secondary); border: 1px solid color-mix(in srgb, var(--channel-accent) 18%, var(--border)); display: inline-flex; align-items: center; gap: 5px; + font-family: inherit; + line-height: inherit; + text-align: left; + cursor: pointer; } +.sun-chip:hover { border-color: color-mix(in srgb, var(--channel-accent) 52%, var(--border)); } +.sun-chip:focus-visible { outline: 2px solid var(--channel-accent); outline-offset: 2px; } .sun-chip[data-channel="vitamin_d"] { --channel-accent: var(--yellow); } .sun-chip[data-channel="circadian"] { --channel-accent: var(--accent); } .sun-chip[data-channel="nir_solar"] { --channel-accent: var(--red); } @@ -1653,7 +1804,7 @@ svg.sun-silhouette text.sun-silhouette-label { overflow: hidden; text-overflow: ellipsis; } - .light-session-kind { + .light-session-row:not(.light-session-complete) .light-session-kind { flex: 1 1 100%; margin-left: 28px; max-width: calc(100% - 28px); @@ -1661,7 +1812,7 @@ svg.sun-silhouette text.sun-silhouette-label { overflow-wrap: anywhere; line-height: 1.35; } - .light-session-mode-chip { + .light-session-row:not(.light-session-complete) .light-session-mode-chip { max-width: 100%; white-space: normal; overflow-wrap: anywhere; @@ -1677,11 +1828,34 @@ svg.sun-silhouette text.sun-silhouette-label { .light-sessions-modal-head h3 { font-size: 19px; } - .light-sessions-modal-summary { - grid-template-columns: repeat(2, minmax(0, 1fr)); - padding: 12px; - gap: 8px; + .light-session-complete { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: start; + column-gap: 12px; + row-gap: 8px; + } + .light-session-complete .light-session-icon { + grid-column: 1; + grid-row: 1; + } + .light-session-complete .light-session-summary { + grid-column: 2; + grid-row: 1; + } + .light-session-complete .light-session-chevron { + grid-column: 3; + grid-row: 1; + } + .light-session-complete .light-session-mode-chip, + .light-session-complete .light-session-warning { + grid-column: 2 / -1; + justify-self: start; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; } + .sun-calculation-state { align-items: flex-start; flex-direction: column; } .light-sessions-modal-body { padding: 12px; } diff --git a/css/light-tools.css b/css/light-tools.css index dd4ccaf0..0e30ca7e 100644 --- a/css/light-tools.css +++ b/css/light-tools.css @@ -195,6 +195,84 @@ min-height: 1.35em; } +.lux-source-picker { + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; + align-items: stretch; + margin: 12px 0 8px; +} +.lux-source-picker-label { + align-self: center; + font-size: 12px; + color: var(--text-muted); +} +.lux-source-option { + min-height: 48px; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + text-align: left; +} +.lux-source-option span, +.lux-source-option small { + display: block; +} +.lux-source-option span { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} +.lux-source-option small { + margin-top: 2px; + font-size: 10px; + color: var(--text-muted); +} +.lux-source-option.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-secondary)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); +} +.lux-source-option:disabled { + cursor: not-allowed; + opacity: 0.45; +} +.lux-manual-entry { + min-height: 132px; + padding: 18px 0; + text-align: center; + box-sizing: border-box; +} +.lux-dial[hidden], +.lux-manual-entry[hidden] { + display: none; +} +.lux-manual-entry label { + display: block; + margin-bottom: 8px; + font-size: 12px; + color: var(--text-muted); +} +.lux-manual-entry-row { + display: flex; + align-items: baseline; + justify-content: center; + gap: 8px; +} +.lux-manual-entry-row input { + width: 140px; + padding: 8px 10px; + font-size: 20px; + text-align: center; +} +.lux-manual-entry-row span { + font-size: 14px; + color: var(--text-muted); +} + .lux-dial { text-align: center; padding: 24px 0; min-height: 132px; @@ -227,6 +305,15 @@ } .lux-zone-marker span { color: var(--text-secondary); margin-left: 2px; } +@media (max-width: 430px) { + .lux-source-picker { + grid-template-columns: 1fr 1fr; + } + .lux-source-picker-label { + grid-column: 1 / -1; + } +} + /* Flicker detector + dark meter result lines */ .flicker-result, .dark-status { margin-top: 14px; padding: 12px 14px; diff --git a/data/light-device-presets.json b/data/light-device-presets.json index 1f3931f1..3b5d172d 100644 --- a/data/light-device-presets.json +++ b/data/light-device-presets.json @@ -1,6 +1,6 @@ { - "version": 3, - "description": "Preset library — curated to the brands getbased has direct context on (Mitochondriak, Chroma, EMR-Tek). Anything not on this list goes through the AI-powered custom-add flow (paste URL or scan label) which extracts specs via the user's Claude provider. Each preset maps to a channel-specific dose profile so deviceSessions feed the same accumulators as outdoor solar sessions. catalogSlug links to data/recommendations.json `_internal.lightDevices` for the affiliate-link surface on each device card. recommendedDistanceCm is the vendor's reference distance for the published irradiance — also used as the default working distance in the session-log dialog so dose math at default == manufacturer's spec, sidestepping the inverse-square pitfall at close ranges.", + "version": 4, + "description": "Preset library for supported light devices. Device sessions retain their source spectrum and remain distinct from full-spectrum sunlight. recommendedDistanceCm is the reference distance for a stored output and the default session distance. Off-reference dose is adjusted only when measured distance data or an explicitly declared point-source model exists; UV estimates require band-resolved output.", "presets": [ { "id": "mitochondriak-pulse", @@ -8,11 +8,24 @@ "model": "Pulse", "type": "pbm-targeted", "peakWavelengths": [460, 630, 670, 850, 900], - "mwPerCm2At15cm": 50, - "recommendedDistanceCm": 15, + "mwPerCm2At15cm": 230, + "recommendedDistanceCm": 2, + "irradianceBasis": "measured-spectrometer", + "specSourceUrl": "https://www.mitochondriak.com/laser-torch-mitochondriak-pulse/", "channels": ["pbm_red", "pbm_nir"], + "channelGroups": [ + { "id": "blue", "label": "Blue", "peaks": [460] }, + { "id": "red", "label": "Red", "peaks": [630, 670] }, + { "id": "nir", "label": "Near-infrared", "peaks": [850, 900] } + ], + "modes": [ + { "id": "red", "label": "Red", "groups": ["red"], "default": true }, + { "id": "nir", "label": "Near-infrared", "groups": ["nir"] }, + { "id": "blue", "label": "Blue", "groups": ["blue"] }, + { "id": "all-on", "label": "All bands", "groups": ["blue", "red", "nir"] } + ], "catalogSlug": "mitochondriak-pulse", - "notes": "Handheld torch (12.5 cm wand). 5 wavelengths incl. blue 460 nm. Manufacturer quotes >230 mW/cm² @ 1 cm; ~50 at 15 cm working distance. Targeted PBM — small illuminated area." + "notes": "Handheld targeted torch. The vendor reports about 230 mW/cm² by spectrometer at 2 cm and a built-in five-minute shutoff. Red is the first mode; red, NIR, blue, and combined selections are available." }, { "id": "mitochondriak-bulb", @@ -20,23 +33,27 @@ "model": "LED RED + NIR bulb", "type": "pbm-targeted", "peakWavelengths": [630, 670, 760, 810, 830, 850], - "mwPerCm2At15cm": 120, + "mwPerCm2At15cm": 100, "recommendedDistanceCm": 15, + "irradianceBasis": "measured-spectrometer", + "specSourceUrl": "https://www.mitochondriak.com/led-bulb-mitochondriak/", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "mitochondriak-bulb", - "notes": "Screw-in 12×12 cm bulb with holder. 56 W. Targeted PBM — small illuminated area." + "notes": "Screw-in 12×12 cm targeted bulb. The vendor reports about 100 mW/cm² by spectrometer at 15 cm." }, { "id": "mitochondriak-mini", "brand": "Mitochondriak", "model": "Mini", "type": "combined", - "peakWavelengths": [630, 670, 760, 810, 830, 850, 940], - "mwPerCm2At15cm": 136, + "peakWavelengths": [630, 670, 760, 810, 830, 850], + "mwPerCm2At15cm": null, "recommendedDistanceCm": 15, + "irradianceBasis": "unknown", + "specSourceUrl": "https://www.mitochondriak.com/infrapanel-mitochondriak-mini/", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "mitochondriak-mini", - "notes": "32×22 cm portable panel — quarter-body coverage. 210 W. Includes long-NIR 940 nm." + "notes": "Small battery-powered targeted panel. The current vendor page reports 31 mW/cm² by spectrometer but does not tie it to a measurement distance, so numerical fluence is withheld." }, { "id": "mitochondriak-office-upgraded", @@ -44,23 +61,36 @@ "model": "Office Upgraded", "type": "combined", "peakWavelengths": [630, 670, 760, 810, 830, 850, 940], - "mwPerCm2At15cm": 120, + "mwPerCm2At15cm": 85, "recommendedDistanceCm": 15, + "irradianceBasis": "measured-spectrometer", + "specSourceUrl": "https://www.mitochondriak.com/mitochondriak-office/", "channels": ["pbm_red", "pbm_nir"], + "channelGroups": [ + { "id": "red", "label": "Red", "peaks": [630, 670] }, + { "id": "nir", "label": "Near-infrared", "peaks": [760, 810, 830, 850, 940] } + ], + "modes": [ + { "id": "red-nir", "label": "Red + NIR", "groups": ["red", "nir"], "default": true }, + { "id": "red", "label": "Red only", "groups": ["red"] }, + { "id": "nir", "label": "NIR only", "groups": ["nir"] } + ], "catalogSlug": "mitochondriak-office-upgraded", - "notes": "Stationary quarter-body panel for desk/office use. 310 W. Manufacturer quotes >60 mW/cm² @ 30 cm; ~120 at 15 cm (near-field 2×)." + "notes": "Compact office panel. The vendor reports 85 mW/cm² by spectrometer at 15 cm, with independent red/NIR intensity and pulsation controls." }, { "id": "mitochondriak-uv-portable", "brand": "Mitochondriak", "model": "UV portable", "type": "uva", - "peakWavelengths": [360, 366, 630, 670, 760, 810, 830, 850], - "mwPerCm2At15cm": 24, - "recommendedDistanceCm": 15, - "channels": ["no_cv", "violet_eye", "pbm_red", "pbm_nir"], + "peakWavelengths": [336, 360, 630, 670, 760, 810, 850, 900], + "mwPerCm2At15cm": 10, + "recommendedDistanceCm": 30, + "irradianceBasis": "measured-total-spectrometer", + "specSourceUrl": "https://www.mitochondriak.com/mitochondriak-uv-portable/", + "channels": ["pomc", "no_cv", "pbm_red", "pbm_nir"], "catalogSlug": "mitochondriak-uv-portable", - "notes": "15×23 cm portable panel with UVA 360/366 nm on top of red+NIR. No UVB. 36 W. Manufacturer quotes >12 mW/cm² @ 30 cm; ~24 at 15 cm (near-field 2×)." + "notes": "Portable UVA + red/NIR panel. The vendor reports about 10 mW/cm² total spectrometer irradiance at 30 cm, but no per-band split; UVA dose is therefore not numerically inferred." }, { "id": "mitochondriak-maxi-upgraded", @@ -68,11 +98,22 @@ "model": "Maxi Upgraded", "type": "combined", "peakWavelengths": [630, 670, 760, 810, 830, 850, 940], - "mwPerCm2At15cm": 210, - "recommendedDistanceCm": 15, + "mwPerCm2At15cm": 60, + "recommendedDistanceCm": 30, + "irradianceBasis": "measured-spectrometer", + "specSourceUrl": "https://www.mitochondriak.com/mitochondriak-maxi/", "channels": ["pbm_red", "pbm_nir"], + "channelGroups": [ + { "id": "red", "label": "Red", "peaks": [630, 670] }, + { "id": "nir", "label": "Near-infrared", "peaks": [760, 810, 830, 850, 940] } + ], + "modes": [ + { "id": "red-nir", "label": "Red + NIR", "groups": ["red", "nir"], "default": true }, + { "id": "red", "label": "Red only", "groups": ["red"] }, + { "id": "nir", "label": "NIR only", "groups": ["nir"] } + ], "catalogSlug": "mitochondriak-maxi-upgraded", - "notes": "90×30 cm half-body panel. 900 W. Same form factor as the Maxi UVB but red+NIR only — no UV. Manufacturer quotes >105 mW/cm² @ 30 cm; ~210 at 15 cm (near-field 2×)." + "notes": "90×30 cm half-body red/NIR panel. The vendor reports more than 60 mW/cm² by spectrometer at 30 cm, with independent red/NIR intensity and pulsation controls." }, { "id": "mitochondriak-maxi-uvb", @@ -80,22 +121,32 @@ "model": "Maxi UVB", "type": "uvb", "peakWavelengths": [295, 380, 480, 630, 670, 760, 810, 830, 850], - "mwPerCm2At15cm": 120, - "recommendedDistanceCm": 15, - "channels": ["vitamin_d", "pomc", "no_cv", "violet_eye", "circadian", "pbm_red", "pbm_nir"], + "mwPerCm2At15cm": 57.5, + "recommendedDistanceCm": 30, + "irradianceByDistanceCm": [ + { "distanceCm": 30, "mwPerCm2": 57.5 }, + { "distanceCm": 70, "mwPerCm2": 34.61 } + ], + "peakShares": [0.004045, 0.009621, 0.012712, 0.221737, 0.221737, 0.132537, 0.132537, 0.132537, 0.132537], + "peakShareBasis": "measured-band-split", + "irradianceBasis": "measured-spectrometer", + "specSourceUrl": "https://www.mitochondriak.com/mitochondriak-maxi-uvb/", + "channels": ["vitamin_d", "pomc", "no_cv", "pbm_red", "pbm_nir"], "channelGroups": [ - { "id": "uv-blue", "label": "UV + blue", "peaks": [295, 380, 480] }, + { "id": "uvb", "label": "UVB 295 nm", "peaks": [295] }, + { "id": "uva-blue", "label": "UVA + blue", "peaks": [380, 480] }, { "id": "red-nir", "label": "Red + NIR", "peaks": [630, 670, 760, 810, 830, 850] } ], "modes": [ - { "id": "all-on", "label": "Full spectrum (UV + red/NIR)", "groups": ["uv-blue", "red-nir"], "default": true }, - { "id": "red-nir-only", "label": "Red/NIR only", "groups": ["red-nir"] } - ], - "coupling": [ - { "if": "uv-blue", "requires": ["red-nir"], "reason": "Vendor design: UV must always run with red/NIR for repair coverage. UV-only mode is intentionally unavailable." } + { "id": "red-nir-only", "label": "Red/NIR only", "groups": ["red-nir"], "default": true }, + { "id": "all-on", "label": "All bands (UV + blue + red/NIR)", "groups": ["uvb", "uva-blue", "red-nir"] }, + { "id": "uva-blue-red-nir", "label": "UVA + blue + red/NIR", "groups": ["uva-blue", "red-nir"] }, + { "id": "uvb-red-nir", "label": "UVB + red/NIR", "groups": ["uvb", "red-nir"] }, + { "id": "uva-blue-only", "label": "UVA + blue only", "groups": ["uva-blue"] }, + { "id": "uvb-only", "label": "UVB only (isolated)", "groups": ["uvb"] } ], "catalogSlug": "mitochondriak-maxi-uvb", - "notes": "Czech 90×30 cm broad-spectrum panel. UVB 295 nm + UVA 380 nm + blue 480 nm + red 630/670 nm + NIR 760/810/830/850 nm. Touchscreen modes: full-spectrum (UV + red/NIR) or red/NIR only — UV cannot run without red/NIR by vendor design. Half-body coverage." + "notes": "Half-body hybrid panel. Current vendor spectrometer data reports 57.5 mW/cm² total at 30 cm and a 70 cm band split of UVB 0.140, UVA 0.333, blue 0.440, red 15.350, and NIR 18.350 mW/cm². UV groups can be controlled independently; the vendor recommends pairing UV with red/NIR." }, { "id": "chroma-trinity", @@ -103,22 +154,28 @@ "model": "Trinity", "type": "uvb", "peakWavelengths": [297, 385, 405, 485, 630, 670, 760, 810, 850, 935, 1050], - "mwPerCm2At15cm": 200, - "recommendedDistanceCm": 20, - "channels": ["vitamin_d", "pomc", "no_cv", "violet_eye", "circadian", "pbm_red", "pbm_nir"], + "mwPerCm2At15cm": null, + "recommendedDistanceCm": 15, + "irradianceBasis": "unknown", + "specSourceUrl": "https://getchroma.co/products/trinity", + "channels": ["vitamin_d", "pomc", "no_cv", "pbm_red", "pbm_nir"], "channelGroups": [ { "id": "ironforge", "label": "Ironforge (red + NIR)", "peaks": [630, 670, 760, 810, 850] }, { "id": "lux-vital", "label": "Lux Vital (cyan + violet + UVA + ext NIR)", "peaks": [385, 405, 485, 935, 1050] }, { "id": "d-light", "label": "D-Light (UVB)", "peaks": [297] } ], "modes": [ - { "id": "all-on", "label": "All modes combined", "groups": ["ironforge", "lux-vital", "d-light"], "default": true }, - { "id": "ironforge", "label": "Ironforge only", "groups": ["ironforge"] }, - { "id": "lux-vital", "label": "Lux Vital only", "groups": ["lux-vital"] }, - { "id": "d-light", "label": "D-Light only (UVB)", "groups": ["d-light"] } + { "id": "ironforge", "label": "Ironforge (red/NIR)", "groups": ["ironforge"], "default": true }, + { "id": "ironforge-lux", "label": "Ironforge + Lux Vital", "groups": ["ironforge", "lux-vital"] }, + { "id": "ironforge-d-light", "label": "Ironforge + D-Light UVB", "groups": ["ironforge", "d-light"] }, + { "id": "all-on", "label": "All modes combined", "groups": ["ironforge", "lux-vital", "d-light"] } + ], + "coupling": [ + { "if": "lux-vital", "requires": ["ironforge"], "reason": "Vendor operation: Ironforge remains on when Lux Vital is enabled." }, + { "if": "d-light", "requires": ["ironforge"], "reason": "Vendor operation: Ironforge remains on when D-Light is enabled." } ], "catalogSlug": "chroma-trinity", - "notes": "Three modular modes that fire independently — Ironforge (red 630/670 + NIR 760/810/850), Lux Vital (cyan 485 + violet 405 + UVA 385 + extended NIR 935/1050), D-Light (narrowband UVB 297 nm). Pick the mode you actually ran for accurate dose accounting. Handheld, ~310 W combined. Vendor measures @ 8 inches (~20 cm)." + "notes": "Handheld three-group device. Ironforge is the always-on red/NIR base; Lux Vital and D-Light add UVA/visible/NIR or narrowband UVB. The vendor gives power allocation and usage distance, but no mode-specific spectral irradiance suitable for UV dosimetry, so numerical UV and vitamin-D estimates are withheld." }, { "id": "chroma-ironforge", @@ -126,11 +183,13 @@ "model": "Ironforge", "type": "combined", "peakWavelengths": [630, 670, 760, 810, 850], - "mwPerCm2At15cm": 250, + "mwPerCm2At15cm": 148, "recommendedDistanceCm": 15, + "irradianceBasis": "measured-radiometer", + "specSourceUrl": "https://getchroma.co/products/ironforge", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "chroma-ironforge", - "notes": "5-wavelength PBM handheld. 850/810/760/670/630 nm. 200 W. 148 mW/cm² @ 6 inches; ~250 at 15 cm. 850-nm-dominant (50% of output) for deep tissue." + "notes": "Five-wavelength PBM handheld. Chroma reports third-party verification of 148 mW/cm² at 6 inches (about 15 cm), with roughly three quarters of output allocated across the NIR bands." }, { "id": "chroma-ironforge-mini", @@ -138,11 +197,13 @@ "model": "Ironforge Mini", "type": "combined", "peakWavelengths": [630, 670, 760, 810, 850], - "mwPerCm2At15cm": 250, + "mwPerCm2At15cm": null, "recommendedDistanceCm": 15, + "irradianceBasis": "unknown", + "specSourceUrl": "https://getchroma.co/products/ironforge-mini", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "chroma-ironforge-mini", - "notes": "Yoga-block-sized handheld. Same 5-wavelength split as Ironforge (850-dominant). 200 W. 148 mW/cm² @ 6 inches; ~250 at 15 cm." + "notes": "Yoga-block-sized handheld with the Ironforge wavelength set. The current product page does not provide a reference-distance irradiance suitable for fluence calculation." }, { "id": "chroma-radiance", @@ -150,11 +211,13 @@ "model": "Radiance", "type": "combined", "peakWavelengths": [405, 630, 660, 810], - "mwPerCm2At15cm": 80, + "mwPerCm2At15cm": null, "recommendedDistanceCm": 15, - "channels": ["violet_eye", "pbm_red", "pbm_nir"], + "irradianceBasis": "unknown", + "specSourceUrl": "https://getchroma.co/products/radiance", + "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "chroma-radiance", - "notes": "Compact 4-band handheld with violet 405 nm on top of red + near-IR. 50 W, 28 oz. Violet adds eye/skin OPN5 effects beyond pure PBM." + "notes": "Compact handheld with 405 nm violet plus red and NIR. The violet band is kept as skin-spectrum context; this app does not credit an eye-channel benefit from looking at a therapy device." }, { "id": "chroma-red-portal", @@ -162,8 +225,9 @@ "model": "Red Portal", "type": "combined", "peakWavelengths": [670, 810], - "mwPerCm2At15cm": 50, + "mwPerCm2At15cm": null, "recommendedDistanceCm": 50, + "irradianceBasis": "unknown", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "chroma-red-portal", "notes": "Desk / wall ambient PBM lamp. 670 + 810 nm. 40 W. Workspace-grade — designed for ~50 cm desk distance, all-day exposure model." @@ -174,11 +238,15 @@ "model": "Sky Portal 2.1", "type": "sad", "peakWavelengths": [415, 480, 660], - "lux": 10000, + "lux": null, + "melanopicEdiLux": 10000, + "melanopicBasis": "vendor-claim", "recommendedDistanceCm": 25, + "irradianceBasis": "vendor-claim", + "specSourceUrl": "https://getchroma.co/products/sky-portal", "channels": ["circadian", "violet_eye"], "catalogSlug": "chroma-sky-portal", - "notes": "Circadian desk lamp. Day mode: violet 415 + cyan 480 + balanced NIR (>10,000 melanopic lux @ 10 inches ≈ 25 cm). Night mode: deep red 660 + 1300 K warm glow. Eye-channel device — no PBM dose." + "notes": "Circadian desk lamp. The vendor states more than 10,000 melanopic lux in daytime white mode at 10 inches (about 25 cm); its public page does not provide the underlying CIE measurement file. Night mode is a low-melanopic amber/red setting and is not modeled by this preset." }, { "id": "chroma-chromatorch", @@ -186,11 +254,12 @@ "model": "Chromatorch", "type": "pbm-targeted", "peakWavelengths": [670, 810, 850], - "mwPerCm2At15cm": 40, + "mwPerCm2At15cm": null, "recommendedDistanceCm": 15, + "irradianceBasis": "unknown", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "chroma-chromatorch", - "notes": "Pocket torch (226 g). Three-band split (~33% each at 670 / 810 / 850). 85 mW/cm² at faceplate; ~40 at 15 cm working distance. 800+ hr battery." + "notes": "Pocket torch (226 g) with 670/810/850 nm output. The vendor states about 85 mW/cm² at the faceplate but no working-distance measurement, so numerical fluence is withheld." }, { "id": "emr-tek-inferno", @@ -198,11 +267,17 @@ "model": "Inferno", "type": "combined", "peakWavelengths": [630, 670, 830], - "mwPerCm2At15cm": 150, + "mwPerCm2At15cm": 200, "recommendedDistanceCm": 15, + "irradianceByDistanceCm": [ + { "distanceCm": 15, "mwPerCm2": 200 }, + { "distanceCm": 30, "mwPerCm2": 70 } + ], + "irradianceBasis": "measured-radiometer", + "specSourceUrl": "https://emr-tek.com/products/inferno", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "emr-tek-inferno", - "notes": "Full-body red + near-IR therapy panel. 1400 W from 132 LEDs + 5 COB modules. >200 mW/cm² @ 6 inches; ~150 at 15 cm." + "notes": "Full-body red + near-IR therapy panel. 1400 W from 132 LEDs + 5 COB modules. Vendor states more than 200 mW/cm² at 6 inches (about 15 cm)." }, { "id": "emr-tek-firedragon", @@ -210,11 +285,12 @@ "model": "Firedragon", "type": "combined", "peakWavelengths": [630, 660, 670, 810, 830, 850], - "mwPerCm2At15cm": 120, + "mwPerCm2At15cm": null, "recommendedDistanceCm": 15, + "irradianceBasis": "unknown", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "emr-tek-firedragon", - "notes": "Desktop / mid-size 6-wavelength panel. 504 W. COB architecture, adjustable stand, LCD timer. Irradiance not published; ~120 at 15 cm based on wattage / form factor." + "notes": "Desktop / mid-size multi-wavelength panel. A usable reference-distance irradiance is not stored, so numerical fluence is withheld." }, { "id": "emr-tek-firewave-compact", @@ -222,11 +298,12 @@ "model": "Firewave Compact", "type": "combined", "peakWavelengths": [630, 670, 830], - "mwPerCm2At15cm": 250, + "mwPerCm2At15cm": null, "recommendedDistanceCm": 50, + "irradianceBasis": "unknown", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "emr-tek-firewave-compact", - "notes": "Desktop COB device. 200 W focused chip-on-board. Manufacturer recommends 20 inches (~50 cm) working distance — at that range output matches a standard panel at 6 inches. Closer use risks over-dosing; default keyed to vendor's recommended distance." + "notes": "Desktop COB device with a 20-inch (about 50 cm) stored working distance. A usable irradiance measurement at that distance is not stored, so numerical fluence is withheld." }, { "id": "emr-tek-firewave-pro", @@ -234,11 +311,12 @@ "model": "Firewave Pro", "type": "combined", "peakWavelengths": [630, 670, 830], - "mwPerCm2At15cm": 200, + "mwPerCm2At15cm": null, "recommendedDistanceCm": 15, + "irradianceBasis": "unknown", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "emr-tek-firewave-pro", - "notes": "Desktop 350 W panel. 3 wavelengths. Irradiance not published; ~200 at 15 cm extrapolated from power." + "notes": "Desktop three-wavelength panel. Irradiance was not verified at the stored reference distance, so numerical fluence is withheld." }, { "id": "emr-tek-firefly", @@ -246,11 +324,12 @@ "model": "Firefly Portable", "type": "pbm-targeted", "peakWavelengths": [630, 670, 830], - "mwPerCm2At15cm": 50, + "mwPerCm2At15cm": null, "recommendedDistanceCm": 15, + "irradianceBasis": "unknown", "channels": ["pbm_red", "pbm_nir"], "catalogSlug": "emr-tek-firefly", - "notes": "USB-C battery-powered handheld. 1.5 hr runtime per charge. 'Panel-grade output' (exact wattage not disclosed) — handheld dose profile, treat as targeted PBM." + "notes": "USB-C battery-powered handheld. A usable reference-distance irradiance is not published, so numerical fluence is withheld." } ], "_types": { diff --git a/data/sun-action-spectra.json b/data/sun-action-spectra.json index d4215411..8879b495 100644 --- a/data/sun-action-spectra.json +++ b/data/sun-action-spectra.json @@ -23,7 +23,7 @@ "label": "POMC / melanocortin", "spectrum": "UVB+UVA 280-400nm", "ref": "cie_erythemal", - "notes": "OPN5 → POMC → α-MSH/ACTH/β-endorphin pathway. Erythemal action spectrum used as proxy." + "notes": "UV-responsive POMC and melanocortin signaling. The erythemal action spectrum is only a coarse exposure proxy; downstream response is not measured." }, "no_cv": { "id": 3, @@ -37,21 +37,21 @@ "label": "Violet / outdoor-eye signal", "spectrum": "360-400nm", "ref": "opn5_violet", - "notes": "OPN5 in skin (melanogenesis kickoff) + retinal OPN5 (axial-length regulation, Tsubota/Torii). Broader than myopia framing." + "notes": "Exploratory violet-light/OPN5 hypothesis. Human evidence is stronger for time outdoors than for a wavelength-specific eye dose." }, "circadian": { "id": 5, "label": "Circadian (melanopic)", "spectrum": "460-490nm + broadband CCT", "ref": "cie_melanopic", - "notes": "Melanopsin ipRGC → SCN. CIE S 026:2018 standard. Effective even at moderate intensities (40 lux + 80min → measurable CAR boost)." + "notes": "Melanopsin/ipRGC-related input represented with CIE S 026:2018 sensitivity. This is a modeled signal, not calibrated α-opic metrology or a daily target." }, "nir_solar": { "id": 6, "label": "Mitochondrial (NIR-solar broadband)", "spectrum": "600-1400nm", "ref": "cco_pbm", - "notes": "Wunsch optical tissue window. Solar IR-A penetrates head-to-knee (Jeffery+Fosbury 2025). Indoor windows with Low-E glass kill this channel." + "notes": "Broad red/near-infrared input motivated by published photobiology research. Tissue penetration and window transmission vary; no biological completion target is inferred." }, "pbm_red": { "id": 7, @@ -71,18 +71,18 @@ "deficit_axes": { "d1_sleep_darkness": { "label": "Sleep-time darkness", - "target": "<0.3 lux at eye during sleep window", - "rationale": "Reiter 2020 — melatonin amplitude attenuated even at sub-lux ALAN exposure. Wunsch — full darkness prerequisites for full nocturnal endocrine cascade." + "target": "Context only: record qualitative darkness or a calibrated meter reading", + "rationale": "Night light can affect sleep and circadian signaling, but a camera estimate is not a hormone measurement and no universal app threshold is inferred." }, "d2_daylight_deficit": { "label": "Daytime indoor-light deficit", - "target": ">10000 lux for ≥1h within solar day", - "rationale": "Ott 1973 — malillumination; Hattar/Huberman — minimum effective AM dose for SCN entrainment." + "target": "Context only: record daylight access, timing, and measurement basis", + "rationale": "Ordinary lux is not melanopic EDI, and the app does not turn a spot reading into a universal daylight quota." }, "d3_junk_light_contamination": { - "label": "Junk-light contamination", - "target": "minimize LED-only/flicker/blue-after-sunset hours", - "rationale": "Wunsch — spectrum-truncation stress; IEEE PAR1789 — flicker risk thresholds; Reiter — blue-suppression of pre-sleep melatonin." + "label": "Spectrum and flicker context", + "target": "Context only: note bright, close, flickering, or late-evening sources", + "rationale": "Spectrum, brightness, flicker, timing, and distance are separate inputs. A source label alone does not establish harm or a biological dose." } }, "_action_spectra_full": "Full per-nm action spectra arrays embedded into js/sun-spectrum.js to keep this JSON small. The references above are the source-of-truth citations; the numeric tables are versioned alongside the model." diff --git a/js/app-light-sun-modules.js b/js/app-light-sun-modules.js index 22499d53..d08c6490 100644 --- a/js/app-light-sun-modules.js +++ b/js/app-light-sun-modules.js @@ -38,15 +38,14 @@ import { _expandLightToolsSection, renderDashboardLightChannelPills, renderLightChannelsLive, + renderLightLiveSession, renderLightSessionLogActions, renderLightTodayStrip, showLight, } from './light-page-view.js'; import { - _clearManualUvi, _inspectConditionsNow, _refreshConditionsNow, - _setManualUvi, renderConditionsNow, renderLightConditionsWidgetBody, } from './light-conditions-now.js'; @@ -59,12 +58,10 @@ import { configureSunRuntimeDeps } from './sun-runtime.js'; export { _expandLightToolsSection, - _clearManualUvi, _inspectConditionsNow, _openAllSessionsModal, _openChannelOnLightPage, _refreshConditionsNow, - _setManualUvi, _toggleChannelDetail, configureLightEnv, ensureActiveDeviceTicker, @@ -73,6 +70,7 @@ export { renderDashboardLightChannelPills, renderLightChannelsLive, renderLightConditionsWidgetBody, + renderLightLiveSession, renderLightSessionLogActions, renderLightTodayHero, renderLightTodayStrip, diff --git a/js/changelog-impl.js b/js/changelog-impl.js index 6cfa12e8..81492180 100644 --- a/js/changelog-impl.js +++ b/js/changelog-impl.js @@ -10,6 +10,19 @@ const CHANGELOG_ACTION_ATTR = 'data-changelog-action'; const changelogDelegateRoots = new WeakSet(); const CHANGELOG = [ + { + version: '1.16.0', date: '2026-08-14', title: 'Light & Sun, completely redesigned', + items: [ + 'See your light day in one place. Outdoor conditions, sun sessions, light devices, indoor environments, measurement tools, and guidance now work together as one clear experience.', + 'Understand what light may stimulate. Light channels explain vitamin D, body-clock timing, blood-vessel responses, skin and mood signaling, cellular energy and repair, and outdoor eye light—in plain language and without turning them into daily quotas.', + 'Get more useful local sun context. Use your home area or optionally share a privacy-rounded current location for today\'s conditions, UV, sun position, and the transition from sunrise into meaningful UVA availability.', + 'Follow sun sessions more easily. Set up skin type, coverage, glass, and eye conditions before starting. The live session card keeps the vitamin D estimate visible, shows the other light channels on demand, and works consistently on the Light page and dashboard.', + 'Review patterns without chasing numbers. Light Today explains the current day, while the Weekly Review looks across recent sessions and suggests one practical next step.', + 'Keep sunlight and light devices distinct. Device sessions reflect their specific spectrum, output, distance, and exposure area. Numerical estimates appear only when the available device information supports them.', + 'Understand your indoor light environment. Assess rooms, save observations, and use tools for brightness, light color, flicker, spectrum, darkness, and window transmission. Lux measurement prefers the phone\'s light sensor, with camera and manual options when needed.', + 'Safety and uncertainty stay visible. Sun and device guidance considers burn risk, eye exposure, heat, photosensitivity, glass, and measurement limits while keeping the feature educational and practical.', + ] + }, { version: '1.15.2', date: '2026-08-13', title: 'Voice follows your AI provider', items: [ @@ -493,7 +506,7 @@ const CHANGELOG = [ { version: '1.6.0', date: '2026-05-04', title: '☀ Light & Sun — the lens for everything sunlight does to you', items: [ - '☀ Light & Sun lens. Sunlight does a lot more than make vitamin D. Track your exposure across six biological channels — Vitamin D, Body clock, Cardiovascular, Mood & hormones, Cellular repair, and Outdoor eye light — and correlate them with your labs and wearable data over time. One-tap session logging: tap when you go outside, tap again when you come back. Plain-English summary on stop with computed vit-D yield and burn-dose status.', + '☀ Light & Sun lens. Sunlight does a lot more than make vitamin D. Track your exposure across six biological channels — Vitamin D, Body clock, Cardiovascular, Mood & hormones, Cell energy & repair, and Outdoor eye light — and correlate them with your labs and wearable data over time. One-tap session logging: tap when you go outside, tap again when you come back. Plain-English summary on stop with computed vit-D yield and burn-dose status.', 'Sun-safety guardrails. Live alert at 70% + 100% of your daily burn dose. A photosensitizing-medication checkbox drops your threshold (tetracyclines, isotretinoin, NSAIDs, St John\'s Wort, others). Cumulative carry-over warning when yesterday + today push you over. High-altitude flag for locations above 1500m.', 'Light therapy devices, first-class. Pick from a preset library (Joovv panels, Mito Red, Sperti UVB, Verilux dawn simulators, full-spectrum bulbs) or add a custom device. Therapy sessions feed the same channels as outdoor sun.', 'Indoor light + screens. Map the rooms you spend time in and the screens you stare at. Each audit question carries a one-line photobiology explainer below it. Indoor light deficits feed back into your channel mix.', diff --git a/js/chat-onboarding.js b/js/chat-onboarding.js index 53114de1..60526bff 100644 --- a/js/chat-onboarding.js +++ b/js/chat-onboarding.js @@ -10,7 +10,7 @@ import { deleteImportedArrayItem, } from './data-merge.js'; import { - detectLatitudeWithAI, getLatitudeFromLocation, getLocationCache, + getLatitudeFromLocation, getLocationCache, latitudeToBand, renameProfile, setProfileDob, setProfileLocation, setProfileSex, } from './profile.js'; @@ -316,10 +316,12 @@ export async function saveChatLocation() { if (!el) return true; if (!country) { el.textContent = ''; return true; } - // Check AI cache first + // Check the legacy/coarse cache first. Country-only onboarding does not + // send anything to a geocoder; postal refinement happens in Profile. const cacheKey = (country + '|').toLowerCase(); - const cached = getLocationCache()[cacheKey]; - if (cached !== undefined) { + const rawCached = getLocationCache()[cacheKey]; + const cached = Number.isFinite(rawCached) ? Number(rawCached) : Number(rawCached?.lat ?? rawCached?.latitude); + if (Number.isFinite(cached)) { const band = latitudeToBand(cached); el.style.color = 'var(--green)'; el.textContent = '\u2713 ' + Math.abs(Math.round(cached)) + '\u00b0' + (cached >= 0 ? 'N' : 'S') + ' \u2014 ' + LATITUDE_BANDS[band]; @@ -330,27 +332,12 @@ export async function saveChatLocation() { if (latStr) { el.style.color = 'var(--green)'; el.textContent = '\u2713 ' + latStr; - } else if (hasAIProvider()) { - el.style.color = 'var(--text-muted)'; - el.textContent = 'Detecting\u2026'; } else { el.textContent = ''; } - // Debounced AI refinement + // No country-only network lookup: a country band is sufficient here, + // and Profile can resolve an optional postal area later. if (_chatLocTimer) clearTimeout(_chatLocTimer); - if (hasAIProvider()) { - _chatLocTimer = setTimeout(async () => { - await detectLatitudeWithAI(country, ''); - // Re-read cache after AI detection - const lat = getLocationCache()[(country + '|').toLowerCase()]; - const latEl = document.getElementById('chat-onboard-lat'); - if (lat !== undefined && latEl) { - const band = latitudeToBand(lat); - latEl.style.color = 'var(--green)'; - latEl.textContent = '\u2713 ' + Math.abs(Math.round(lat)) + '\u00b0' + (lat >= 0 ? 'N' : 'S') + ' \u2014 ' + LATITUDE_BANDS[band]; - } - }, 1500); - } return true; } diff --git a/js/client-list-form.js b/js/client-list-form.js index 009d3784..65f64abd 100644 --- a/js/client-list-form.js +++ b/js/client-list-form.js @@ -18,7 +18,6 @@ import { LATITUDE_BANDS } from './constants.js'; import { getAvatarColor } from './nav.js'; import { getClientHaplogroupList, - hasClientListAIProvider, navigateClientListRoute, refreshClientProfileButton, setClientManualHaplogroup, @@ -218,7 +217,7 @@ export function openClientForm(profileId) {
Region
- +
@@ -250,6 +249,7 @@ export function openClientForm(profileId) {
+
Postal codes are optional. When entered, the getbased proxy asks OpenStreetMap for the postal area and returns only privacy-rounded coordinates (~11 km); current phone location remains a separate opt-in. © OpenStreetMap contributors.
@@ -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 = `
`; for (const k of order) { const meta = ch[k] || {}; - const v7 = totals7d[k] || 0; - const v30 = totals30d[k] || 0; - const t7 = tier7(v7, k); - const t30 = tier30(v30, k); - const trendDir = t7 > t30 ? 'up' : t7 < t30 ? 'down' : 'flat'; + const sun = sunTotals7d[k] || 0; + const device = deviceTotals7d[k] || 0; + const active = _hasSignal(sun) || _hasSignal(device); + const sourceLabel = _sourceSignalLabel(sun, device); const dc = _channelDayCount(k); - const tip = `${meta.what || ''} — ${dc.n} of 7 days hit target this week.`; + const sourceDays = [dc.sun ? `sunlight ${dc.sun}d` : '', dc.device ? `device ${dc.device}d` : ''].filter(Boolean).join(' · '); + const tip = `${meta.what || ''} — ${sourceLabel}${sourceDays ? ` (${sourceDays})` : ''}.`; const detailId = `light-pill-detail-${k}`; - html += ``; } 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 = ``; return `
- Action spectrum & citations + Research & sources

Spectrum: ${escapeHTML(cit.spectrum)}

    ${refs}
${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
${escapeHTML(srRows)} - ${targetLine} ${bars}
`; } -// 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 `
-
-
-
-
-
- Sun ${sunPct}% - Device ${devPct}% -
+function _renderChannelSources(sun, device) { + const hasSun = _hasSignal(sun); + const hasDevice = _hasSignal(device); + if (!hasSun && !hasDevice) 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 ? `` : ''} + ${showDev ? `` : ''}`; 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)}

${escapeHTML(meta.what || '')}

- ${_renderChannelSourceMix(sun7, dev7)} + ${_renderChannelSources(sun7, dev7)} ${_renderChannelWeekChart(channelKey)} - ${_renderDailyBeatsBankingNote(channelKey)} + ${_renderHowToReadNote(channelKey)} - ${_channelNextMove(channelKey, t7, devices, atm)} + ${_channelNextMove(channelKey, _hasSignal(sun7), _hasSignal(dev7), devices, atm)} ${_renderChannelCitations(channelKey)}
`; @@ -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 `
${escapeHTML(SUGGESTIONS[worstKey] || '')}
`; + + return `
+
Past 7 days
+

${escapeHTML(headline)}

+

${escapeHTML(comparison)}

+

${escapeHTML(nextStep)}

+ ${currentTotal === 0 ? '' : ''} +
`; } 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 `
-
-
- - ${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…
`; } if (status === 'ok' && !stale) { - const dot = a.dot; - return `
-
-
- - ${escapeHTML(a.tip || '')} - -
- ${a.detail ? `
${escapeHTML(a.detail)}
` : ''} -
-
`; + const action = ``; + return _renderWeeklyAIReview(a, action); } if (status === 'error') { const msg = a?.errorMessage ? `Analysis failed — ${a.errorMessage}` : 'Analysis failed.'; return `
${staticFallback || ''} - +
+ ${escapeHTML(msg)} + +
`; } - // 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 `
${staticFallback || ''} - +
+ + ${escapeHTML(helper)} +
`; } diff --git a/js/light-conditions-interpretation.js b/js/light-conditions-interpretation.js index 8c53d324..bc2be5f7 100644 --- a/js/light-conditions-interpretation.js +++ b/js/light-conditions-interpretation.js @@ -24,84 +24,17 @@ export function _solarZenithAngle(date, coords) { } } -// What the UVI means for vit-D synthesis (Holick threshold). -export function _vitDLabel(uvi) { - if (uvi == null || uvi < 1) return 'no vit-D synthesis'; - if (uvi < 3) return 'vit-D synthesis weak'; - if (uvi < 6) return 'vit-D synthesis moderate'; - if (uvi < 9) return 'vit-D synthesis strong'; - return 'vit-D synthesis ample (burn risk dominates)'; -} - -// "Time to MED" for the user — accounts for the real UVI curve from now -// until sunset, not a naive constant-UVI extrapolation. At 6pm with UVI -// 1.7 falling toward 0, naive math says "burn in 14 hours" which is -// nonsense — the sun sets first. -// -// Integrates the user's accumulated erythemal dose hour-by-hour using -// Open-Meteo's hourly forecast. Returns one of: -// { kind: 'no-uv' } — UV near zero, no risk to compute -// { kind: 'safe-til-sunset' } — won't burn before sun is down -// { kind: 'minutes', value: N } — N minutes from now to MED -export function _timeToMed(uvi, fitzpatrick, atm) { - if (uvi == null || uvi < 0.5) return { kind: 'no-uv' }; - // Standard MED in J/m² by Fitzpatrick type. UVI 1 ≈ 25 mW/m² erythemal. - const medJoules = { I: 200, II: 250, III: 300, IV: 450, V: 600, VI: 1000 }; - const joulesToMed = medJoules[fitzpatrick] || medJoules.III; - const bodyFraction = 0.20; // face + arms + hands + neck default - const ratePerUvi = 25 * bodyFraction; // mW/m² of erythemal per UVI unit - - // Try the integrated path first — uses Open-Meteo's hourly UVI forecast - // for today, accumulating dose from now until sunset. - const hourly = atm?.hourly; - const sunset = atm?.daily?.sunset; - if (Array.isArray(hourly?.time) && Array.isArray(hourly?.uv_index) && sunset) { - const sunsetMs = new Date(sunset).getTime(); - const now = Date.now(); - if (sunsetMs <= now) return { kind: 'no-uv' }; // already past sunset - let cumulativeJ = 0; - let lastT = now; - for (let index = 0; index < hourly.time.length; index++) { - const segmentStartTime = new Date(hourly.time[index]).getTime(); - const segmentEndTime = index + 1 < hourly.time.length - ? new Date(hourly.time[index + 1]).getTime() - : segmentStartTime + 3600000; - // Skip hours fully before now - if (segmentEndTime <= now) continue; - // Stop at sunset - if (segmentStartTime >= sunsetMs) break; - const segmentStart = Math.max(segmentStartTime, lastT, now); - const segmentEnd = Math.min(segmentEndTime, sunsetMs); - if (segmentEnd <= segmentStart) continue; - const segmentMinutes = (segmentEnd - segmentStart) / 60000; - const hourlyUvi = hourly.uv_index[index] || 0; - const erythemalRate = hourlyUvi * ratePerUvi; // mW/m² - const joulesPerMinute = erythemalRate * 60 / 1000; - const segmentJoules = joulesPerMinute * segmentMinutes; - if (cumulativeJ + segmentJoules >= joulesToMed) { - const remainingJoules = joulesToMed - cumulativeJ; - const minutesIntoSegment = joulesPerMinute > 0 ? remainingJoules / joulesPerMinute : 0; - const minutesFromNow = Math.round((segmentStart - now) / 60000 + minutesIntoSegment); - return { kind: 'minutes', value: Math.max(0, minutesFromNow) }; - } - cumulativeJ += segmentJoules; - lastT = segmentEnd; - } - return { kind: 'safe-til-sunset' }; - } - - // Fallback — no hourly forecast available (e.g. CAMS / NOAA / offline). - // Use constant-UVI extrapolation, but clamp at "won't burn today" if the - // result exceeds time until sunset (when known). - const erythemalRate = uvi * ratePerUvi; - if (erythemalRate <= 0) return { kind: 'no-uv' }; - const joulesPerMinute = erythemalRate * 60 / 1000; - const naiveMinutes = Math.round(joulesToMed / joulesPerMinute); - if (sunset) { - const minutesToSunset = Math.max(0, (new Date(sunset).getTime() - Date.now()) / 60000); - if (naiveMinutes > minutesToSunset) return { kind: 'safe-til-sunset' }; - } - return { kind: 'minutes', value: naiveMinutes }; +// Current-condition interpretation follows the WHO UVI protection bands. +// UVI is erythema-weighted, so this widget deliberately does not infer +// vitamin-D synthesis or a personal burn time from the scalar value. +export function _uviConditionLabel(uvi) { + if (!Number.isFinite(uvi)) return ''; + if (uvi < 1) return 'Very low UV'; + if (uvi < 3) return 'Low UV'; + if (uvi < 6) return 'Moderate UV · protection recommended'; + if (uvi < 8) return 'High UV · protection needed'; + if (uvi < 11) return 'Very high UV · extra protection'; + return 'Extreme UV · avoid unprotected exposure'; } // Format minutes as "Xh Ym" / "Xm" / "<1m" @@ -127,44 +60,46 @@ export function _sunPositionLabel(elevationDegrees) { return 'Overhead'; } -// Sun-position sub — supporting context with shadow ratio + UV strength. +// Sun-position sub — geometry-only context. Actual UVI also depends on +// clouds, ozone, aerosols, altitude, and reflection. export function _sunPositionSub(elevationDegrees) { - if (elevationDegrees == null || elevationDegrees < 0) return 'no UV'; - if (elevationDegrees < 5) return 'UV negligible'; + if (elevationDegrees == null || elevationDegrees < 0) return 'solar UV potential absent'; + if (elevationDegrees < 5) return 'solar UV potential minimal'; const ratio = 1 / Math.tan(elevationDegrees * Math.PI / 180); const roundedRatio = ratio.toFixed(1); - if (elevationDegrees >= 70) return `UV peak · shadow ${roundedRatio}× height`; - if (elevationDegrees >= 50) return `UV strong · shadow ${roundedRatio}× height`; - if (elevationDegrees >= 30) return `UV building · shadow ${roundedRatio}× height`; - if (elevationDegrees >= 15) return `UV moderate · shadow ${roundedRatio}× height`; - return `UV weak · shadow ${roundedRatio}× height`; + if (elevationDegrees >= 70) return `UV potential peak · shadow ${roundedRatio}× height`; + if (elevationDegrees >= 50) return `UV potential high · shadow ${roundedRatio}× height`; + if (elevationDegrees >= 30) return `UV potential building · shadow ${roundedRatio}× height`; + if (elevationDegrees >= 15) return `UV potential moderate · shadow ${roundedRatio}× height`; + return `UV potential low · shadow ${roundedRatio}× height`; } -// Compute the time of day when UV-A first reaches the ground (and when -// it stops). UV-A 320-400 nm requires sun elevation ~5° above the horizon -// — below that, atmospheric path is too long for meaningful 320-400 nm to -// penetrate. This is "biological dawn" / "biological dusk" — the moments -// when the eye + skin actually start receiving the violet/UV-A signals -// that drive circadian entrainment, α-MSH / β-endorphin release, and -// retinal dopamine. Much more biologically meaningful than civil sunrise. +// Compute crossings of a fixed 5° solar-elevation transition. This is an +// operational marker for when surface UV-A becomes a more substantial +// photobiological input, not a claim that UV-A is literally absent outside +// the window or that downstream biology flips as one whole-body switch. // // Returns { firstUVA: , lastUVA: } for the day, or nulls if // the sun never rises high enough (polar winter) or coords unavailable. // -// Threshold: 5° elevation. Reference: Hattar / Lambert eye-skin axis -// literature; OZONE-corrected UV-A penetration models (Madronich 1998). -export function _computeUvaWindow(coords, dateLike) { +export function _computeUvaWindow(coords, dateLike, offsetSeconds = 0) { if (!coords || typeof interpretationDeps.solarZenithAngle !== 'function') { return { firstUVA: null, lastUVA: null }; } const baseDate = dateLike ? new Date(dateLike) : new Date(); - const day = new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate()); + const offsetMs = (Number(offsetSeconds) || 0) * 1000; + const localDate = new Date(baseDate.getTime() + offsetMs); + const dayStartMs = Date.UTC( + localDate.getUTCFullYear(), + localDate.getUTCMonth(), + localDate.getUTCDate() + ) - offsetMs; const SAMPLE_STEP_MIN = 1; const ELEVATION_THRESHOLD_DEG = 5; let firstUVA = null; let lastUVA = null; for (let minute = 0; minute < 24 * 60; minute += SAMPLE_STEP_MIN) { - const time = new Date(day.getTime() + minute * 60_000); + const time = new Date(dayStartMs + minute * 60_000); const zenith = _solarZenithAngle(time, coords); if (zenith == null) continue; const elevation = 90 - zenith; @@ -178,8 +113,6 @@ export function _computeUvaWindow(coords, dateLike) { export const SHADOW_RULE_HINT = 'Shadow rule: when your shadow is shorter than you, UV is high (strong sunburn risk). When shadow is longer than you, UV is weak. Used by dermatology orgs as a no-meter outdoor heuristic.'; -export const TANNING_MODIFIERS_NOTE = 'Estimate based on Fitzpatrick skin type alone. Actual burn time also depends on genetics (e.g. MC1R variants), diet (omega-3 / antioxidants), recent sun history (tan), circadian state, sleep, and hydration. Use as a starting point, not gospel.'; - // Friendly cloud-cover narrative — "Overcast" / "Partly cloudy" / "Clear sky". export function _cloudNarrative(percent) { if (percent == null) return null; @@ -190,46 +123,33 @@ export function _cloudNarrative(percent) { return 'Overcast'; } -// Aggregate AQ from multiple pollutants — return the worst-of category so -// a user with high NO2 but low PM2.5 isn't told "Good" (false reassurance). +export function _europeanAQCategory(index) { + if (!Number.isFinite(index)) return null; + if (index <= 20) return { cls: 'good', label: 'Good', score: 0 }; + if (index <= 40) return { cls: 'good', label: 'Fair', score: 1 }; + if (index <= 60) return { cls: 'moderate', label: 'Moderate', score: 2 }; + if (index <= 80) return { cls: 'unhealthy-sensitive', label: 'Poor', score: 3 }; + if (index <= 100) return { cls: 'unhealthy', label: 'Very poor', score: 4 }; + return { cls: 'hazardous', label: 'Extremely poor', score: 5 }; +} + +// Trust the provider's consolidated European AQI and component indices. +// Raw instantaneous concentrations are not reclassified locally because +// the official pollutant bands use specific rolling-average windows. export function _aggregateAQ(airQuality, fallbackEaqi) { - const categories = []; - if (Number.isFinite(fallbackEaqi)) { - if (fallbackEaqi <= 20) categories.push({ cls: 'good', label: 'Good', score: 0, why: 'EAQI' }); - else if (fallbackEaqi <= 40) categories.push({ cls: 'good', label: 'Fair', score: 1, why: 'EAQI' }); - else if (fallbackEaqi <= 60) categories.push({ cls: 'moderate', label: 'Moderate', score: 2, why: 'EAQI' }); - else if (fallbackEaqi <= 80) categories.push({ cls: 'unhealthy-sensitive', label: 'Poor', score: 3, why: 'EAQI' }); - else if (fallbackEaqi <= 100) categories.push({ cls: 'unhealthy', label: 'Very poor', score: 4, why: 'EAQI' }); - else categories.push({ cls: 'hazardous', label: 'Extremely poor', score: 5, why: 'EAQI' }); - } - if (airQuality) { - const pm25 = airQuality.pm25; - const pm10 = airQuality.pm10; - const no2 = airQuality.no2; - if (Number.isFinite(pm25)) { - if (pm25 < 12) categories.push({ cls: 'good', label: 'Good', score: 0, why: 'PM2.5' }); - else if (pm25 < 35) categories.push({ cls: 'moderate', label: 'Moderate', score: 2, why: 'PM2.5' }); - else if (pm25 < 55) categories.push({ cls: 'unhealthy-sensitive', label: 'Unhealthy for sensitive', score: 3, why: 'PM2.5' }); - else if (pm25 < 150) categories.push({ cls: 'unhealthy', label: 'Unhealthy', score: 4, why: 'PM2.5' }); - else categories.push({ cls: 'hazardous', label: 'Hazardous', score: 5, why: 'PM2.5' }); - } - if (Number.isFinite(pm10)) { - if (pm10 < 54) categories.push({ cls: 'good', label: 'Good', score: 0, why: 'PM10' }); - else if (pm10 < 154) categories.push({ cls: 'moderate', label: 'Moderate', score: 2, why: 'PM10' }); - else if (pm10 < 254) categories.push({ cls: 'unhealthy-sensitive', label: 'Unhealthy for sensitive', score: 3, why: 'PM10' }); - else categories.push({ cls: 'unhealthy', label: 'Unhealthy', score: 4, why: 'PM10' }); - } - if (Number.isFinite(no2)) { - if (no2 < 40) categories.push({ cls: 'good', label: 'Good', score: 0, why: 'NO₂' }); - else if (no2 < 90) categories.push({ cls: 'moderate', label: 'Moderate', score: 2, why: 'NO₂' }); - else if (no2 < 120) categories.push({ cls: 'unhealthy-sensitive', label: 'Unhealthy for sensitive', score: 3, why: 'NO₂' }); - else if (no2 < 230) categories.push({ cls: 'unhealthy', label: 'Unhealthy', score: 4, why: 'NO₂' }); - else categories.push({ cls: 'hazardous', label: 'Hazardous', score: 5, why: 'NO₂' }); - } - } - if (categories.length === 0) return null; - categories.sort((a, b) => b.score - a.score); - return categories[0]; + const index = Number.isFinite(airQuality?.european_aqi) + ? airQuality.european_aqi : fallbackEaqi; + const category = _europeanAQCategory(index); + if (!category) return null; + const components = [ + ['PM2.5', airQuality?.european_aqi_pm2_5], + ['PM10', airQuality?.european_aqi_pm10], + ['NO₂', airQuality?.european_aqi_nitrogen_dioxide], + ['O₃', airQuality?.european_aqi_ozone], + ['SO₂', airQuality?.european_aqi_sulphur_dioxide], + ].filter(([, value]) => Number.isFinite(value)); + components.sort((a, b) => Number(b[1]) - Number(a[1])); + return { ...category, index, why: components[0]?.[0] || 'EAQI' }; } export function _fmtTime(iso) { @@ -238,35 +158,19 @@ export function _fmtTime(iso) { return match ? match[1] : iso; } -export function _surfaceOzoneCls(microgramsPerCubicMeter) { - if (microgramsPerCubicMeter == null) return null; - if (microgramsPerCubicMeter < 50) return 'good'; - if (microgramsPerCubicMeter < 100) return 'moderate'; - if (microgramsPerCubicMeter < 180) return 'unhealthy-sensitive'; - if (microgramsPerCubicMeter < 240) return 'unhealthy'; - return 'hazardous'; -} - -export function _surfaceOzoneLabel(microgramsPerCubicMeter) { - if (microgramsPerCubicMeter == null) return null; - if (microgramsPerCubicMeter < 50) return { label: 'Clean', action: 'fine for any outdoor activity' }; - if (microgramsPerCubicMeter < 100) return { label: 'Mild', action: 'fine for most · sensitive may feel it' }; - if (microgramsPerCubicMeter < 180) return { label: 'Moderate', action: 'go easy on hard cardio outdoors' }; - if (microgramsPerCubicMeter < 240) return { label: 'Unhealthy', action: 'limit outdoor exercise' }; - return { label: 'Hazardous', action: 'avoid outdoor exercise' }; -} - -export const SMOG_HINT = 'Smog = ground-level ozone (O₃), formed when sunlight reacts with vehicle exhaust + industrial emissions. Higher levels irritate lungs and reduce exercise capacity, especially for asthma, COPD, kids, elderly. WHO 8-hour guideline: 100 µg/m³.'; +export const SMOG_HINT = 'Ground-level ozone (O₃) is an air pollutant. The displayed category uses the provider-computed European AQI ozone component; the µg/m³ value is context, not a category inferred from one instantaneous reading.'; export function _humanProviderLabel(source) { if (!source) return 'unknown'; - if (source.startsWith('open_meteo')) return 'Open-Meteo'; - if (source.startsWith('selfhost')) return 'self-hosted'; - if (source.startsWith('cams')) return 'CAMS'; - if (source.startsWith('noaa')) return 'NOAA NWS'; - if (source.startsWith('manual')) return 'manual entry'; - if (source.startsWith('zenith_offline') || source.startsWith('offline')) return 'offline estimate'; - return source.replace(/_stale$/, ''); + const normalized = source.replace(/_stale$/, ''); + if (normalized.includes('cams') && normalized.includes('open_meteo')) return 'CAMS + Open-Meteo'; + if (normalized.startsWith('open_meteo')) return 'Open-Meteo'; + if (normalized.startsWith('selfhost')) return 'self-hosted'; + if (normalized.startsWith('cams')) return 'CAMS'; + if (normalized.startsWith('noaa')) return 'NOAA NWS'; + if (normalized.startsWith('manual')) return 'legacy manual entry'; + if (normalized.startsWith('zenith_offline') || normalized.startsWith('offline')) return 'offline estimate'; + return normalized; } // Check the atmosphere response for plausibility — flag suspicious values @@ -281,7 +185,7 @@ export function _sanityCheckAtmosphere(atm, coords) { warnings.push(`UVI ${atm.uvIndex.toFixed(1)} exceeds today's forecast peak (${peak.toFixed(1)}) — likely stale data, try Refresh`); } if (coords) { - const zenith = _solarZenithAngle(new Date(), coords); + const zenith = _solarZenithAngle(new Date(atm.validAt || Date.now()), coords); if (zenith != null && zenith > 95 && atm.uvIndex > 0.3) { warnings.push(`UVI ${atm.uvIndex} reported but sun is ${Math.round(zenith - 90)}° below horizon`); } diff --git a/js/light-conditions-now-hooks.js b/js/light-conditions-now-hooks.js index 13412704..109592c6 100644 --- a/js/light-conditions-now-hooks.js +++ b/js/light-conditions-now-hooks.js @@ -1,7 +1,6 @@ // @ts-check // light-conditions-now-hooks.js - wire Conditions Now runtime dependencies at startup. -import { saveImportedData } from './data.js'; import { computeUVConfidence, fetchAtmosphere, @@ -19,7 +18,6 @@ configureLightConditionsNow({ getSunCoords, isDebugMode, purgeMeteoCache, - saveImportedData, showNotification, solarZenithAngle, }); diff --git a/js/light-conditions-now.js b/js/light-conditions-now.js index bc565ab6..718444ce 100644 --- a/js/light-conditions-now.js +++ b/js/light-conditions-now.js @@ -2,7 +2,6 @@ // light-conditions-now.js — Current outdoor conditions widget for Light & Sun import { getErrorMessage } from './caught-error.js'; -import { state } from './state.js'; import { escapeHTML, escapeAttr } from './utils.js'; import { openAppendedModalOverlay, removeModalOverlay } from './modal-lifecycle.js'; import { @@ -25,7 +24,6 @@ const lightConditionsDeps = { getSunCoords: () => null, isDebugMode: () => false, purgeMeteoCache: () => {}, - saveImportedData: null, showNotification: null, solarZenithAngle: null, }; @@ -93,15 +91,6 @@ function handleLightConditionsActionClick(event) { event.stopPropagation(); return; } - if (action === 'set-manual-uvi') { - _setManualUvi(); - event.stopPropagation(); - return; - } - if (action === 'clear-manual-uvi') { - _clearManualUvi(); - event.stopPropagation(); - } } export function installLightConditionsActionDelegates(root = typeof document !== 'undefined' ? document : null) { @@ -116,6 +105,10 @@ if (typeof document !== 'undefined') installLightConditionsActionDelegates(); export function renderLightConditionsWidgetBody({ variant = 'full', slotId = '' } = {}) { const conditionsOpts = { variant }; if (slotId) conditionsOpts.slotId = slotId; + const locationSetup = _getSunCoords() ? '' : `
+ To see local conditions, set your country in the profile editor or use your phone location for this session. + +
`; return `
Conditions now @@ -124,6 +117,7 @@ export function renderLightConditionsWidgetBody({ variant = 'full', slotId = ''
+ ${locationSetup} ${renderConditionsNow(conditionsOpts)}
`; } @@ -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() {
${atm?.source ? escapeHTML(atm.source) : '—'}
- + +
${atm?.validAt ? escapeHTML(new Date(atm.validAt).toLocaleString()) : '—'}
+
+
+
${atm?.fetchedAt ? escapeHTML(new Date(atm.fetchedAt).toLocaleString()) : '—'}
- +
${coords ? `${coords.lat.toFixed(2)}°, ${coords.lon.toFixed(2)}° (${escapeHTML(coords.source || 'unknown')})` : '—'}
+
+ +
${Number.isFinite(atm?._requestCoords?.lat) && Number.isFinite(atm?._requestCoords?.lon) ? `${atm._requestCoords.lat.toFixed(2)}°, ${atm._requestCoords.lon.toFixed(2)}°${atm._requestCoords.privacyRounded ? ' (privacy-rounded)' : ''}` : '—'}
+
${(() => { @@ -425,34 +392,31 @@ export function _inspectConditionsNow() { source: atm?.source, snapshotAgeSec: atm?._camsMeta?.ageSec ?? null, cloudCover: atm?.cloudCover ?? null, - zenithDeg: coords && atm?.fetchedAt ? _solarZenithAngle(new Date(atm.fetchedAt), coords) : null, + zenithDeg: coords && (atm?.validAt || atm?.fetchedAt) ? _solarZenithAngle(new Date(atm.validAt || atm.fetchedAt), coords) : null, uvIndex: atm?.uvIndex ?? null, isStale: !!atm?._stale, - manualOverridden: !!atm?._uvOverridden, }) : (atm?.confidence ?? null); if (computed == null) return '—'; const pct = Math.round(computed * 100); // Tooltip lists the active discounts so the user can see WHY // confidence dropped — turns a single number into honest reasoning. const factors = []; - if (atm?._uvOverridden) factors.push('manual UVI override'); - else { - const age = atm?._camsMeta?.ageSec; - if (Number.isFinite(age)) { - if (age > 86400) factors.push(`stale grid (${Math.round(age/3600)}h old)`); - else if (age > 43200) factors.push(`grid ${Math.round(age/3600)}h old`); - else if (age > 21600) factors.push(`grid ${Math.round(age/3600)}h old`); - } - const cc = atm?.cloudCover; - const ccNorm = cc != null && cc > 1 ? cc / 100 : cc; - if (Number.isFinite(ccNorm)) { - if (ccNorm > 0.8) factors.push(`heavy cloud (${Math.round(ccNorm*100)}%)`); - else if (ccNorm > 0.5) factors.push(`moderate cloud (${Math.round(ccNorm*100)}%)`); - } - if (atm?._stale) factors.push('upstream marked stale'); - const u = atm?.uvIndex; - if (Number.isFinite(u) && u < 2) factors.push(`low UVI (${u.toFixed(1)} — model band noisy below 2)`); + const age = atm?._camsMeta?.ageSec; + if (Number.isFinite(age)) { + if (age > 86400) factors.push(`stale grid (${Math.round(age/3600)}h old)`); + else if (age > 43200) factors.push(`grid ${Math.round(age/3600)}h old`); + else if (age > 21600) factors.push(`grid ${Math.round(age/3600)}h old`); + } + const cc = atm?.cloudCover; + const ccNorm = cc != null && cc > 1 ? cc / 100 : cc; + if (Number.isFinite(ccNorm)) { + if (ccNorm > 0.8) factors.push(`heavy cloud (${Math.round(ccNorm*100)}%)`); + else if (ccNorm > 0.5) factors.push(`moderate cloud (${Math.round(ccNorm*100)}%)`); } + if (atm?._stale) factors.push('upstream marked stale'); + if (atm?._offline) factors.push('offline geometry-only estimate'); + const u = atm?.uvIndex; + if (Number.isFinite(u) && u < 2) factors.push(`low UVI (${u.toFixed(1)} — model band noisy below 2)`); const tip = factors.length ? `Discounted by: ${factors.join('; ')}` : 'No active discounts; baseline source confidence.'; return `${pct}%`; })()}
diff --git a/js/light-conditions-renderer.js b/js/light-conditions-renderer.js index aae16b7b..0fe9dc0d 100644 --- a/js/light-conditions-renderer.js +++ b/js/light-conditions-renderer.js @@ -1,12 +1,13 @@ // @ts-check // light-conditions-renderer.js — Current-conditions presentation owner. -import { state } from './state.js'; import { escapeAttr, escapeHTML } from './utils.js'; +import { parseProviderTimeMs } from './sun-uvdata-atmosphere.js'; import { _aggregateAQ, _cloudNarrative, _computeUvaWindow, + _europeanAQCategory, _fmtMinutes, _fmtTime, _humanProviderLabel, @@ -14,13 +15,9 @@ import { _solarZenithAngle, _sunPositionLabel, _sunPositionSub, - _surfaceOzoneCls, - _surfaceOzoneLabel, - _timeToMed, - _vitDLabel, + _uviConditionLabel, SHADOW_RULE_HINT, SMOG_HINT, - TANNING_MODIFIERS_NOTE, } from './light-conditions-interpretation.js'; function conditionsTooltipAttr(text, opts = {}) { @@ -28,6 +25,21 @@ function conditionsTooltipAttr(text, opts = {}) { return ` data-conditions-tooltip="${escapeAttr(text)}"${opts.focusable ? ' tabindex="0"' : ''}`; } +function sourceAttributionHTML(source, fallbackLabel) { + const value = String(source || ''); + const links = []; + if (value.includes('cams')) { + links.push('CAMS'); + } + if (value.includes('open_meteo')) { + links.push('Open-Meteo'); + } + if (value.includes('noaa')) { + links.push('NOAA NWS'); + } + return links.length ? links.join(' + ') : escapeHTML(fallbackLabel); +} + export function renderConditionsHTML(atm, coords, variant, offline = false) { const uvi = atm.uvIndex != null ? Math.round(atm.uvIndex * 10) / 10 : null; const uviClear = atm.uvClearSky != null ? Math.round(atm.uvClearSky * 10) / 10 : null; @@ -37,39 +49,37 @@ export function renderConditionsHTML(atm, coords, variant, offline = false) { const ozone = atm.ozoneDU != null ? Math.round(atm.ozoneDU) : null; const surfaceOzone = atm.airQuality?.surfaceOzoneUgM3 != null ? Math.round(atm.airQuality.surfaceOzoneUgM3) : null; const cloud = atm.cloudCover != null ? Math.round(atm.cloudCover) : null; - const aqPm25 = atm.airQuality?.pm25 != null ? Math.round(atm.airQuality.pm25) : null; // Sanity-check the data — UVI shouldn't exist when sun is below horizon, // shouldn't exceed ~16 anywhere on Earth, etc. Flag suspicious responses // so the user knows when the upstream looks off. const sanityWarnings = _sanityCheckAtmosphere(atm, coords); + const uviWarnings = sanityWarnings.filter(warning => warning.startsWith('UVI')); + const uviReliable = uviWarnings.length === 0; const sourceLabel = _humanProviderLabel(atm.source); + const validAt = Number.isFinite(atm.validAt) ? atm.validAt : atm.fetchedAt; + const validAgoMin = validAt ? Math.max(0, Math.round((Date.now() - validAt) / 60000)) : null; const fetchedAgoMin = atm.fetchedAt ? Math.max(0, Math.round((Date.now() - atm.fetchedAt) / 60000)) : null; - const freshnessLabel = fetchedAgoMin == null ? 'unknown' - : fetchedAgoMin < 1 ? 'just now' - : fetchedAgoMin < 60 ? `${fetchedAgoMin} min ago` - : `${Math.round(fetchedAgoMin / 60)}h ago`; + const elapsedLabel = minutes => minutes == null ? 'unknown' + : minutes < 1 ? 'just now' + : minutes < 60 ? `${minutes} min ago` + : `${Math.round(minutes / 60)}h ago`; + const freshnessLabel = elapsedLabel(validAgoMin); + const retrievedLabel = elapsedLabel(fetchedAgoMin); // Solar zenith angle — degrees from vertical. 0 = sun directly overhead. const zenith = _solarZenithAngle(new Date(), coords); const sunAngle = zenith != null ? Math.round(90 - zenith) : null; // UV index color ramp — UVI 0 green → UVI 11+ purple. let uviCls = 'low'; - if (uvi != null) { + if (uvi != null && uviReliable) { if (uvi >= 11) uviCls = 'extreme'; else if (uvi >= 8) uviCls = 'very-high'; else if (uvi >= 6) uviCls = 'high'; else if (uvi >= 3) uviCls = 'moderate'; } - // Resolve user's Fitzpatrick (for time-to-MED). Track whether it's - // user-set vs the default III fallback so we can qualify the readout. - const userFp = state.importedData?.sunDefaults?.fitzpatrick - || (state.importedData?.lightCircadian?.skinType?.match?.(/^(I{1,3}|IV|VI?)\b/) || [])[1]; - const fp = userFp || 'III'; - const fpIsDefault = !userFp; - const medResult = uvi != null ? _timeToMed(uvi, fp, atm) : null; - const vitDLabel = _vitDLabel(uvi); + const uviLabel = _uviConditionLabel(uvi); const peakAt = atm.daily?.peakAt; const peakUvi = atm.daily?.uvIndexMax; const peakIsNow = peakAt && uvi != null && peakUvi != null && uvi >= peakUvi - 0.3; @@ -79,30 +89,37 @@ export function renderConditionsHTML(atm, coords, variant, offline = false) { const cloudWord = _cloudNarrative(cloud); const cloudChip = cloudWord ? (uviClear != null && uvi != null && uviClear > uvi + 0.5 - ? `${cloudWord} · clear-sky max UVI ${uviClear.toFixed(1)}` + ? `${cloudWord} · clear-sky UVI ${uviClear.toFixed(1)}` : cloudWord) : ''; - const surfaceOzoneCls = _surfaceOzoneCls(surfaceOzone); const eaqi = atm.airQuality?.european_aqi ?? null; const aqAgg = _aggregateAQ(atm.airQuality, eaqi); + const ozoneEaqi = atm.airQuality?.european_aqi_ozone ?? null; + const ozoneCategory = _europeanAQCategory(ozoneEaqi); // Build today's chronological sun-event rail with a current-time marker. const sunrise = atm.daily?.sunrise; const sunset = atm.daily?.sunset; - const { firstUVA, lastUVA } = _computeUvaWindow(coords, sunrise || new Date()); + const locationOffsetSeconds = Number(atm.hourly?.utcOffsetSeconds) || 0; + const sunriseMs = parseProviderTimeMs(sunrise, locationOffsetSeconds); + const sunsetMs = parseProviderTimeMs(sunset, locationOffsetSeconds); + const peakAtMs = parseProviderTimeMs(peakAt, locationOffsetSeconds); + const uvaAnchor = Number.isFinite(sunriseMs) ? new Date(sunriseMs) : new Date(); + const { firstUVA, lastUVA } = _computeUvaWindow(coords, uvaAnchor, locationOffsetSeconds); const events = []; if (sunrise) { events.push({ icon: '🌅', label: _fmtTime(sunrise), - ts: new Date(sunrise).getTime(), + ts: sunriseMs, kind: 'sunrise', - tooltip: 'Geometric sunrise — sun crosses horizon. UV-A still negligible, eye-light barely above twilight.', + tooltip: 'Geometric sunrise — the solar disk crosses the horizon and the direct spectrum begins its transition from twilight.', }); } const localHHMM = date => { const pad = value => String(value).padStart(2, '0'); - return `${pad(date.getHours())}:${pad(date.getMinutes())}`; + const shifted = new Date(date.getTime() + locationOffsetSeconds * 1000); + return `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}`; }; if (firstUVA) { events.push({ @@ -111,14 +128,14 @@ export function renderConditionsHTML(atm, coords, variant, offline = false) { ts: firstUVA.getTime(), kind: 'first-uva', uvaEvent: true, - tooltip: 'Sun reaches ~5° elevation — atmospheric path short enough for 320-400 nm UV-A to penetrate. Biological dawn: eye + skin start receiving the violet/UV-A signals that drive circadian entrainment, α-MSH / β-endorphin, and retinal dopamine.', + tooltip: 'UV-A transition begins. The sun crosses the model’s ~5° threshold, where surface UV-A becomes a more substantial photobiological input. Photon-driven molecular signaling can be switch-like, while downstream responses depend on wavelength, dose, tissue, and context. Trace diffuse UV-A may be present earlier.', }); } if (peakAt) { events.push({ icon: '☀', label: `${_fmtTime(peakAt)}${peakUvi != null ? ` · UVI ${peakUvi.toFixed(1)}` : ''}`, - ts: new Date(peakAt).getTime(), + ts: peakAtMs, peak: true, kind: 'peak', tooltip: 'Solar noon — UVI at its daily maximum.', @@ -131,16 +148,16 @@ export function renderConditionsHTML(atm, coords, variant, offline = false) { ts: lastUVA.getTime(), kind: 'last-uva', uvaEvent: true, - tooltip: 'Sun drops below ~5° elevation — UV-A fades from the surface. Biological dusk window closes; melatonin synthesis ramps up.', + tooltip: 'UV-A transition closes. The sun drops below the same ~5° threshold, so this photobiological input rapidly diminishes. Small direct or diffuse UV-A may persist toward sunset and twilight, and downstream signaling does not stop instantly.', }); } if (sunset) { events.push({ icon: '🌇', label: _fmtTime(sunset), - ts: new Date(sunset).getTime(), + ts: sunsetMs, kind: 'sunset', - tooltip: 'Geometric sunset — sun drops below horizon. UV-A already gone for ~30-60 min.', + tooltip: 'Geometric sunset — the solar disk drops below the horizon. Low diffuse twilight, including small UV-A contributions, can persist after this moment.', }); } const nowTs = Date.now(); @@ -182,69 +199,66 @@ export function renderConditionsHTML(atm, coords, variant, offline = false) { }; const timelineTip = 'Today\'s sun timeline — left to right is the timeline through your day. Events left of the highlighted now-marker have passed; events to the right are upcoming.'; const sunEventsLine = events.length ? `
-
Today's sun timeline
+
Today's sun timeline
- ${eventsWithNow.map((event, index) => `${event.icon}${escapeHTML(eventRailLabel(event))}${escapeHTML(eventRailTime(event))}`).join('')} + ${eventsWithNow.map((event, index) => `${event.icon}${escapeHTML(eventRailLabel(event))}${escapeHTML(eventRailTime(event))}`).join('')}
` : ''; - const storedOverride = state.importedData?.sunDefaults?.overrides?.uvIndex; + const sourceIsGeometryOffline = atm._offline || /(?:zenith_offline|offline)/.test(String(atm.source || '')); + const sourceIsOffline = offline || sourceIsGeometryOffline; + const sourceIsStale = !sourceIsOffline && !!atm._stale; + const sourceStatusClass = sourceIsOffline ? 'is-offline' : (sourceIsStale ? 'is-stale' : 'is-fresh'); + const sourceStatusLabel = sourceIsGeometryOffline ? 'offline modeled estimate' + : offline ? 'offline · cached model' + : sourceIsStale ? 'stale cached model' + : 'current model'; + const sourceHTML = sourceAttributionHTML(atm.source, sourceLabel); const trustFooter = `
- + - ${offline ? 'offline · cached' : (atm._stale ? 'stale · cached' : 'live')} · via ${escapeHTML(sourceLabel)} · ${escapeHTML(freshnessLabel)} + ${sourceStatusLabel} · via ${sourceHTML} · valid ${escapeHTML(freshnessLabel)} ${sanityWarnings.length ? `⚠ ${sanityWarnings.length} sanity warning${sanityWarnings.length === 1 ? '' : 's'}` : ''} - - - - - ${Number.isFinite(storedOverride) ? `` : ''} -
`; if (variant === 'compact') { - const compactInterpretation = uvi != null ? (() => { - let text = vitDLabel; - if (medResult?.kind === 'no-uv') text += ' · no burn risk'; - else if (medResult?.kind === 'safe-til-sunset') text += ' · won\'t burn before sunset'; - else if (medResult?.kind === 'minutes') text += ` · ~${_fmtMinutes(medResult.value)} to sunburn dose${fpIsDefault ? '*' : ''}`; - return text; - })() : ''; + const compactInterpretation = uvi != null + ? (uviReliable ? uviLabel : '⚠ UVI data looks inconsistent — see Details') + : ''; return `
- ${uvi != null ? `UVI ${uvi}` : ''} - ${aqAgg ? `AQ ${escapeHTML(aqAgg.label)}` : ''} + ${uvi != null ? `UVI ${uvi}` : ''} + ${aqAgg ? `AQ ${escapeHTML(aqAgg.label)}` : ''} ${peakAt && !peakIsNow ? `peak ${_fmtTime(peakAt)}` : ''} - - ${escapeHTML(sourceLabel)} + + ${sourceHTML}
${compactInterpretation ? `
${escapeHTML(compactInterpretation)}
` : ''}`; } - 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')}
` : ''} ${(cloudChip || peakChip) ? `
${cloudChip ? `${escapeHTML(cloudChip)}` : ''} ${peakChip ? `${escapeHTML(peakChip)}` : ''} @@ -255,23 +269,17 @@ export function renderConditionsHTML(atm, coords, variant, offline = false) {
${escapeHTML(_sunPositionLabel(sunAngle))}
${escapeHTML(_sunPositionSub(sunAngle))}
-
-
${ozone != null ? 'Ozone column' : 'Smog (ground O₃)'}
-
${ozone != null ? ozone : (surfaceOzone != null ? escapeHTML(_surfaceOzoneLabel(surfaceOzone)?.label || '—') : '—')}
-
${ - ozone != null ? 'DU stratospheric' - : surfaceOzone != null ? escapeHTML(_surfaceOzoneLabel(surfaceOzone)?.action || `${surfaceOzone} µg/m³`) : '' - }
+
+
${showSurfaceOzone ? 'Ground ozone' : 'Ozone column'}
+
${escapeHTML(ozoneValue)}
+
${escapeHTML(ozoneSub)}
Air quality
${aqAgg ? escapeHTML(aqAgg.label) : '—'}
-
${aqAgg ? (aqAgg.why === 'EAQI' ? 'EU air quality index' : (aqAgg.why ? `worst pollutant: ${aqAgg.why} ${aqAgg.why === 'PM2.5' && aqPm25 != null ? aqPm25 + ' µg/m³' : ''}` : 'worst-of multi-pollutant')) : ''}
+
${aqAgg ? `EU index ${Math.round(aqAgg.index)}${aqAgg.why !== 'EAQI' ? ` · highest: ${escapeHTML(aqAgg.why)}` : ''}` : ''}
${sunEventsLine} -
- 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 = {}) {
` : ''} ${(() => { const useUS = state.unitSystem === 'US'; @@ -170,7 +194,7 @@ 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.'} `; })()}
@@ -182,9 +206,9 @@ export async function openDeviceSessionDialog(deviceId, deps = {}) {
- 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() {