Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 39 additions & 29 deletions MODULE_MAP.md

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions api/postal-geocode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// @ts-check

import { fetchWithValidatedRedirects, readResponseTextWithCap } from '../lib/proxy-upstream.js';

const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const CACHE_MAX = 512;
const cache = new Map();
let requestQueue = Promise.resolve();
let lastRequestStartedAt = 0;

function fetchPostalGeocodeUpstream(url, options, signal) {
const run = async () => {
const waitMs = Math.max(0, 1100 - (Date.now() - lastRequestStartedAt));
if (waitMs) await new Promise(resolve => setTimeout(resolve, waitMs));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
lastRequestStartedAt = Date.now();
return fetchWithValidatedRedirects(url, options, { signal });
};
const pending = requestQueue.then(run, run);
requestQueue = pending.then(() => undefined, () => undefined);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return pending;
}

/**
* @param {Record<string, any>} payload
* @param {any} req
* @param {{ corsHeaders: (req: any) => Record<string, string>, 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) {
return helpers.proxyUpstreamErrorResponse(req, error, 'Location lookup unavailable');
}
}
4 changes: 4 additions & 0 deletions api/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../lib/proxy-rate-limit.js')> | null} */
Expand Down Expand Up @@ -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;

Expand Down
187 changes: 147 additions & 40 deletions css/light-channels.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -251,25 +254,17 @@
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;
padding: 3px 9px; border-radius: 999px;
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);
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading