Local-first Brief: the landing leads with good news from your home

Per the owner's call (overrides the earlier "Brief sacred" stance): when a home is
set, the homepage opens with local good news first, not global. This is the hook —
you land and see awesome stories from YOUR corner first.

- queries.home_brief: local-first highlights (high/medium-confidence near, blended
  out to country then world so it's always a full, strong set), preferring already-
  summarized stories so the calm read stays rich. Recent window, ranked within tier.
- /api/brief gains a `home` param: private/no-store when set; over-fetches + caps so
  dismissal/boundary filtering never thins it; falls back to global top-up if needed.
- Landing UI: a Local <-> Global toggle ("📍 Near you / 🌍 Everywhere") when a home
  is set, the calm picker invite when not (dismissible), and Change. Default leads
  local; one tap back to the global brief. No home set => exactly today's behavior.

Backend + frontend tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-19 21:36:18 -04:00
parent 2239549799
commit d2a6293a13
7 changed files with 1784 additions and 16 deletions
+51 -9
View File
@@ -69,7 +69,8 @@
let homeValue = $state('');
let homePromptDismissed = $state(false);
let feedNextOffset = $state(null);
const homeActive = () => selected === 'browse' && !!homeValue;
let showGlobalBrief = $state(false); // toggle: see the global brief even with a home set
const homeActive = () => selected === 'latest' && !!homeValue;
let showSignIn = $state(false);
let showSaved = $state(false); // Saved flyout
let loading = $state(true);
@@ -248,15 +249,17 @@
// 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() {
return P.param(prefs.data) + '|' + Array.from(dismissed).sort().join(',');
const h = homeValue && !showGlobalBrief ? homeValue : '';
return P.param(prefs.data) + '|' + Array.from(dismissed).sort().join(',') + '|h:' + h;
}
async function loadToday(fresh) {
const q = P.param(prefs.data);
const ex = Array.from(dismissed).join(',');
let fetched;
const homeq = homeValue && !showGlobalBrief ? `&home=${encodeURIComponent(homeValue)}` : '';
try {
fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
fetched = await getJSON(`/api/brief?limit=7${homeq}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
} catch (e) {
if (brief) return; // already showing a saved brief — a failed background refresh stays invisible
throw e; // true first load with nothing painted → let the caller surface the error
@@ -295,7 +298,9 @@
}
if (key === 'latest') {
const q = P.param(prefs.data);
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
// Closer to Home lives on the all-news browse lane (Latest).
const homeq = homeValue ? `&home=${encodeURIComponent(homeValue)}` : '';
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${homeq}${q ? '&' + q : ''}${exq}`;
}
if (key === 'following') {
const q = P.param(prefs.data);
@@ -312,8 +317,7 @@
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)));
const homeq = homeValue ? `&home=${encodeURIComponent(homeValue)}` : '';
return `/api/feed?limit=${PAGE}&offset=${offset}${homeq}${q ? '&' + q : ''}${exq}`;
return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
}
// All navigation goes through the URL (goto), so browser Back/Forward and the
@@ -354,7 +358,7 @@
feed = items;
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;
feedDone = (key === 'latest' && homeValue) ? feedNextOffset == null : items.length < PAGE;
markDisplayed(feed);
if (key.startsWith('source:') && items[0]) {
sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source };
@@ -499,8 +503,15 @@
// --- 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 === 'browse') loadView('browse', true); // re-section the feed now
if (selected === 'today') loadToday(true); // re-lead the landing with local
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 clearHome() { setHome(''); }
function dismissHomePrompt() {
@@ -675,6 +686,32 @@
{/if}
{/if}
{#if brief?.items?.length}
{#if homeEditing || (!homeValue && !homePromptDismissed)}
<div class="homecard rise">
{#if !homeValue}<p class="homecopy">Want your good news closer to home?</p>{/if}
<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="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>
{/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}
@@ -715,7 +752,7 @@
<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 selected === 'latest'}
{#if homeEditing || (!homeValue && !homePromptDismissed)}
<div class="homecard rise">
<p class="homecopy">Want good news closer to home?</p>
@@ -971,6 +1008,11 @@
.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; }
.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; }