"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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user