Closer to Home frontend: inline home prompt + sectioned feed

Completes "Closer to Home" (Codex UX: obvious once, quiet forever).

- Opt-in home (country + optional US state) in localStorage; empty = default feed.
- Calm inline prompt above the browse feed ("Want good news closer to home?")
  with a country/state picker; dismissible and remembered (no nagging). Once set,
  a slim "📍 Showing local first · Change · Clear" indicator replaces it.
- Browse feed passes home and pages by next_offset (the near/country lead block
  never skews world paging); soft section headers (Near you / Elsewhere in your
  country / Around the world) render only for tiers that exist.
- Only affects the default browse lane; Brief, topic lanes, and the shareable
  default feed are untouched.

Follow-up nicety: mirror the Home setting inside the Boundaries panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-19 20:23:29 -04:00
parent e7e8f5515e
commit 3861ed4060
+121 -6
View File
@@ -63,6 +63,13 @@
let feed = $state([]);
let feedDone = $state(false); // no more pages for the current feed view
let loadingMore = $state(false);
// Closer to Home: the reader's opt-in home ('US' or 'US-NY'), localStorage-only.
// Empty = the default non-personalized feed. feedNextOffset carries the API's
// world-tier paging cursor so the one-time near/country lead block never skews paging.
let homeValue = $state('');
let homePromptDismissed = $state(false);
let feedNextOffset = $state(null);
const homeActive = () => selected === 'browse' && !!homeValue;
let showSignIn = $state(false);
let showSaved = $state(false); // Saved flyout
let loading = $state(true);
@@ -305,7 +312,8 @@
return `/api/feed?source_id=${encodeURIComponent(key.slice(7))}&sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
}
const q = P.param(P.merge(prefs.data, viewFilter(key)));
return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
const homeq = homeValue ? `&home=${encodeURIComponent(homeValue)}` : '';
return `/api/feed?limit=${PAGE}&offset=${offset}${homeq}${q ? '&' + q : ''}${exq}`;
}
// All navigation goes through the URL (goto), so browser Back/Forward and the
@@ -340,10 +348,13 @@
await loadToday(fresh);
if (seq !== loadSeq) return; // a newer navigation superseded this one
} else {
const items = (await getJSON(feedUrl(key, 0))).items;
const resp = await getJSON(feedUrl(key, 0));
if (seq !== loadSeq) return;
const items = resp.items;
feed = items;
feedDone = items.length < PAGE;
feedNextOffset = resp.next_offset ?? null;
// Home lane pages by the API's world cursor; other lanes by simple length.
feedDone = (key === 'browse' && homeValue) ? feedNextOffset == null : items.length < PAGE;
markDisplayed(feed);
if (key.startsWith('source:') && items[0]) {
sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source };
@@ -389,11 +400,14 @@
if (loadingMore || feedDone || selected === 'today') return;
loadingMore = true;
try {
const items = (await getJSON(feedUrl(selected, feed.length))).items;
const off = homeActive() && feedNextOffset != null ? feedNextOffset : feed.length;
const resp = await getJSON(feedUrl(selected, off));
const items = resp.items;
const have = new Set(feed.map((a) => a.id));
const fresh = items.filter((a) => !have.has(a.id));
feed = [...feed, ...fresh];
feedDone = items.length < PAGE;
feedNextOffset = resp.next_offset ?? null;
feedDone = homeActive() ? feedNextOffset == null : items.length < PAGE;
markDisplayed(fresh);
} catch {
flash('Could not load more just now.');
@@ -482,11 +496,65 @@
document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
}
// --- Closer to Home: opt-in, localStorage-only, easy to clear ---
function setHome(v) {
homeValue = v || '';
try { v ? localStorage.setItem('goodnews:home', v) : localStorage.removeItem('goodnews:home'); } catch { /* ignore */ }
if (selected === 'browse') loadView('browse', true); // re-section the feed now
}
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] : '');
// Home picker. Countries are the well-covered ones (matches backend normalization);
// US gets state granularity. Kept short on purpose — calm, not a config wall.
const HOME_COUNTRIES = [
['US', 'United States'], ['GB', 'United Kingdom'], ['CA', 'Canada'], ['AU', 'Australia'],
['IE', 'Ireland'], ['NZ', 'New Zealand'], ['FR', 'France'], ['DE', 'Germany'],
['NL', 'Netherlands'], ['BE', 'Belgium'], ['IT', 'Italy'], ['ES', 'Spain'], ['IN', 'India'],
];
const US_STATES = [
['AL','Alabama'],['AK','Alaska'],['AZ','Arizona'],['AR','Arkansas'],['CA','California'],
['CO','Colorado'],['CT','Connecticut'],['DE','Delaware'],['DC','Washington DC'],['FL','Florida'],
['GA','Georgia'],['HI','Hawaii'],['ID','Idaho'],['IL','Illinois'],['IN','Indiana'],['IA','Iowa'],
['KS','Kansas'],['KY','Kentucky'],['LA','Louisiana'],['ME','Maine'],['MD','Maryland'],
['MA','Massachusetts'],['MI','Michigan'],['MN','Minnesota'],['MS','Mississippi'],['MO','Missouri'],
['MT','Montana'],['NE','Nebraska'],['NV','Nevada'],['NH','New Hampshire'],['NJ','New Jersey'],
['NM','New Mexico'],['NY','New York'],['NC','North Carolina'],['ND','North Dakota'],['OH','Ohio'],
['OK','Oklahoma'],['OR','Oregon'],['PA','Pennsylvania'],['RI','Rhode Island'],['SC','South Carolina'],
['SD','South Dakota'],['TN','Tennessee'],['TX','Texas'],['UT','Utah'],['VT','Vermont'],
['VA','Virginia'],['WA','Washington'],['WV','West Virginia'],['WI','Wisconsin'],['WY','Wyoming'],
];
let homeEditing = $state(false);
let pickCountry = $state('');
let pickState = $state('');
function applyHomePick() {
if (!pickCountry) return;
setHome(pickCountry === 'US' && pickState ? `${pickCountry}-${pickState}` : pickCountry);
homeEditing = false;
}
function openHomeEditor() { pickCountry = homeCountry; pickState = homeState; homeEditing = true; }
onMount(async () => {
initPrefs();
initHistory();
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
try {
homeValue = localStorage.getItem('goodnews:home') || '';
homePromptDismissed = localStorage.getItem('goodnews:homeDismissed') === '1';
} catch { /* ignore */ }
refreshAuth();
// trackVisit() now fires once in the global layout (covers every landing page).
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on direct/shared link
@@ -647,8 +715,38 @@
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
{/if}
{:else if feed.length}
{#if selected === 'browse'}
{#if homeEditing || (!homeValue && !homePromptDismissed)}
<div class="homecard rise">
<p class="homecopy">Want good news closer to home?</p>
<div class="homepick">
<select bind:value={pickCountry} aria-label="Country">
<option value="">Pick a country…</option>
{#each HOME_COUNTRIES as [code, label] (code)}<option value={code}>{label}</option>{/each}
</select>
{#if pickCountry === 'US'}
<select bind:value={pickState} aria-label="State">
<option value="">All of the US</option>
{#each US_STATES as [code, label] (code)}<option value={code}>{label}</option>{/each}
</select>
{/if}
<button class="hset" onclick={applyHomePick} disabled={!pickCountry}>Show local first</button>
{#if homeValue}
<button class="linkish" onclick={() => (homeEditing = false)}>Cancel</button>
{:else}
<button class="linkish" onclick={dismissHomePrompt}>Not now</button>
{/if}
</div>
</div>
{:else if homeValue}
<div class="homebar">📍 Showing local first · <button class="linkish" onclick={openHomeEditor}>Change</button> · <button class="linkish" onclick={clearHome}>Clear</button></div>
{/if}
{/if}
<div class="grid rise">
{#each feed as a (a.id)}
{#each feed as a, i (a.id)}
{#if a.section && a.section !== feed[i - 1]?.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>
@@ -859,6 +957,23 @@
.install-go:hover { background: var(--accent-deep); }
.install-x { background: none; border: none; color: var(--muted); font: inherit; font-size: 0.88rem; cursor: pointer; }
.install-x:hover { color: var(--accent-deep); }
/* Closer to Home: calm inline prompt + slim indicator + section headers */
.homecard { border: 1px solid var(--line); border-radius: 14px; background: var(--accent-soft);
padding: 14px 16px; margin: 0 0 18px; }
.homecopy { margin: 0 0 10px; font-family: var(--label); color: var(--accent-deep); }
.homepick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.homepick select { font: inherit; font-size: 0.9rem; padding: 7px 10px; border: 1px solid var(--line);
border-radius: 9px; background: var(--bg); color: var(--ink); }
.hset { font: inherit; font-weight: 600; padding: 7px 16px; border: none; border-radius: 999px;
background: var(--accent); color: #fff; cursor: pointer; }
.hset:hover { background: var(--accent-deep); }
.hset:disabled { opacity: 0.55; cursor: default; }
.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; }
.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; }
.loadmore { display: flex; justify-content: center; margin: 30px 0 6px; }
.loadmore button {
background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);