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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user