"Since you last visited" cue + PWA install (add to home screen)

Two calm returning-reader features.

Since-last-visit (Highlights companion, not a nav lane — per Codex):
* queries.feed gains a `since` filter; GET /api/since?ts= returns the count +
  a few accepted/non-dup/visible articles discovered since the reader's last
  visit (boundary-respecting; invalid/future ts → 0, no error).
* Home stores last_seen in localStorage (reads prev, then stamps now); on
  Highlights, a gentle "Since you were last here, N new calm reads came in"
  note with a "See what's new" reveal of a compact inline section. Dismissible.
  No badges, no unread counts, no "missed" language.

PWA:
* Real PNG icons (192/512 + full-bleed maskable) rasterized from favicon.svg;
  manifest fixed (azure theme to match the brand, PNG icons); apple-touch-icon.
* Minimal service worker: precache the app shell, always-fresh API + /a/ pages.
* Gentle, dismissible install banner (beforeinstallprompt → Install; iOS → the
  Share → Add to Home Screen hint). Never nags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-09 20:38:12 -04:00
parent 008364e922
commit d0fb153e46
11 changed files with 217 additions and 3 deletions
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon-192.png" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0083ad" />
+42
View File
@@ -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 */ }
}
}
+85
View File
@@ -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
});
</script>
@@ -467,6 +488,25 @@
</header>
{#if selected === 'today'}
{#if sinceCount > 0 && !sinceDismissed}
<div class="welcomeback rise">
<p class="wb-text">
Since you were last here, {sinceCount} new calm read{sinceCount === 1 ? '' : 's'} came in.
{#if !sinceOpen}<button class="wb-cta" onclick={() => (sinceOpen = true)}>See whats new</button>{/if}
</p>
<button class="wb-x" onclick={() => (sinceDismissed = true)} aria-label="Dismiss">×</button>
</div>
{#if sinceOpen && sinceItems.length}
<section class="rise sincesec">
<h2 class="since-h">New since your last visit</h2>
<div class="grid">
{#each sinceItems as a (a.id)}
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
{/each}
</div>
</section>
{/if}
{/if}
{#if brief?.items?.length}
<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} />
@@ -538,6 +578,20 @@
</div>
</section>
{/if}
{#if !pwa.isStandalone && !pwa.dismissed && (pwa.canInstall || pwa.isIOS)}
<aside class="install rise">
<div class="install-text">
<strong>Keep Upbeat Bytes a tap away.</strong>
{#if pwa.canInstall}Add it to your home screen — it opens like an app, no store needed.
{:else}On iPhone: tap the <span class="ios-share">Share</span> button, then “Add to Home Screen.”{/if}
</div>
<div class="install-actions">
{#if pwa.canInstall}<button class="install-go" onclick={installApp}>Install</button>{/if}
<button class="install-x" onclick={dismissPwa}>Not now</button>
</div>
</aside>
{/if}
{/if}
</main>
@@ -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);
+40
View File
@@ -0,0 +1,40 @@
/// <reference types="@sveltejs/kit" />
// 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)));
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+6 -3
View File
@@ -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" }
]
}
+21
View File
@@ -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),
+6
View File
@@ -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:
+16
View File
@@ -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