"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
+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);