news behavior split: /news leads with Latest, Highlights via ?view=highlights
Base-aware so the frozen `/` is untouched (it's still the live, indexed site until cutover); the new behavior applies only to /news. At cutover `/` becomes the hub and only /news's behavior remains. - defaultView(base): /news bare → Latest (the live firehose); `/` bare → Highlights. - Brief is canonically /news?view=highlights, with ?view=today kept as an alias. - Latest is pure chronological on /news — stop passing `home` into it (geo scope belongs to Highlights). The Closer-to-Home card/dial is hidden on /news Latest; Highlights keeps the scope dial. `/`'s Latest keeps geo (frozen). - Back fixed: on /news it shows only for genuine drill-ins (tag/source/search), not the top-level lanes (Latest/Highlights/Following); `/` keeps its old rule. - goBack's app-safe fallback lands on the base's default view. feednav.js gains defaultView + def-aware parse/build; 36 frontend tests (9 new), build clean. /news stays noindex. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@
|
|||||||
import { track } from '$lib/analytics.js';
|
import { track } from '$lib/analytics.js';
|
||||||
import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
|
import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
|
||||||
import { ritualState, markBriefSeen } from '$lib/ritual.js';
|
import { ritualState, markBriefSeen } from '$lib/ritual.js';
|
||||||
import { feedBase, parseView, viewUrl } from '$lib/feednav.js';
|
import { feedBase, defaultView, parseView, viewUrl } from '$lib/feednav.js';
|
||||||
|
|
||||||
// Which top bar to wear: 'legacy' = the feed's own Header (the interim `/` mount, kept
|
// Which top bar to wear: 'legacy' = the feed's own Header (the interim `/` mount, kept
|
||||||
// unchanged), 'hub' = the shared editorial HubBar (the /news mount, part of the new family).
|
// unchanged), 'hub' = the shared editorial HubBar (the /news mount, part of the new family).
|
||||||
@@ -36,8 +36,18 @@
|
|||||||
// so `/` behaves exactly as today and `/news` is self-coherent. At cutover, `/` becomes
|
// so `/` behaves exactly as today and `/news` is self-coherent. At cutover, `/` becomes
|
||||||
// the hub and only `/news` renders the feed.
|
// the hub and only `/news` renders the feed.
|
||||||
function base() { return feedBase($page.url.pathname); }
|
function base() { return feedBase($page.url.pathname); }
|
||||||
function urlForView(key) { return viewUrl(base(), key); }
|
function urlForView(key) { return viewUrl(base(), key, defaultView(base())); }
|
||||||
let selected = $derived(parseView($page.url));
|
let selected = $derived(parseView($page.url, defaultView(feedBase($page.url.pathname))));
|
||||||
|
// Latest is local-first only on the frozen `/`; on /news it's the pure chronological
|
||||||
|
// firehose — the geo scope dial belongs to Highlights, not Latest (Codex).
|
||||||
|
let latestGeo = $derived(chrome !== 'hub');
|
||||||
|
// Back is for genuine drill-ins. On /news suppress it for the top-level lanes (Latest is
|
||||||
|
// the default there); on the frozen `/` keep the original "anything but Highlights" rule.
|
||||||
|
let showBack = $derived(
|
||||||
|
chrome === 'hub'
|
||||||
|
? (selected.startsWith('tag:') || selected.startsWith('source:') || selected === 'search')
|
||||||
|
: selected !== 'today'
|
||||||
|
);
|
||||||
let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim());
|
let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim());
|
||||||
let searchOpen = $state(false);
|
let searchOpen = $state(false);
|
||||||
let searchText = $state('');
|
let searchText = $state('');
|
||||||
@@ -69,7 +79,7 @@
|
|||||||
// Scope dial: the reader's "emotional radius" — nearby | region | country | world.
|
// Scope dial: the reader's "emotional radius" — nearby | region | country | world.
|
||||||
// Persisted; default nearby. 'world' = the global brief/feed (no geo lead).
|
// Persisted; default nearby. 'world' = the global brief/feed (no geo lead).
|
||||||
let homeScope = $state('nearby');
|
let homeScope = $state('nearby');
|
||||||
const homeActive = () => selected === 'latest' && !!homeValue;
|
const homeActive = () => latestGeo && selected === 'latest' && !!homeValue;
|
||||||
let showSignIn = $state(false);
|
let showSignIn = $state(false);
|
||||||
let showSaved = $state(false); // Saved flyout
|
let showSaved = $state(false); // Saved flyout
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -299,8 +309,8 @@
|
|||||||
}
|
}
|
||||||
if (key === 'latest') {
|
if (key === 'latest') {
|
||||||
const q = P.param(prefs.data);
|
const q = P.param(prefs.data);
|
||||||
// Closer to Home lives on the all-news browse lane (Latest).
|
// Closer to Home lives on Highlights now; Latest stays pure chronological on /news.
|
||||||
const homeq = homeValue ? `&home=${encodeURIComponent(homeValue)}` : '';
|
const homeq = latestGeo && homeValue ? `&home=${encodeURIComponent(homeValue)}` : '';
|
||||||
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${homeq}${q ? '&' + q : ''}${exq}`;
|
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${homeq}${q ? '&' + q : ''}${exq}`;
|
||||||
}
|
}
|
||||||
if (key === 'following') {
|
if (key === 'following') {
|
||||||
@@ -338,7 +348,7 @@
|
|||||||
let appNavDepth = 0;
|
let appNavDepth = 0;
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (appNavDepth > 0 && typeof history !== 'undefined') history.back();
|
if (appNavDepth > 0 && typeof history !== 'undefined') history.back();
|
||||||
else goto(urlForView('today')); // landed here directly → app-safe Highlights
|
else goto(urlForView(defaultView(base()))); // landed directly → the base's default view
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the data for a view. Called by afterNavigate (URL-driven: in-app goto,
|
// Load the data for a view. Called by afterNavigate (URL-driven: in-app goto,
|
||||||
@@ -359,7 +369,7 @@
|
|||||||
feed = items;
|
feed = items;
|
||||||
feedNextOffset = resp.next_offset ?? null;
|
feedNextOffset = resp.next_offset ?? null;
|
||||||
// Home lane pages by the API's world cursor; other lanes by simple length.
|
// Home lane pages by the API's world cursor; other lanes by simple length.
|
||||||
feedDone = (key === 'latest' && homeValue) ? feedNextOffset == null : items.length < PAGE;
|
feedDone = (key === 'latest' && latestGeo && homeValue) ? feedNextOffset == null : items.length < PAGE;
|
||||||
markDisplayed(feed);
|
markDisplayed(feed);
|
||||||
if (key.startsWith('source:') && items[0]) {
|
if (key.startsWith('source:') && items[0]) {
|
||||||
sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source };
|
sourceNames = { ...sourceNames, [key.slice(7)]: items[0].source };
|
||||||
@@ -687,11 +697,7 @@
|
|||||||
{isFollowing(followTarget.kind, followTarget.value) ? '✓ Following' : 'Follow ' + followTarget.noun}
|
{isFollowing(followTarget.kind, followTarget.value) ? '✓ Following' : 'Follow ' + followTarget.noun}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- TODO(latest-default): once bare /news becomes Latest, suppress Back for BOTH
|
{#if showBack}
|
||||||
'latest' and 'today'; show it only for genuine drill-ins (tag/source/search),
|
|
||||||
or Back lands on the top-level Latest page. Don't change this until then —
|
|
||||||
it would alter the frozen `/` feed. -->
|
|
||||||
{#if selected !== 'today'}
|
|
||||||
<button class="viewback" onclick={goBack} aria-label="Go back">
|
<button class="viewback" onclick={goBack} aria-label="Go back">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
Back
|
Back
|
||||||
@@ -807,7 +813,7 @@
|
|||||||
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
|
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if feed.length}
|
{:else if feed.length}
|
||||||
{#if selected === 'latest'}
|
{#if selected === 'latest' && latestGeo}
|
||||||
{#if homeEditing || (!homeValue && !homePromptDismissed)}
|
{#if homeEditing || (!homeValue && !homePromptDismissed)}
|
||||||
<div class="homecard rise">
|
<div class="homecard rise">
|
||||||
<p class="homecopy">Want good news closer to home?</p>
|
<p class="homecopy">Want good news closer to home?</p>
|
||||||
|
|||||||
@@ -8,20 +8,32 @@ export function feedBase(pathname) {
|
|||||||
return (pathname || '').startsWith('/news') ? '/news' : '/';
|
return (pathname || '').startsWith('/news') ? '/news' : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The default (bare-path) view per base: `/news` leads with Latest — the live, "as it
|
||||||
|
// comes in" firehose — while the interim `/` keeps leading with Highlights (frozen). At
|
||||||
|
// cutover `/` becomes the hub, so only the /news default remains.
|
||||||
|
export function defaultView(base) {
|
||||||
|
return base === '/news' ? 'latest' : 'today';
|
||||||
|
}
|
||||||
|
|
||||||
// The current view key, derived from query params only (path-agnostic, so a deep link
|
// The current view key, derived from query params only (path-agnostic, so a deep link
|
||||||
// works at either base): search > source > tag > explicit view > 'today' (Highlights).
|
// works at either base): search > source > tag > explicit view > the base's default.
|
||||||
export function parseView(url) {
|
// 'highlights' is the canonical param for the Brief; 'today' stays accepted as an alias.
|
||||||
|
export function parseView(url, def = 'today') {
|
||||||
const p = url.searchParams;
|
const p = url.searchParams;
|
||||||
if ((p.get('q') || '').trim()) return 'search';
|
if ((p.get('q') || '').trim()) return 'search';
|
||||||
if (p.get('source')) return 'source:' + p.get('source');
|
if (p.get('source')) return 'source:' + p.get('source');
|
||||||
if (p.get('tag')) return 'tag:' + p.get('tag');
|
if (p.get('tag')) return 'tag:' + p.get('tag');
|
||||||
return p.get('view') || 'today';
|
const v = p.get('view');
|
||||||
|
if (!v) return def;
|
||||||
|
if (v === 'highlights' || v === 'today') return 'today'; // Brief (alias-tolerant)
|
||||||
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A link to a view at the given base path. 'today' is the bare base; everything else
|
// A link to a view at the given base path. The base's default view is the bare path;
|
||||||
// carries its query param so the same parseView() reads it back identically.
|
// the Brief is canonically `?view=highlights`; everything else carries its own param.
|
||||||
export function viewUrl(base, key) {
|
export function viewUrl(base, key, def = 'today') {
|
||||||
if (key === 'today') return base;
|
if (key === def) return base;
|
||||||
|
if (key === 'today') return base + '?view=highlights';
|
||||||
if (key.startsWith('source:')) return base + '?source=' + encodeURIComponent(key.slice(7));
|
if (key.startsWith('source:')) return base + '?source=' + encodeURIComponent(key.slice(7));
|
||||||
if (key.startsWith('tag:')) return base + '?tag=' + encodeURIComponent(key.slice(4));
|
if (key.startsWith('tag:')) return base + '?tag=' + encodeURIComponent(key.slice(4));
|
||||||
return base + '?view=' + encodeURIComponent(key);
|
return base + '?view=' + encodeURIComponent(key);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { feedBase, parseView, viewUrl } from './feednav.js';
|
import { feedBase, defaultView, parseView, viewUrl } from './feednav.js';
|
||||||
|
|
||||||
const view = (path) => parseView(new URL('https://x' + path));
|
const view = (path, def) => parseView(new URL('https://x' + path), def);
|
||||||
|
|
||||||
describe('feedBase', () => {
|
describe('feedBase', () => {
|
||||||
it('is /news only under the /news path; everything else is /', () => {
|
it('is /news only under the /news path; everything else is /', () => {
|
||||||
expect(feedBase('/news')).toBe('/news');
|
expect(feedBase('/news')).toBe('/news');
|
||||||
expect(feedBase('/news?view=latest')).toBe('/news');
|
expect(feedBase('/news?view=highlights')).toBe('/news');
|
||||||
expect(feedBase('/')).toBe('/');
|
expect(feedBase('/')).toBe('/');
|
||||||
expect(feedBase('/?view=latest')).toBe('/');
|
expect(feedBase('/?view=latest')).toBe('/');
|
||||||
expect(feedBase('')).toBe('/');
|
expect(feedBase('')).toBe('/');
|
||||||
@@ -14,37 +14,55 @@ describe('feedBase', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('defaultView', () => {
|
||||||
|
it('/news leads with Latest; / (frozen) leads with Highlights', () => {
|
||||||
|
expect(defaultView('/news')).toBe('latest');
|
||||||
|
expect(defaultView('/')).toBe('today');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseView', () => {
|
describe('parseView', () => {
|
||||||
it('derives the view from query params, path-agnostic', () => {
|
it('bare path resolves to the base default', () => {
|
||||||
expect(view('/')).toBe('today');
|
expect(view('/')).toBe('today'); // / default (frozen)
|
||||||
expect(view('/news')).toBe('today');
|
expect(view('/news', 'latest')).toBe('latest'); // /news default = Latest
|
||||||
expect(view('/?view=latest')).toBe('latest');
|
});
|
||||||
expect(view('/news?view=following')).toBe('following');
|
it('Brief is reachable via ?view=highlights, with ?view=today as an alias', () => {
|
||||||
|
expect(view('/news?view=highlights', 'latest')).toBe('today');
|
||||||
|
expect(view('/news?view=today', 'latest')).toBe('today'); // alias kept
|
||||||
|
expect(view('/?view=today')).toBe('today');
|
||||||
|
});
|
||||||
|
it('reads the standard views regardless of base', () => {
|
||||||
|
expect(view('/news?view=latest', 'latest')).toBe('latest');
|
||||||
|
expect(view('/?view=following')).toBe('following');
|
||||||
expect(view('/?tag=clean-energy')).toBe('tag:clean-energy');
|
expect(view('/?tag=clean-energy')).toBe('tag:clean-energy');
|
||||||
expect(view('/?source=7')).toBe('source:7');
|
expect(view('/?source=7')).toBe('source:7');
|
||||||
expect(view('/?q=solar')).toBe('search');
|
expect(view('/?q=solar')).toBe('search');
|
||||||
});
|
});
|
||||||
it('prioritizes search > source > tag > view', () => {
|
it('prioritizes search > source > tag > view, ignores whitespace q', () => {
|
||||||
expect(view('/?q=a&source=7&tag=b&view=latest')).toBe('search');
|
expect(view('/?q=a&source=7&tag=b&view=latest')).toBe('search');
|
||||||
expect(view('/?source=7&tag=b&view=latest')).toBe('source:7');
|
expect(view('/?source=7&tag=b&view=latest')).toBe('source:7');
|
||||||
expect(view('/?tag=b&view=latest')).toBe('tag:b');
|
expect(view('/?tag=b&view=latest')).toBe('tag:b');
|
||||||
expect(view('/?q=%20')).toBe('today'); // whitespace-only q is not a search
|
expect(view('/?q=%20')).toBe('today');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('viewUrl', () => {
|
describe('viewUrl', () => {
|
||||||
it('builds base-relative links that parseView reads back identically', () => {
|
it('the base default is the bare path; the Brief is ?view=highlights', () => {
|
||||||
for (const base of ['/', '/news']) {
|
// /news: Latest is the default (bare), Highlights is explicit
|
||||||
expect(viewUrl(base, 'today')).toBe(base);
|
expect(viewUrl('/news', 'latest', 'latest')).toBe('/news');
|
||||||
expect(viewUrl(base, 'latest')).toBe(base + '?view=latest');
|
expect(viewUrl('/news', 'today', 'latest')).toBe('/news?view=highlights');
|
||||||
expect(viewUrl(base, 'tag:clean-energy')).toBe(base + '?tag=clean-energy');
|
expect(viewUrl('/news', 'following', 'latest')).toBe('/news?view=following');
|
||||||
expect(viewUrl(base, 'source:7')).toBe(base + '?source=7');
|
// / (frozen): Highlights is the default (bare), Latest is explicit
|
||||||
// round-trip: the generated URL resolves back to the same view key
|
expect(viewUrl('/', 'today', 'today')).toBe('/');
|
||||||
expect(view(viewUrl(base, 'latest').replace(base, '/'))).toBe('latest');
|
expect(viewUrl('/', 'latest', 'today')).toBe('/?view=latest');
|
||||||
}
|
});
|
||||||
|
it('round-trips: a generated link parses back to the same view', () => {
|
||||||
|
expect(view(viewUrl('/news', 'today', 'latest'), 'latest')).toBe('today');
|
||||||
|
expect(view(viewUrl('/news', 'latest', 'latest'), 'latest')).toBe('latest');
|
||||||
|
expect(view(viewUrl('/', 'latest', 'today').replace('/', '/'), 'today')).toBe('latest');
|
||||||
});
|
});
|
||||||
it('encodes tag/source/view values', () => {
|
it('encodes tag/source/view values', () => {
|
||||||
expect(viewUrl('/news', 'tag:good news')).toBe('/news?tag=good%20news');
|
expect(viewUrl('/news', 'tag:good news', 'latest')).toBe('/news?tag=good%20news');
|
||||||
expect(viewUrl('/', 'view=a&b')).toBe('/?view=view%3Da%26b');
|
expect(viewUrl('/', 'source:42', 'today')).toBe('/?source=42');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user