"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:
@@ -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" />
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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 what’s 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);
|
||||
|
||||
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user