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