clientRank already existed specifically to stop stale high-scorers from
leading the feed, but computed age from updatedAt — the same timestamp
that keeps resetting whenever the backend attaches any article to a
cluster (BACKLOG #15). A fossil's age always read as ~0, silently
defeating the whole mechanism. Fixed to use firstSeenAt.
clientRank's decay is soft (max 20% penalty) and isn't always enough
against a high signalScore alone, so added StorySummary.isFossil (>5
days old by firstSeenAt) as a hard backstop: mainStories and
makeDigest now sink fossils below current stories, so one can never
take the hero/lead slot regardless of score. Still shown in the feed,
just not in the most prominent position.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Old stories were reading "1 hr ago" because updatedAt bumps every time
the backend attaches another article to a cluster, however loosely
related. firstSeenAt is when the cluster actually first appeared and
is already in the API response — just wasn't decoded.
Verified live: story_1a37c858235b (F1 Austria GP recap) has
firstSeenAt 2026-06-16 but updatedAt 2026-07-11, freshnessScore 0.96 —
looked brand new despite being 25 days old. Also confirmed the API's
isStale field is unusable as a stopgap (always false regardless of age).
Ranking still surfaces old stories at the top — that's server-side
(signalScore/freshnessScore) and out of scope for the client per the
architecture rule against re-ranking. Logged in BACKLOG #15.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CI caught this: the sync-lag fix referenced a nonexistent SwiftUI
environment value. The container is reached through the context.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- cacheStories() runs in Task.detached with a background ModelContext
- persistQuickCache() encodes 100 stories off-thread via Task.detached
- loadSectionSupplements() batches 5 parallel fetches into one sectionSupplement write
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
APIClient:
- markAllRead(storyIds💊) — POST /me/read/bulk with device ID, timestamp,
source label, and pill name so backend can record context
Models:
- PaginatedStories gains unreadCount, readSuppressedCount, lastReadSyncAt,
activePill — backend now returns these so the UI can report honest state
SignalFeedView:
- Header "…" menu replaces the standalone settings button; menu contains
"Mark all as read" (disabled while pending or when nothing is unread) and
a Settings item
- markAllRead() captures all visible unread IDs, optimistically inserts
ReadStory for each, shows toast ("Marked N stories as read"), then fires
the bulk API; on failure deletes the optimistic ReadStory records and
shows an honest error toast ("Sync failed. Stories may reappear until retry.")
- Toast auto-dismisses after 3.5s, appears at the bottom of the ZStack
with a slide-up + opacity animation
Cross-pill suppression was already in place — backend applies
unread_story_condition() to every /stories request regardless of tags,
so stories marked read in All disappear from Tech, Sport, etc. on the
next pill fetch.
Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
- APIClient: generate and persist a stable device UUID (jarvis.device.id
in UserDefaults); attach as X-Jarvis-Device-Id header on every request
- Add markStoryRead / markStoryUnread — POST/DELETE /stories/:id/read with
device_id, read_at, and source fields
- Wire both calls into all three read-toggle sites: SignalFeedView,
ArticleReaderView, StoryDetailView
- Add postVoid helper for fire-and-forget POST calls (no response body needed)
- Pass include_read param on stories fetch for future server-side filtering
- CacheMaintenance: recentHours 84→72 (mirrors backend 72h story window);
markerHours 96→192 so read suppression survives a full weekly news cycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- East Africa pill: merges Uganda + east-africa tags; Uganda pinned first via sub-sections
- Southern Africa pill: covers SA, Namibia, Botswana, Zimbabwe, Zambia, Mozambique; SA/Namibia/Botswana prioritized
- StoryStore.setPill now resets lastSyncedAt to bypass 30s rate-limit on filter switch
- sectionSupplement background fetch populates sparse regional sections in All digest
- SignalFeedView digestContent: flat hero+scroll path for pills without subSections (was showing max 5)
- Theme: removed standalone Uganda pill, wired new sub-section layouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Offline caching:
- BackgroundRefreshManager.preCacheArticles() fetches top article per story
(score ≥60, up to 20 stories) and stores as CachedArticle in SwiftData
- Runs after every BG fetch AND after every foreground sync via StoryStore
- Articles are available in ArticleReaderView offline without ever opening them
Pull-to-refresh reshuffle when nothing new:
- loadStories(refresh:) now returns newCount (genuinely new story IDs)
- If server returns 0 new stories, feed merges in any BG-cached stories not
already in the live feed, then re-sorts by signal score (reshuffle)
- seenSnapshot is always cleared so everything re-ranks fresh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Removed AI, Cloud, HomeLab as top-level pills
- Tech pill now matches all their slugs (ai, ml, cloud, homelab, security, dev)
- Tech view shows a sectioned front page: AI · Cloud · HomeLab · Security · Dev · Technology
- Sub-sections defined via NewsSection.techSubSections + StoryPill.subSections
- digest() extracted to makeDigest(sections:) so any pill can have sections
- Tech auto-loads 100 stories to fill its sub-sections (same as All)
- "All" Tech section in the main feed now links to Tech pill
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Every pill (F1, Tech, AI, Cloud, HomeLab…) now shows the same hero card
layout as "All" — first story is the big featured card, rest follow as rows
- "All" retains its full multi-section front page (US / Tech / AI / Sport…)
- Topic pill headers use the pill name instead of "TOP STORIES"
- filteredStories now falls back to cached SwiftData rows while loading, so
switching to F1 or Tech shows cached cards instantly instead of a blank list
- Logo splash screen (3 pill bars + jarvis wordmark) shows on launch and fades
out after setup completes; minimum 1.3 s so it's always readable
- JarvisWordmark moved to Theme.swift so SplashView and feed can share it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Mark-read now immediately expands the READ shelf so the story is visible
- Orange signal stripe is fully removed (not dimmed) when isRead=true
- Pull-to-refresh clears seen snapshot → full reshuffle by signal score
- BackgroundRefreshManager fires a local notification for the top new
high-signal story after each cache refresh; Tech/AI/HomeLab stories
get their category in the notification title (≥65 signal threshold)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reading experience:
- Body text #CECECE (dark) / #1A1916 (light) — was near-invisible #4A4A4A
- 30pt heavy headline, 17pt body at 8.5pt line-spacing
- Skeleton pulse loading state replaces dark spinner box
- Hero image fades in on load (0.3s ease-in)
- Orange reading progress bar (2pt) below nav bar
- Max reading width 680pt for iPad legibility
Appearance:
- AppearanceMode enum (system/light/dark) in Theme.swift
- @AppStorage("appearanceMode") applied once in JarvisApp root
- Appearance picker (Picker in Menu) in reader toolbar
- Removed .preferredColorScheme(.dark) from all 12 child views
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Switches from .system(.heavy) to Courier New Bold — classic typewriter look
- 'j' is now lowercase (was 'J'), still in KisaniOrange
- 'i' renders in white with an orange Circle overlaid at the tittle position,
so the dot reads orange against the black background
- Implemented as HStack of individual Text segments so each character can
be independently coloured; 'i' uses .overlay(alignment: .top) with a
size-proportional Circle offset to sit at glyph-tittle height
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Render the #1 Top Story as an Apple-News-style hero card (flat surface, 4pt
stripe, 24pt headline, summary, sources, prominent score). Prettify category
labels (slug -> Title Case, with AI/US/Formula 1 special-cases) across rows,
hero, and detail. Per Emil's framework, no row entrance/press motion on a
constantly-scrolled surface — invest in hierarchy and defaults instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Instead of one long firehose, "All" now renders Apple/Google-News-style:
a Top Stories lead block, then category sections (World, Politics, Business,
AI, Technology, Africa, Sport, Science, Entertainment, Health, Lifestyle) with
the top few stories each and a "See all" jump to the matching pill. Sections are
built from backend tags[]; specific pills keep the flat list. "All" preloads up
to ~100 stories to fill sections.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "All" feed stuttered because every row appearing ran markSeen, which did a
disk save(), an O(n) contains over a @Query'd SeenStory array, and triggered a
full feed re-render on each insert. Now markSeen does an O(1) insert into a
non-observed reference Set, no per-row save (the container autosaves), and
SeenStory is no longer @Query'd by the view. cacheStories also does one fetch +
an in-memory index instead of a query per story.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CacheMaintenance prunes the SwiftData caches so they don't grow forever:
seen markers >30d, read >90d, cached articles >30d, cached stories >14d —
always protecting saved bookmarks. Runs on launch. Settings gains a
"Clear offline cache" action (keeps saved) with a count + confirmation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add tags[] to CachedStory (defaulted for lightweight migration) and persist
them on upsert; offline StorySummary now carries the cached tags instead of
falling back to topic. Backend stays the source of truth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the keyword/source-majority Interest system with a StoryPill enum that
matches stories by canonical category slugs. StorySummary gains a tolerant
tags[] (optional, decode-safe); effectiveTags uses tags, falling back to topic
during the backend transition. No more headline keyword-searching on the client —
the backend decides classification; the client asks slug membership.
Pills: All, F1, Sport, AI, Cloud, HomeLab, Tech, Uganda, South Africa, Canada, US.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New Sport pill: detect sports by clear headline terms or majority
sports-outlet sources, and exclude sports (and F1) from All/Tech/regions so
World Cup/soccer no longer pollutes Finance/Tech.
- New client keyword pills: Canada, US, AI, Cloud, HomeLab (headline-matched).
- Remove the Finance pill; keep Tech (backend topic).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Left swipe: short reveals Save (tap), full swipe marks read. Right swipe
still shares.
- Read stories leave the main feed and collapse into a "Read" shelf at the
bottom (collapsed by default), shown as slim one-line rows; swipe to mark
unread. An "all caught up" state shows when everything loaded is read.
- StoryRowView gains a compact presentation for the shelf.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Sink already-seen and read stories to the bottom of the feed (new SeenStory
model + per-load snapshot so order is stable while scrolling and refreshes
sink what you've seen) so fresh content surfaces.
- F1 matching: clear F1 headline OR a majority of F1-feed sources, replacing the
broad keyword list that leaked crypto in (fia -> "deFIAnt"). Dropped unused
matchSources path.
- Remove the East Africa pill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Swipe left bookmarks a story (toggle) into the Saved tab; swipe right opens
the iOS share sheet and marks the story read.
- New SavedStory SwiftData model; Saved tab now lists explicit bookmarks
instead of the whole offline cache.
- Convert the feed to a styled List (native swipeActions require it), keeping
the flat look, pull-to-refresh, and infinite scroll. Add a ShareSheet wrapper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Show a 6-line cross-source summary preview in each feed row.
- Pinned, mutually-exclusive filter pills: All, F1, Uganda, East Africa,
South Africa, Finance, Tech. "All" is news-only (F1 lives only under F1);
regions don't overlap (East Africa excludes Uganda & South Africa).
- Categorization correctness: regions match the headline only (the backend's
summaries/outlets are polluted by over-clustering), and F1 takes precedence
over every other pill since the backend mis-tags F1 as tech/finance/politics.
- Finance/Tech match the backend topic; sparse filters auto-pull more pages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Equal-score stories reshuffled on every pull-to-refresh because the sort
was unstable. Add StorySummary.feedOrder (signalScore desc, then id desc,
mirroring the backend's signal_score DESC, id DESC) and use it for the feed,
the WebSocket re-sort, and the Saved/Search tabs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Don't blank the feed on refresh: keep current stories until new data
arrives, and ignore benign URLError.cancelled (-999) so a network blip
no longer wipes the list. Coalesce WebSocket story.created bursts into a
single debounced refresh.
- Empty state now distinguishes polling, couldn't-reach-server (with the
error + host + Retry), offline, and genuinely-empty.
- Track read stories in SwiftData (ReadStory); mark a story read when opened
and drop/dim its signal stripe across the feed and Latest/Saved/Search.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Settings hub (gear → SettingsView) hosting Notifications and Connectivity
(Connectivity previously had no entry point).
- NotificationsView: permission flow, breaking/new-story alerts with a
min-signal threshold, morning & evening briefings with time pickers, and a
weather toggle + location, plus a "send test briefing" action.
- NotificationManager: schedules daily briefings, builds "Jarvis" content
(weather + top signal stories), fires breaking alerts off WebSocket
story.created events, and refreshes via a BGAppRefresh task.
- WeatherService: Open-Meteo geocoding + daily forecast (no API key/entitlement).
- AppDelegate registers the background-refresh handler at launch; Info.plist
gains UIBackgroundModes + BGTaskSchedulerPermittedIdentifiers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed,
story detail, article reader, feed manager, and LAN⇄Tailscale connectivity.
Project generated from project.yml via XcodeGen.
Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates,
backlog, and API/backend handoff docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>