diff --git a/frontend/src/app.html b/frontend/src/app.html
index 9204a02..b730922 100644
--- a/frontend/src/app.html
+++ b/frontend/src/app.html
@@ -3,6 +3,7 @@
+
diff --git a/frontend/src/lib/pwa.svelte.js b/frontend/src/lib/pwa.svelte.js
new file mode 100644
index 0000000..cb1b6bc
--- /dev/null
+++ b/frontend/src/lib/pwa.svelte.js
@@ -0,0 +1,42 @@
+// Calm PWA install state. Captures the browser's install prompt (Android/desktop
+// Chrome), detects iOS (which has no prompt — needs the Share → Add to Home Screen
+// hint), and remembers a dismissal so we never nag.
+import { browser } from '$app/environment';
+
+export const pwa = $state({ canInstall: false, isIOS: false, isStandalone: false, dismissed: false });
+let deferred = null;
+
+if (browser) {
+ try {
+ pwa.isStandalone =
+ window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
+ pwa.isIOS = /iphone|ipad|ipod/i.test(window.navigator.userAgent);
+ pwa.dismissed = localStorage.getItem('goodnews:pwa_dismissed') === '1';
+ } catch {
+ /* private mode / unavailable */
+ }
+ window.addEventListener('beforeinstallprompt', (e) => {
+ e.preventDefault();
+ deferred = e;
+ pwa.canInstall = true;
+ });
+ window.addEventListener('appinstalled', () => {
+ pwa.canInstall = false;
+ pwa.isStandalone = true;
+ });
+}
+
+export async function installApp() {
+ if (!deferred) return;
+ deferred.prompt();
+ try { await deferred.userChoice; } catch { /* dismissed */ }
+ deferred = null;
+ pwa.canInstall = false;
+}
+
+export function dismissPwa() {
+ pwa.dismissed = true;
+ if (browser) {
+ try { localStorage.setItem('goodnews:pwa_dismissed', '1'); } catch { /* ignore */ }
+ }
+}
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 984a980..a1f8d96 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -15,6 +15,7 @@
import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
import { trackVisit, track } from '$lib/analytics.js';
+ import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
let moods = $state([]);
let topics = $state([]);
@@ -75,6 +76,25 @@
else showSignIn = true;
}
+ // "Since you last visited" — a calm welcome-back cue on Highlights only. Read
+ // the previous visit time, ask how many new calm reads arrived, then stamp now.
+ const LAST_SEEN_KEY = 'goodnews:last_seen';
+ let sinceCount = $state(0);
+ let sinceItems = $state([]);
+ let sinceOpen = $state(false);
+ let sinceDismissed = $state(false);
+ async function checkSince() {
+ let prev = null;
+ try { prev = localStorage.getItem(LAST_SEEN_KEY); localStorage.setItem(LAST_SEEN_KEY, new Date().toISOString()); }
+ catch { return; }
+ if (!prev) return; // first visit on this device → no note
+ try {
+ const q = P.param(prefs.data);
+ const r = await getJSON(`/api/since?ts=${encodeURIComponent(prev)}${q ? '&' + q : ''}`);
+ if (r.count > 0) { sinceCount = r.count; sinceItems = r.items; }
+ } catch { /* quiet */ }
+ }
+
// React to sign-in only (untrack the body so browsing doesn't retrigger it).
$effect(() => {
const u = auth.user;
@@ -421,6 +441,7 @@
error = 'Could not reach Upbeat Bytes.';
}
loading = false;
+ checkSince(); // after the first paint; non-blocking
});
@@ -467,6 +488,25 @@
{#if selected === 'today'}
+ {#if sinceCount > 0 && !sinceDismissed}
+
+
+ Since you were last here, {sinceCount} new calm read{sinceCount === 1 ? '' : 's'} came in.
+ {#if !sinceOpen}{/if}
+
+
+
+ {#if sinceOpen && sinceItems.length}
+
+ New since your last visit
+
+ {#each sinceItems as a (a.id)}
+
drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
+ {/each}
+
+
+ {/if}
+ {/if}
{#if brief?.items?.length}
drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} onimageerror={heroImageFailed} />
@@ -538,6 +578,20 @@
{/if}
+
+ {#if !pwa.isStandalone && !pwa.dismissed && (pwa.canInstall || pwa.isIOS)}
+
+ {/if}
{/if}
@@ -613,6 +667,37 @@
}
.endcap .digestcta:hover { background: var(--accent-deep); }
.endcap .digestcta:disabled { opacity: 0.6; cursor: default; }
+
+ /* "Since you last visited" — a calm welcome-back cue on Highlights */
+ .welcomeback {
+ display: flex; align-items: center; gap: 12px; justify-content: space-between;
+ background: var(--accent-soft); border: 1px solid var(--accent-soft); color: var(--accent-deep);
+ border-radius: 14px; padding: 12px 16px; margin: 4px 0 18px;
+ }
+ .welcomeback .wb-text { margin: 0; font-size: 0.95rem; }
+ .wb-cta { background: none; border: none; color: var(--accent-deep); font: inherit; font-weight: 600;
+ cursor: pointer; text-decoration: underline; margin-left: 6px; padding: 0; }
+ .wb-x { background: none; border: none; color: var(--accent-deep); font-size: 1.3rem; line-height: 1;
+ cursor: pointer; padding: 0 4px; opacity: 0.7; flex-shrink: 0; }
+ .wb-x:hover { opacity: 1; }
+ .sincesec { margin: 0 0 26px; }
+ .since-h { font-size: 1.1rem; margin: 0 0 12px; color: var(--ink); }
+
+ /* PWA install banner — gentle, dismissible, never nagging */
+ .install {
+ display: flex; align-items: center; gap: 14px; justify-content: space-between; flex-wrap: wrap;
+ background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
+ padding: 16px 20px; margin: 28px 0 0;
+ }
+ .install-text { font-size: 0.92rem; color: var(--ink); line-height: 1.5; }
+ .install-text strong { display: block; margin-bottom: 2px; }
+ .ios-share { color: var(--accent-deep); font-weight: 600; }
+ .install-actions { display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
+ .install-go { background: var(--accent); color: #fff; border: none; border-radius: 999px;
+ padding: 9px 20px; font: inherit; font-weight: 600; cursor: pointer; }
+ .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); }
.loadmore { display: flex; justify-content: center; margin: 30px 0 6px; }
.loadmore button {
background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
diff --git a/frontend/src/service-worker.js b/frontend/src/service-worker.js
new file mode 100644
index 0000000..0a5f17f
--- /dev/null
+++ b/frontend/src/service-worker.js
@@ -0,0 +1,40 @@
+///
+// Minimal, calm service worker: precache the app shell + static assets so the
+// site is installable (PWA) and the shell opens fast / works offline. Live data
+// (the API and server-rendered /a/ pages) is always fetched fresh, never staled.
+import { build, files, version } from '$service-worker';
+
+const CACHE = `upbeat-${version}`;
+const SHELL = [...build, ...files];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
+ .then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ if (request.method !== 'GET') return;
+ const url = new URL(request.url);
+ if (url.origin !== location.origin) return;
+
+ // Always-fresh, never cached: the API and the server-rendered article pages.
+ if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/a/')) return;
+
+ // Navigations: serve the cached app shell when offline (SPA fallback).
+ if (request.mode === 'navigate') {
+ event.respondWith(fetch(request).catch(() => caches.match('/') || caches.match('/index.html')));
+ return;
+ }
+
+ // Static assets (JS/CSS/icons/fonts): cache-first, fall back to network.
+ event.respondWith(caches.match(request).then((cached) => cached || fetch(request)));
+});
diff --git a/frontend/static/icon-192.png b/frontend/static/icon-192.png
new file mode 100644
index 0000000..0e8db96
Binary files /dev/null and b/frontend/static/icon-192.png differ
diff --git a/frontend/static/icon-512.png b/frontend/static/icon-512.png
new file mode 100644
index 0000000..129c80a
Binary files /dev/null and b/frontend/static/icon-512.png differ
diff --git a/frontend/static/icon-maskable-512.png b/frontend/static/icon-maskable-512.png
new file mode 100644
index 0000000..f912ea5
Binary files /dev/null and b/frontend/static/icon-maskable-512.png differ
diff --git a/frontend/static/manifest.webmanifest b/frontend/static/manifest.webmanifest
index 3d7e25f..e25f85e 100644
--- a/frontend/static/manifest.webmanifest
+++ b/frontend/static/manifest.webmanifest
@@ -5,9 +5,12 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
- "background_color": "#faf6ee",
- "theme_color": "#2f7d5b",
+ "background_color": "#f7f4ec",
+ "theme_color": "#0083ad",
"icons": [
- { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+ { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
+ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
]
}
diff --git a/goodnews/api.py b/goodnews/api.py
index d588e0c..f946018 100644
--- a/goodnews/api.py
+++ b/goodnews/api.py
@@ -1422,6 +1422,27 @@ def create_app() -> FastAPI:
items=[Article.from_row(r) for r in rows],
)
+ @app.get("/api/since", response_model=FeedResponse)
+ def feed_since(ts: str = Query(...), prefs: str | None = Query(None)) -> FeedResponse:
+ # A calm welcome-back cue: accepted/non-dup/visible articles discovered
+ # since the reader's last visit (boundary-respecting). count = how many;
+ # items = a few to show inline. No nagging, no unread state stored.
+ try:
+ norm = ts.replace("Z", "+00:00")
+ dt = datetime.fromisoformat(norm)
+ since = (dt.astimezone(timezone.utc) if dt.tzinfo else dt).strftime("%Y-%m-%d %H:%M:%S")
+ except (ValueError, TypeError):
+ return FeedResponse(topic=None, flavor=None, count=0, items=[])
+ fp = prefs_from_json(prefs)
+ now = datetime.now(timezone.utc)
+ kw = _prefs_sql_kw(fp, now)
+ with get_conn() as conn:
+ rows = queries.feed(conn, sort="latest", since=since, limit=60, **kw)
+ if fp.avoid_terms:
+ rows = filter_articles(rows, fp, now)
+ rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
+ return FeedResponse(topic=None, flavor=None, count=len(rows), items=[Article.from_row(r) for r in rows[:8]])
+
@app.get("/api/brief", response_model=BriefResponse)
def brief(
date: str | None = Query(None),
diff --git a/goodnews/queries.py b/goodnews/queries.py
index d2715b2..9b2b765 100644
--- a/goodnews/queries.py
+++ b/goodnews/queries.py
@@ -64,6 +64,7 @@ def feed(
sort: str = "ranked",
follow_sources: list[int] | None = None,
follow_tags: list[str] | None = None,
+ since: str | None = None,
) -> list[dict]:
"""Return articles with categorical filters applied in SQL.
@@ -116,6 +117,11 @@ def feed(
clauses.append("a.source_id = ?")
params.append(source_id)
+ if since:
+ # "New since last visit": articles discovered after the reader's last visit.
+ clauses.append("a.discovered_at > ?")
+ params.append(since)
+
# "Following" feed: articles from a followed source OR carrying a followed tag.
# Passing either list (even empty) switches to following mode; no follows → none.
if follow_sources is not None or follow_tags is not None:
diff --git a/tests/test_admin.py b/tests/test_admin.py
index ef049a1..3684419 100644
--- a/tests/test_admin.py
+++ b/tests/test_admin.py
@@ -265,3 +265,19 @@ def test_follows_and_following_feed(tmp_path, monkeypatch):
assert TestClient(app).get("/api/feed?following=true").json()["count"] == 0
assert TestClient(app).get("/api/follows").status_code == 401
assert tc.post("/api/follows", json={"kind": "source", "value": "999"}).status_code == 404
+
+
+def test_since_endpoint(tmp_path, monkeypatch):
+ import os, sqlite3
+ app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
+ c = sqlite3.connect(os.environ["GOODNEWS_DB"])
+ for aid, when in [(2, "2020-01-01 00:00:00"), (3, "2030-01-01 00:00:00")]:
+ c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,discovered_at) VALUES (?,1,?,?,?,?)",
+ (aid, f"http://s/{aid}", f"t{aid}", f"h{aid}", when))
+ c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (?,1)", (aid,))
+ c.commit(); c.close()
+ tc = TestClient(app)
+ r = tc.get("/api/since?ts=2027-01-01T00:00:00Z").json()
+ assert r["count"] == 1 and [i["id"] for i in r["items"]] == [3] # only the post-2027 article
+ assert tc.get("/api/since?ts=2099-01-01T00:00:00Z").json()["count"] == 0 # nothing newer
+ assert tc.get("/api/since?ts=not-a-date").json()["count"] == 0 # invalid ts → quiet 0