Scope dial v2: Nearby / Region / Country / World radius on the homepage
Codex-approved evolution: the reader controls the "emotional radius" of the landing. - Census-region "Regional" grain (geo.region_of / region_states). Scope-aware tiering (queries.home_tiers): closest->widest lead, confidence-gated on state + region, never a hard filter — blends outward so the set is always full. 'world' = the global brief. - queries.home_brief takes a scope; /api/brief gains a scope param (nearby|region| country|world). Country-only / non-US homes collapse to country. - Homepage dial replaces the 2-button toggle: adaptive stops (4 with a US state, else Country/World), persisted scope, "Good news closest first" framing. Concrete, soft section labels (Around New Jersey / Across the Northeast / Across the US / Around the world) so the reader sees the dial worked. Backend 366 + frontend tests green. (Latest feed still on v1 local-first; aligning it to the dial is the immediate follow-up.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,9 @@
|
||||
let homeValue = $state('');
|
||||
let homePromptDismissed = $state(false);
|
||||
let feedNextOffset = $state(null);
|
||||
let showGlobalBrief = $state(false); // toggle: see the global brief even with a home set
|
||||
// Scope dial: the reader's "emotional radius" — nearby | region | country | world.
|
||||
// Persisted; default nearby. 'world' = the global brief/feed (no geo lead).
|
||||
let homeScope = $state('nearby');
|
||||
const homeActive = () => selected === 'latest' && !!homeValue;
|
||||
let showSignIn = $state(false);
|
||||
let showSaved = $state(false); // Saved flyout
|
||||
@@ -249,7 +251,7 @@
|
||||
// Instant-paint and the merge only reuse a saved brief when this still matches,
|
||||
// so a boundary change can never briefly resurface content it should now hide.
|
||||
function briefSig() {
|
||||
const h = homeValue && !showGlobalBrief ? homeValue : '';
|
||||
const h = homeValue && homeScope !== 'world' ? `${homeValue}:${homeScope}` : '';
|
||||
return P.param(prefs.data) + '|' + Array.from(dismissed).sort().join(',') + '|h:' + h;
|
||||
}
|
||||
|
||||
@@ -257,7 +259,9 @@
|
||||
const q = P.param(prefs.data);
|
||||
const ex = Array.from(dismissed).join(',');
|
||||
let fetched;
|
||||
const homeq = homeValue && !showGlobalBrief ? `&home=${encodeURIComponent(homeValue)}` : '';
|
||||
// Only personalize when a home is set and the dial isn't on 'world' (global).
|
||||
const homeq = homeValue && homeScope !== 'world'
|
||||
? `&home=${encodeURIComponent(homeValue)}&scope=${homeScope}` : '';
|
||||
try {
|
||||
fetched = await getJSON(`/api/brief?limit=7${homeq}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
|
||||
} catch (e) {
|
||||
@@ -501,32 +505,51 @@
|
||||
}
|
||||
|
||||
// --- Closer to Home: opt-in, localStorage-only, easy to clear ---
|
||||
function setHome(v) {
|
||||
homeValue = v || '';
|
||||
showGlobalBrief = false; // a fresh home choice leads with local
|
||||
try { v ? localStorage.setItem('goodnews:home', v) : localStorage.removeItem('goodnews:home'); } catch { /* ignore */ }
|
||||
if (selected === 'today') loadToday(true); // re-lead the landing with local
|
||||
function reloadHomeViews() {
|
||||
if (selected === 'today') loadToday(true);
|
||||
else if (selected === 'latest') loadView('latest', true);
|
||||
}
|
||||
// The landing's Local ⟷ Global toggle (only meaningful with a home set).
|
||||
function setBriefScope(global) {
|
||||
showGlobalBrief = global;
|
||||
if (selected === 'today') loadToday(true);
|
||||
function persistScope() { try { localStorage.setItem('goodnews:homeScope', homeScope); } catch { /* ignore */ } }
|
||||
function setHome(v) {
|
||||
homeValue = v || '';
|
||||
// No US state -> nearby/region don't apply; collapse the dial to country.
|
||||
if (!String(v).includes('-') && (homeScope === 'nearby' || homeScope === 'region')) homeScope = 'country';
|
||||
try { v ? localStorage.setItem('goodnews:home', v) : localStorage.removeItem('goodnews:home'); } catch { /* ignore */ }
|
||||
persistScope();
|
||||
reloadHomeViews();
|
||||
}
|
||||
function setScope(s) { homeScope = s; persistScope(); reloadHomeViews(); }
|
||||
function clearHome() { setHome(''); }
|
||||
function dismissHomePrompt() {
|
||||
homePromptDismissed = true;
|
||||
try { localStorage.setItem('goodnews:homeDismissed', '1'); } catch { /* ignore */ }
|
||||
}
|
||||
// Section header label for a feed item's tier (only rendered when the tier changes).
|
||||
function sectionLabel(key) {
|
||||
if (key === 'near') return homeState ? 'Near you' : 'Close to home';
|
||||
if (key === 'country') return 'Elsewhere in your country';
|
||||
if (key === 'world') return 'Around the world';
|
||||
return '';
|
||||
}
|
||||
let homeCountry = $derived(homeValue ? homeValue.split('-')[0] : '');
|
||||
let homeState = $derived(homeValue.includes('-') ? homeValue.split('-')[1] : '');
|
||||
// US census regions (mirror of the backend) — for concrete section labels.
|
||||
const US_REGIONS = {
|
||||
Northeast: ['CT','ME','MA','NH','RI','VT','NJ','NY','PA'],
|
||||
Midwest: ['IL','IN','MI','OH','WI','IA','KS','MN','MO','NE','ND','SD'],
|
||||
South: ['DE','FL','GA','MD','NC','SC','VA','DC','WV','AL','KY','MS','TN','AR','LA','OK','TX'],
|
||||
West: ['AZ','CO','ID','MT','NV','NM','UT','WY','AK','CA','HI','OR','WA'],
|
||||
};
|
||||
let homeStateName = $derived(homeState ? (US_STATES.find(([c]) => c === homeState)?.[1] ?? homeState) : '');
|
||||
let homeRegionName = $derived(homeState ? (Object.keys(US_REGIONS).find((r) => US_REGIONS[r].includes(homeState)) ?? '') : '');
|
||||
let homeCountryLabel = $derived(homeCountry === 'US' ? 'the US' : homeCountry === 'GB' ? 'the UK'
|
||||
: (HOME_COUNTRIES.find(([c]) => c === homeCountry)?.[1] ?? homeCountry));
|
||||
// The dial's stops adapt to what's entered: full radius for a US state, else Country/World.
|
||||
let scopeStops = $derived(homeState
|
||||
? [['nearby', 'Nearby'], ['region', 'Region'], ['country', 'Country'], ['world', 'World']]
|
||||
: [['country', 'Country'], ['world', 'World']]);
|
||||
// Concrete, soft section labels — honest place names so the reader sees the dial worked.
|
||||
function sectionLabel(key) {
|
||||
if (key === 'state') return homeStateName ? `Around ${homeStateName}` : 'Near you';
|
||||
if (key === 'region') return homeRegionName ? `Across the ${homeRegionName}` : 'Your region';
|
||||
if (key === 'country') return `Across ${homeCountryLabel}`;
|
||||
if (key === 'world') return 'Around the world';
|
||||
if (key === 'near') return homeStateName ? `Around ${homeStateName}` : 'Near you'; // legacy feed key
|
||||
return '';
|
||||
}
|
||||
|
||||
// Home picker. Countries are the well-covered ones (matches backend normalization);
|
||||
// US gets state granularity. Kept short on purpose — calm, not a config wall.
|
||||
@@ -565,6 +588,8 @@
|
||||
try {
|
||||
homeValue = localStorage.getItem('goodnews:home') || '';
|
||||
homePromptDismissed = localStorage.getItem('goodnews:homeDismissed') === '1';
|
||||
homeScope = localStorage.getItem('goodnews:homeScope') || 'nearby';
|
||||
if (!homeValue.includes('-') && (homeScope === 'nearby' || homeScope === 'region')) homeScope = 'country';
|
||||
} catch { /* ignore */ }
|
||||
refreshAuth();
|
||||
// trackVisit() now fires once in the global layout (covers every landing page).
|
||||
@@ -706,17 +731,24 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else if homeValue}
|
||||
<div class="briefscope rise">
|
||||
<button class="bs-btn" class:on={!showGlobalBrief} onclick={() => setBriefScope(false)}>📍 Near you</button>
|
||||
<button class="bs-btn" class:on={showGlobalBrief} onclick={() => setBriefScope(true)}>🌍 Everywhere</button>
|
||||
<button class="linkish bs-change" onclick={openHomeEditor}>Change</button>
|
||||
<div class="scopedial rise">
|
||||
<span class="sd-label">Good news closest first</span>
|
||||
<div class="sd-stops">
|
||||
{#each scopeStops as [s, label] (s)}
|
||||
<button class="sd-btn" class:on={homeScope === s} onclick={() => setScope(s)}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="linkish sd-change" onclick={openHomeEditor}>Change home</button>
|
||||
</div>
|
||||
{/if}
|
||||
<section class="rise">
|
||||
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} onimageerror={heroImageFailed} />
|
||||
{#if restArticles.length}
|
||||
<div class="grid rest">
|
||||
{#each restArticles as a (a.id)}
|
||||
{#each restArticles as a, i (a.id)}
|
||||
{#if a.section && a.section !== restArticles[i - 1]?.section && a.section !== heroArticle?.section}
|
||||
<h3 class="feed-section">{sectionLabel(a.section)}</h3>
|
||||
{/if}
|
||||
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1008,11 +1040,14 @@
|
||||
.linkish { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.86rem;
|
||||
cursor: pointer; text-decoration: underline; padding: 0; }
|
||||
.homebar { font-size: 0.86rem; color: var(--muted); margin: 0 0 16px; }
|
||||
.briefscope { display: flex; gap: 8px; align-items: center; margin: 0 0 16px; }
|
||||
.bs-btn { font: inherit; font-size: 0.88rem; font-weight: 600; padding: 7px 16px; border: 1px solid var(--line);
|
||||
border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
|
||||
.bs-btn.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-deep); }
|
||||
.bs-change { margin-left: auto; }
|
||||
.scopedial { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 0 0 16px; }
|
||||
.sd-label { font-size: 0.82rem; color: var(--muted); }
|
||||
.sd-stops { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
|
||||
.sd-btn { font: inherit; font-size: 0.85rem; font-weight: 600; padding: 6px 14px; border: none;
|
||||
background: var(--bg); color: var(--ink); cursor: pointer; border-right: 1px solid var(--line); }
|
||||
.sd-stops .sd-btn:last-child { border-right: none; }
|
||||
.sd-btn.on { background: var(--accent-soft); color: var(--accent-deep); }
|
||||
.sd-change { margin-left: auto; }
|
||||
.feed-section { grid-column: 1 / -1; margin: 8px 0 2px; font-family: var(--label); font-size: 0.78rem;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||
.grid > .feed-section:first-child { margin-top: 0; }
|
||||
|
||||
Reference in New Issue
Block a user