commit 27d0e2276783cba028ad26925e781525fb936411 Author: Robin Kutesa Date: Thu Jun 18 18:04:59 2026 +0300 Initial commit: Jarvis iOS app 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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b2e96c3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,20 @@ +--- +name: Bug report +about: Something in the app misbehaves +title: "[Bug] " +labels: bug +--- + +## What happened + +## Expected + +## Steps to reproduce +1. + +## Environment +- Device / simulator: +- iOS version: +- Backend host / version (`/api/v1/health`): + +## Screenshots / logs diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..41ce8e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,16 @@ +--- +name: Feature request +about: Propose a change or addition +title: "[Feature] " +labels: enhancement +--- + +## Problem / motivation + +## Proposed solution + +## Client / backend impact +- [ ] iOS client change +- [ ] Backend contract change (coordinate via `API/contract.md`) + +## Notes diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..358afab --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Summary + +## Changes +- + +## Testing +- [ ] `xcodegen generate && xcodebuild ... build` passes +- [ ] Verified on simulator against a live/mock backend + +## Related issues +Closes # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c406dda --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build · ${{ matrix.os }} · ${{ matrix.configuration }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ macos-14, macos-15 ] + configuration: [ Debug, Release ] + + steps: + - uses: actions/checkout@v4 + + - name: Xcode version + run: xcodebuild -version + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate + + - name: Build (${{ matrix.configuration }}, iOS Simulator) + run: | + set -o pipefail + xcodebuild \ + -project Jarvis.xcodeproj \ + -scheme Jarvis \ + -configuration ${{ matrix.configuration }} \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e70574 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Xcode / build +build/ +DerivedData/ +*.xcuserstate +xcuserdata/ +*.xcscmblueprint +*.moved-aside + +# Generated by XcodeGen — project.yml is the source of truth +Jarvis.xcodeproj/ + +# SwiftPM +.build/ +.swiftpm/ + +# macOS +.DS_Store + +# Local secrets / env +*.local +.env diff --git a/API/contract.md b/API/contract.md new file mode 100644 index 0000000..7a79db3 --- /dev/null +++ b/API/contract.md @@ -0,0 +1,323 @@ +# Jarvis API Contract + +Base URL: `http://:/api/v1` + +--- + +## Authentication + +None. Local network / self-hosted only. +All endpoints are open. Secure at the network level (VPN, firewall). + +--- + +## REST Endpoints + +### GET /stories + +Returns paginated stories sorted by signal score descending. + +**Query params** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| limit | int | 20 | Max stories per page (max 100) | +| after | string | — | Cursor for next page | +| topic | string | — | Filter by topic slug e.g. `finance`, `tech` | +| min_signal | int | — | Only return stories above this score | + +**Response 200** + +```json +{ + "data": [ + { + "id": "story_2847", + "headline": "Bank of Uganda cuts interest rates", + "summary": "The Bank of Uganda reduced its benchmark lending rate by 50 basis points, citing easing inflation and the need to stimulate credit growth.", + "topic": "finance", + "signalScore": 91, + "scoreBreakdown": { + "sourceAuthority": 28, + "freshness": 22, + "localRelevance": 15, + "crossSourceConfirmation": 16, + "topicImportance": 10 + }, + "sourceCount": 7, + "sources": [ + { + "id": "src_001", + "name": "Daily Monitor", + "url": "https://monitor.co.ug", + "publishedAt": "2026-06-18T08:03:00Z", + "isBreaking": true + } + ], + "consensus": "All 7 sources confirm the rate cut. Daily Monitor and NilePost agree on the 50bp figure.", + "conflict": null, + "cachedAt": null, + "updatedAt": "2026-06-18T09:45:00Z", + "createdAt": "2026-06-18T08:03:00Z" + } + ], + "nextCursor": "cursor_abc123", + "hasMore": true, + "total": 248 +} +``` + +--- + +### GET /stories/:id + +Returns full story detail including article timeline. + +**Response 200** + +```json +{ + "id": "story_2847", + "headline": "Bank of Uganda cuts interest rates", + "summary": "...", + "topic": "finance", + "signalScore": 91, + "scoreBreakdown": { + "sourceAuthority": 28, + "freshness": 22, + "localRelevance": 15, + "crossSourceConfirmation": 16, + "topicImportance": 10 + }, + "sourceCount": 7, + "consensus": "All 7 sources confirm the rate cut.", + "conflict": "Two regional outlets dispute impact on mortgage rates.", + "timeline": [ + { + "articleId": "art_001", + "source": "Daily Monitor", + "headline": "BoU cuts lending rate by 50 basis points", + "publishedAt": "2026-06-18T08:03:00Z", + "isBreaking": true + }, + { + "articleId": "art_002", + "source": "NilePost", + "headline": "Central bank eases policy amid inflation slowdown", + "publishedAt": "2026-06-18T08:21:00Z", + "isBreaking": false + } + ], + "updatedAt": "2026-06-18T09:45:00Z", + "createdAt": "2026-06-18T08:03:00Z" +} +``` + +--- + +### GET /articles/:id + +Returns full article content for offline caching. + +**Response 200** + +```json +{ + "id": "art_001", + "storyId": "story_2847", + "source": "Daily Monitor", + "sourceUrl": "https://monitor.co.ug", + "headline": "BoU cuts lending rate by 50 basis points", + "body": "Full article text...", + "imageUrl": "https://...", + "author": "John Doe", + "publishedAt": "2026-06-18T08:03:00Z" +} +``` + +--- + +### GET /feeds + +Returns all configured RSS feeds with health state. + +**Response 200** + +```json +{ + "data": [ + { + "id": "feed_001", + "name": "Daily Monitor", + "url": "https://monitor.co.ug/rss", + "health": "active", + "pollIntervalSeconds": 300, + "failureCount": 0, + "lastFetchedAt": "2026-06-18T09:40:00Z", + "articleCountToday": 44 + } + ] +} +``` + +--- + +### POST /feeds + +Add a new RSS feed. + +**Request** + +```json +{ + "url": "https://example.com/rss", + "name": "Example News", + "pollIntervalSeconds": 900 +} +``` + +**Response 201** + +Returns the created feed object. + +--- + +### DELETE /feeds/:id + +Remove a feed. + +**Response 204** No content. + +--- + +### GET /health + +Server health check. Used by the app on launch to verify connection. + +**Response 200** + +```json +{ + "status": "ok", + "version": "1.0.0", + "storiesCount": 248, + "feedsCount": 37, + "uptime": 86400 +} +``` + +--- + +## WebSocket + +### Connect + +`ws://:/ws` + +No auth headers required. + +--- + +### Events (server → client) + +#### story.updated + +Signal score changed or new article joined the story. + +```json +{ + "type": "story.updated", + "storyId": "story_2847", + "signalScore": 94, + "sourceCount": 9, + "updatedAt": "2026-06-18T10:01:00Z" +} +``` + +App behaviour: refetch `GET /stories/story_2847`, update local cache. + +--- + +#### story.created + +New story formed from a new cluster. + +```json +{ + "type": "story.created", + "storyId": "story_2901", + "headline": "Parliament passes new budget", + "signalScore": 42, + "topic": "politics", + "createdAt": "2026-06-18T10:05:00Z" +} +``` + +App behaviour: insert into feed, resort by signal score. + +--- + +#### story.stale + +Story has gone cold — no new articles in decay window. + +```json +{ + "type": "story.stale", + "storyId": "story_2800", + "updatedAt": "2026-06-18T10:10:00Z" +} +``` + +App behaviour: dim story in feed or remove based on user preference. + +--- + +#### feed.health + +A feed's health state changed. + +```json +{ + "type": "feed.health", + "feedId": "feed_012", + "health": "failing", + "failureCount": 3, + "updatedAt": "2026-06-18T10:12:00Z" +} +``` + +App behaviour: update feed health indicator in feed manager. + +--- + +### Ping / Pong + +Server sends `{"type":"ping"}` every 30s. +Client must respond with `{"type":"pong"}`. +No response in 60s → server closes connection. +Client should reconnect with exponential backoff: 1s → 2s → 4s → 8s → max 60s. + +--- + +## Error shapes + +All errors follow this shape: + +```json +{ + "error": { + "code": "story_not_found", + "message": "No story with id story_9999", + "status": 404 + } +} +``` + +| Code | Status | Meaning | +|------|--------|---------| +| `story_not_found` | 404 | Story ID does not exist | +| `feed_not_found` | 404 | Feed ID does not exist | +| `invalid_cursor` | 400 | Pagination cursor is malformed | +| `invalid_url` | 400 | Feed URL is not a valid RSS feed | +| `server_error` | 500 | Internal server error | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3d92d22 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# Jarvis — Claude Code Handoff + +## What this is +Jarvis is an iOS app (SwiftUI + SwiftData) that connects to a self-hosted RSS +correlation platform. It displays news stories ranked by signal score, caches +full article content for offline reading, and receives live updates over WebSocket. + +## Architecture +- REST on launch → WebSocket for live updates +- Server computes signal scores — app only renders them +- Full offline support via SwiftData cache +- No auth — local network / self-hosted only + +## Files produced so far +- `API/contract.md` — full REST + WebSocket API spec +- `Jarvis/Models/Models.swift` — all Codable structs + SwiftData models +- `Jarvis/Networking/APIClient.swift` — REST layer (actor-based) +- `Jarvis/Networking/WebSocketManager.swift` — WS connection + reconnect backoff +- `Jarvis/Store/StoryStore.swift` — central ObservableObject, drives all views +- `Jarvis/Store/ServerSettings.swift` — persists server host to UserDefaults +- `Jarvis/JarvisApp.swift` — app entry point, SwiftData container +- `Jarvis/Views/RootTabView.swift` — tab navigation +- `Jarvis/Views/Onboarding/OnboardingView.swift` — server setup screen + +## Views still needed +- `SignalFeedView` — home screen, story list sorted by signal score +- `StoryDetailView` — cluster detail, timeline, consensus/conflict +- `ArticleReaderView` — full article, cached badge, more from cluster +- `FeedManagerView` — feed list, health states, add/delete +- `LatestView`, `SavedView`, `SearchView` — secondary tabs + +## Design tokens +- Primary accent: `KisaniOrange` = #FF5C00 (add to Assets.xcassets) +- Background: pure black #000000 +- Surface: #111111, #1A1A1A +- All views: `.preferredColorScheme(.dark)` + +## Key decisions already made +- Story = user-facing product object (never expose Cluster to UI) +- Pipeline: Article → Cluster → Story +- Signal score is server-computed, breakdown exposed as `scoreBreakdown` +- WebSocket pushes lightweight events (storyId + score only), app refetches full object via REST +- WS reconnect: exponential backoff 1s → 2s → 4s → max 60s +- Pagination: cursor-based via `nextCursor` + +## To start in Claude Code +```bash +cd jarvis +# Create new Xcode project named Jarvis, iOS target +# Drag all .swift files into the project +# Add KisaniOrange (#FF5C00) to Assets.xcassets as a Color Set +# Set minimum deployment target to iOS 17 (SwiftData requirement) +``` diff --git a/Jarvis/Assets.xcassets/AccentColor.colorset/Contents.json b/Jarvis/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..5da41aa --- /dev/null +++ b/Jarvis/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x5C", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Jarvis/Assets.xcassets/AppIcon.appiconset/Contents.json b/Jarvis/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/Jarvis/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Jarvis/Assets.xcassets/Contents.json b/Jarvis/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Jarvis/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Jarvis/Assets.xcassets/KisaniOrange.colorset/Contents.json b/Jarvis/Assets.xcassets/KisaniOrange.colorset/Contents.json new file mode 100644 index 0000000..4e246d8 --- /dev/null +++ b/Jarvis/Assets.xcassets/KisaniOrange.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x5C", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x5C", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Jarvis/Connectivity/ConnectivityManager.swift b/Jarvis/Connectivity/ConnectivityManager.swift new file mode 100644 index 0000000..f0ebf9a --- /dev/null +++ b/Jarvis/Connectivity/ConnectivityManager.swift @@ -0,0 +1,111 @@ +// ConnectivityManager.swift +// Jarvis — resolves which endpoint to use by probing /health, activates it on the +// APIClient + WebSocket, and re-resolves when the network path changes. + +import Foundation +import Network + +enum Reachability: Equatable { + case unknown, checking, reachable, unreachable +} + +@MainActor +final class ConnectivityManager: ObservableObject { + static let shared = ConnectivityManager() + + /// The endpoint currently in use (nil = nothing reachable). + @Published var activeHost: String? + /// Per-host probe results, for the settings UI badges. + @Published var status: [String: Reachability] = [:] + @Published var isResolving = false + + private let settings = ConnectivitySettings.shared + private let probeSession: URLSession + private var monitor: NWPathMonitor? + private var lastActivated: String? + private var resolveTask: Task? + + private init() { + let cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 2.5 + cfg.waitsForConnectivity = false + probeSession = URLSession(configuration: cfg) + } + + /// Begin watching the network path; re-resolve (debounced) on changes. + func start() { + guard monitor == nil else { return } + let m = NWPathMonitor() + m.pathUpdateHandler = { [weak self] _ in + Task { @MainActor in self?.scheduleResolve() } + } + m.start(queue: DispatchQueue(label: "jarvis.connectivity")) + monitor = m + } + + private func scheduleResolve() { + guard settings.autoConnect else { return } // only auto-switch when enabled + resolveTask?.cancel() + let delay = UInt64(max(1, settings.switchDelaySeconds)) * 1_000_000_000 + resolveTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { return } + await self?.resolveAndActivate() + } + } + + /// Probe candidates in priority order; activate the first that answers. + func resolveAndActivate() async { + guard !isResolving else { return } + let candidates = settings.candidates + guard !candidates.isEmpty else { return } + isResolving = true + defer { isResolving = false } + + for host in candidates { + status[host] = .checking + if await probe(host) { + status[host] = .reachable + await activate(host) + return + } else { + status[host] = .unreachable + } + } + // Nothing reachable. + activeHost = nil + } + + /// Probe a single host and record its status (used by the settings screen). + @discardableResult + func check(_ host: String) async -> Bool { + let h = host.trimmingCharacters(in: .whitespaces) + guard !h.isEmpty else { return false } + status[h] = .checking + let ok = await probe(h) + status[h] = ok ? .reachable : .unreachable + return ok + } + + // MARK: - Private + + private func probe(_ host: String) async -> Bool { + guard let url = URL(string: "http://\(host)/api/v1/health") else { return false } + var req = URLRequest(url: url) + req.httpMethod = "GET" + do { + let (_, response) = try await probeSession.data(for: req) + return (response as? HTTPURLResponse).map { (200...299).contains($0.statusCode) } ?? false + } catch { + return false + } + } + + private func activate(_ host: String) async { + activeHost = host + guard host != lastActivated else { return } // avoid thrashing the WS + lastActivated = host + await ServerSettings.shared.activate(host: host) + await StoryStore.shared.loadStories(refresh: true) + } +} diff --git a/Jarvis/Connectivity/ConnectivitySettings.swift b/Jarvis/Connectivity/ConnectivitySettings.swift new file mode 100644 index 0000000..12c382a --- /dev/null +++ b/Jarvis/Connectivity/ConnectivitySettings.swift @@ -0,0 +1,73 @@ +// ConnectivitySettings.swift +// Jarvis — persisted connection profiles: a Direct/LAN address and a Remote/VPN +// address for the same server, the chosen provider, and the auto-switch policy. + +import Foundation + +@MainActor +final class ConnectivitySettings: ObservableObject { + static let shared = ConnectivitySettings() + + private let key = "jarvis_connectivity_v2" + + @Published var provider: VPNProvider { didSet { persist() } } + @Published var directHost: String { didSet { persist() } } // LAN, e.g. 192.168.30.50:8080 + @Published var remoteHost: String { didSet { persist() } } // Tailscale/VPN address + /// "Use when remote" — when off, only the LAN address is ever used. + @Published var remoteEnabled: Bool { didSet { persist() } } + /// "Auto-connect" — re-resolve automatically when the network path changes. + @Published var autoConnect: Bool { didSet { persist() } } + /// Debounce before switching endpoints after a network change (seconds). + @Published var switchDelaySeconds: Int { didSet { persist() } } + + private struct Stored: Codable { + var provider: VPNProvider + var directHost: String + var remoteHost: String + var remoteEnabled: Bool? + var autoConnect: Bool? + var switchDelaySeconds: Int? + } + + private init() { + if let data = UserDefaults.standard.data(forKey: key), + let s = try? JSONDecoder().decode(Stored.self, from: data) { + provider = s.provider + directHost = s.directHost + remoteHost = s.remoteHost + remoteEnabled = s.remoteEnabled ?? true + autoConnect = s.autoConnect ?? true + switchDelaySeconds = s.switchDelaySeconds ?? 5 + } else { + provider = .tailscale + directHost = UserDefaults.standard.string(forKey: "jarvis_server_host") ?? "" + remoteHost = "" + remoteEnabled = true + autoConnect = true + switchDelaySeconds = 5 + } + } + + private func persist() { + let s = Stored(provider: provider, directHost: directHost, remoteHost: remoteHost, + remoteEnabled: remoteEnabled, autoConnect: autoConnect, + switchDelaySeconds: switchDelaySeconds) + if let data = try? JSONEncoder().encode(s) { + UserDefaults.standard.set(data, forKey: key) + } + } + + /// Whether the remote endpoint participates in resolution right now. + var remoteActiveInPolicy: Bool { provider.needsRemote && remoteEnabled } + + /// Hosts to probe, in priority order (LAN first), de-duplicated and trimmed. + var candidates: [String] { + let d = directHost.trimmingCharacters(in: .whitespaces) + let r = remoteActiveInPolicy ? remoteHost.trimmingCharacters(in: .whitespaces) : "" + var seen = Set(); var out: [String] = [] + for h in [d, r] where !h.isEmpty && !seen.contains(h) { seen.insert(h); out.append(h) } + return out + } + + var isConfigured: Bool { !candidates.isEmpty } +} diff --git a/Jarvis/Connectivity/VPNProvider.swift b/Jarvis/Connectivity/VPNProvider.swift new file mode 100644 index 0000000..2e76819 --- /dev/null +++ b/Jarvis/Connectivity/VPNProvider.swift @@ -0,0 +1,101 @@ +// VPNProvider.swift +// Jarvis — remote-access providers. The app can't start another app's VPN tunnel +// (iOS sandboxing), so the provider only tailors hints, help text, and an +// "open the app" jump. Reachability + endpoint selection is provider-agnostic. + +import UIKit + +enum VPNProvider: String, CaseIterable, Codable, Identifiable { + case direct // LAN only, no VPN + case tailscale + case headscale // self-hosted control plane, Tailscale clients + case netbird + case zerotier + case netmaker + case wireguard + + var id: String { rawValue } + + var displayName: String { + switch self { + case .direct: return "Direct (LAN)" + case .tailscale: return "Tailscale" + case .headscale: return "Headscale" + case .netbird: return "NetBird" + case .zerotier: return "ZeroTier" + case .netmaker: return "Netmaker" + case .wireguard: return "WireGuard" + } + } + + /// Whether a separate remote/VPN address is meaningful for this provider. + var needsRemote: Bool { self != .direct } + + /// Placeholder shown in the remote-address field. + var remoteHint: String { + switch self { + case .direct: return "—" + case .tailscale: return "jarvis.tailnet.ts.net:8080 or 100.x.y.z:8080" + case .headscale: return "100.x.y.z:8080 (MagicDNS or tailnet IP)" + case .netbird: return "100.x.y.z:8080" + case .zerotier: return "10.x.x.x:8080 (managed IP)" + case .netmaker: return "10.x.x.x:8080" + case .wireguard: return "10.0.0.x:8080 (peer IP)" + } + } + + var blurb: String { + switch self { + case .direct: + return "Use the LAN address only. Best when the phone is always on the home network." + case .tailscale: + return "On your home wifi Jarvis uses the LAN address directly; away, it uses your Tailscale address. Make sure Tailscale is connected when remote." + case .headscale: + return "Self-hosted Tailscale control plane. Same client behaviour as Tailscale — keep the client connected when away." + case .netbird: + return "Open-source WireGuard mesh. Keep the NetBird app connected when off the LAN." + case .zerotier: + return "Managed network via ZeroTier. Keep the ZeroTier app joined to the network when remote." + case .netmaker: + return "Self-hosted WireGuard mesh. Keep your Netmaker client up when off the LAN." + case .wireguard: + return "Plain WireGuard tunnel. Enable the tunnel in the WireGuard app when remote." + } + } + + /// Best-effort URL scheme + App Store fallback used by "Open ". + /// iOS can't start the tunnel for us — this just brings the app forward. + private var urlScheme: URL? { + switch self { + case .tailscale, .headscale: return URL(string: "tailscale://") + case .zerotier: return URL(string: "zerotier://") + case .wireguard: return URL(string: "wireguard://") + default: return nil + } + } + + private var appStoreURL: URL? { + switch self { + case .tailscale, .headscale: return URL(string: "https://apps.apple.com/app/id1470499037") + case .zerotier: return URL(string: "https://apps.apple.com/app/id1084101492") + case .wireguard: return URL(string: "https://apps.apple.com/app/id1441195209") + case .netbird: return URL(string: "https://apps.apple.com/app/id6443155627") + default: return nil + } + } + + var canOpenApp: Bool { urlScheme != nil || appStoreURL != nil } + + /// Try the app's scheme; fall back to its App Store page. + @MainActor + func openApp() { + let candidates = [urlScheme, appStoreURL].compactMap { $0 } + func attempt(_ i: Int) { + guard i < candidates.count else { return } + UIApplication.shared.open(candidates[i]) { ok in + if !ok { attempt(i + 1) } + } + } + attempt(0) + } +} diff --git a/Jarvis/Info.plist b/Jarvis/Info.plist new file mode 100644 index 0000000..b6fa7a5 --- /dev/null +++ b/Jarvis/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Jarvis + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSApplicationQueriesSchemes + + tailscale + zerotier + wireguard + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Jarvis connects to your self-hosted news platform on the local network. + UILaunchScreen + + UIColorName + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UIUserInterfaceStyle + Dark + + diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift new file mode 100644 index 0000000..5a48752 --- /dev/null +++ b/Jarvis/JarvisApp.swift @@ -0,0 +1,35 @@ +// JarvisApp.swift + +import SwiftUI +import SwiftData + +@main +struct JarvisApp: App { + @StateObject private var settings = ServerSettings.shared + @StateObject private var store = StoryStore.shared + @StateObject private var ws = WebSocketManager.shared + @StateObject private var connectivity = ConnectivitySettings.shared + @StateObject private var connManager = ConnectivityManager.shared + + var body: some Scene { + WindowGroup { + Group { + if settings.isConfigured { + RootTabView() + } else { + OnboardingView() + } + } + .environmentObject(settings) + .environmentObject(store) + .environmentObject(ws) + .environmentObject(connectivity) + .environmentObject(connManager) + .task { + connManager.start() + await connManager.resolveAndActivate() + } + } + .modelContainer(for: [CachedStory.self, CachedArticle.self]) + } +} diff --git a/Jarvis/Models/Models.swift b/Jarvis/Models/Models.swift new file mode 100644 index 0000000..a5d9ce4 --- /dev/null +++ b/Jarvis/Models/Models.swift @@ -0,0 +1,214 @@ +// Models.swift +// Jarvis — all Codable structs matching the API contract + +import Foundation +import SwiftData + +// MARK: - Signal Score + +struct SignalScore: Codable, Hashable { + let total: Int + let sourceAuthority: Int + let freshness: Int + let localRelevance: Int + let crossSourceConfirmation: Int + let topicImportance: Int + + enum CodingKeys: String, CodingKey { + case total = "signalScore" + case sourceAuthority, freshness, localRelevance + case crossSourceConfirmation, topicImportance + } +} + +// MARK: - Source + +struct StorySource: Codable, Hashable, Identifiable { + let id: String + let name: String + let url: String + let publishedAt: Date + let isBreaking: Bool +} + +// MARK: - Timeline Entry + +struct TimelineEntry: Codable, Hashable, Identifiable { + let articleId: String + let source: String + let headline: String + let publishedAt: Date + let isBreaking: Bool + + var id: String { articleId } +} + +// MARK: - Story Summary (list view) + +struct StorySummary: Codable, Identifiable, Hashable { + let id: String + let headline: String + let summary: String + let topic: String + let signalScore: Int + let scoreBreakdown: ScoreBreakdown + let sourceCount: Int + let sources: [StorySource] + let consensus: String? + let conflict: String? + let updatedAt: Date + let createdAt: Date +} + +struct ScoreBreakdown: Codable, Hashable { + let sourceAuthority: Int + let freshness: Int + let localRelevance: Int + let crossSourceConfirmation: Int + let topicImportance: Int +} + +// MARK: - Story Detail (full, with timeline) + +struct StoryDetail: Codable, Identifiable { + let id: String + let headline: String + let summary: String + let topic: String + let signalScore: Int + let scoreBreakdown: ScoreBreakdown + let sourceCount: Int + let consensus: String? + let conflict: String? + let timeline: [TimelineEntry] + let updatedAt: Date + let createdAt: Date +} + +// MARK: - Article (full content, for offline cache) + +struct Article: Codable, Identifiable { + let id: String + let storyId: String + let source: String + let sourceUrl: String + let headline: String + let body: String + let imageUrl: String? + let author: String? + let publishedAt: Date +} + +// MARK: - Feed + +struct Feed: Codable, Identifiable { + let id: String + let name: String + let url: String + let health: FeedHealth + let pollIntervalSeconds: Int + let failureCount: Int + let lastFetchedAt: Date? + let articleCountToday: Int +} + +enum FeedHealth: String, Codable { + case active, failing, dead +} + +// MARK: - Server Health + +struct ServerHealth: Codable { + let status: String + let version: String + let storiesCount: Int + let feedsCount: Int + let uptime: Int +} + +// MARK: - Paginated Response + +struct PaginatedStories: Codable { + let data: [StorySummary] + let nextCursor: String? + let hasMore: Bool + let total: Int +} + +// MARK: - WebSocket Events + +enum WSEventType: String, Codable { + case storyUpdated = "story.updated" + case storyCreated = "story.created" + case storyStale = "story.stale" + case feedHealth = "feed.health" + case ping, pong +} + +struct WSEvent: Codable { + let type: WSEventType + let storyId: String? + let feedId: String? + let signalScore: Int? + let sourceCount: Int? + let headline: String? + let topic: String? + let health: FeedHealth? + let failureCount: Int? + let updatedAt: Date? + let createdAt: Date? +} + +// MARK: - SwiftData cached models + +@Model +final class CachedStory { + @Attribute(.unique) var id: String + var headline: String + var summary: String + var topic: String + var signalScore: Int + var sourceCount: Int + var consensus: String? + var conflict: String? + var updatedAt: Date + var cachedAt: Date + + init(from summary: StorySummary) { + self.id = summary.id + self.headline = summary.headline + self.summary = summary.summary + self.topic = summary.topic + self.signalScore = summary.signalScore + self.sourceCount = summary.sourceCount + self.consensus = summary.consensus + self.conflict = summary.conflict + self.updatedAt = summary.updatedAt + self.cachedAt = Date() + } +} + +@Model +final class CachedArticle { + @Attribute(.unique) var id: String + var storyId: String + var source: String + var headline: String + var body: String + var imageUrl: String? + var author: String? + var publishedAt: Date + var cachedAt: Date + + init(from article: Article) { + self.id = article.id + self.storyId = article.storyId + self.source = article.source + self.headline = article.headline + self.body = article.body + self.imageUrl = article.imageUrl + self.author = article.author + self.publishedAt = article.publishedAt + self.cachedAt = Date() + } +} diff --git a/Jarvis/Networking/APIClient.swift b/Jarvis/Networking/APIClient.swift new file mode 100644 index 0000000..ef566e5 --- /dev/null +++ b/Jarvis/Networking/APIClient.swift @@ -0,0 +1,159 @@ +// APIClient.swift +// Jarvis — REST networking layer + +import Foundation + +enum APIError: Error, LocalizedError { + case invalidURL + case serverError(code: String, message: String, status: Int) + case decodingError(Error) + case networkError(Error) + case notConnected + + var errorDescription: String? { + switch self { + case .invalidURL: return "Invalid server URL" + case .notConnected: return "Not connected to a Jarvis server" + case .serverError(_, let msg, _): return msg + case .decodingError(let e): return "Decode error: \(e.localizedDescription)" + case .networkError(let e): return e.localizedDescription + } + } +} + +private struct APIErrorResponse: Decodable { + struct Inner: Decodable { let code, message: String; let status: Int } + let error: Inner +} + +actor APIClient { + static let shared = APIClient() + + private var baseURL: URL? + private let session: URLSession + private let decoder: JSONDecoder + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 10 + self.session = URLSession(configuration: config) + + self.decoder = JSONDecoder() + self.decoder.dateDecodingStrategy = .iso8601 + self.decoder.keyDecodingStrategy = .convertFromSnakeCase + } + + func configure(host: String) throws { + guard let url = URL(string: "http://\(host)/api/v1") else { + throw APIError.invalidURL + } + self.baseURL = url + } + + // MARK: - Health + + func checkHealth() async throws -> ServerHealth { + try await get("/health") + } + + // MARK: - Stories + + func fetchStories( + cursor: String? = nil, + topic: String? = nil, + minSignal: Int? = nil, + limit: Int = 20 + ) async throws -> PaginatedStories { + var params: [String: String] = ["limit": "\(limit)"] + if let cursor { params["after"] = cursor } + if let topic { params["topic"] = topic } + if let minSignal { params["min_signal"] = "\(minSignal)" } + return try await get("/stories", params: params) + } + + func fetchStory(id: String) async throws -> StoryDetail { + try await get("/stories/\(id)") + } + + // MARK: - Articles + + func fetchArticle(id: String) async throws -> Article { + try await get("/articles/\(id)") + } + + // MARK: - Feeds + + func fetchFeeds() async throws -> [Feed] { + struct FeedsResponse: Decodable { let data: [Feed] } + let response: FeedsResponse = try await get("/feeds") + return response.data + } + + func addFeed(url: String, name: String, pollInterval: Int = 900) async throws -> Feed { + let body = ["url": url, "name": name, "pollIntervalSeconds": pollInterval] as [String: Any] + return try await post("/feeds", body: body) + } + + func deleteFeed(id: String) async throws { + try await delete("/feeds/\(id)") + } + + // MARK: - Private helpers + + private func get(_ path: String, params: [String: String] = [:]) async throws -> T { + guard let base = baseURL else { throw APIError.notConnected } + var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)! + if !params.isEmpty { + components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) } + } + guard let url = components.url else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "GET" + return try await execute(request) + } + + private func post(_ path: String, body: [String: Any]) async throws -> T { + guard let base = baseURL else { throw APIError.notConnected } + let url = base.appendingPathComponent(path) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return try await execute(request) + } + + private func delete(_ path: String) async throws { + guard let base = baseURL else { throw APIError.notConnected } + let url = base.appendingPathComponent(path) + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 204 else { + throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500) + } + } + + private func execute(_ request: URLRequest) async throws -> T { + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw APIError.networkError(URLError(.badServerResponse)) + } + if !(200...299).contains(http.statusCode) { + if let err = try? decoder.decode(APIErrorResponse.self, from: data) { + throw APIError.serverError(code: err.error.code, message: err.error.message, status: err.error.status) + } + throw APIError.serverError(code: "unknown", message: "HTTP \(http.statusCode)", status: http.statusCode) + } + do { + return try decoder.decode(T.self, from: data) + } catch { + throw APIError.decodingError(error) + } + } catch let error as APIError { + throw error + } catch { + throw APIError.networkError(error) + } + } +} diff --git a/Jarvis/Networking/WebSocketManager.swift b/Jarvis/Networking/WebSocketManager.swift new file mode 100644 index 0000000..242a415 --- /dev/null +++ b/Jarvis/Networking/WebSocketManager.swift @@ -0,0 +1,128 @@ +// WebSocketManager.swift +// Jarvis — WebSocket connection, event parsing, reconnect with backoff + +import Foundation +import Combine + +enum ConnectionState { + case disconnected + case connecting + case connected + case reconnecting(attempt: Int) +} + +@MainActor +final class WebSocketManager: ObservableObject { + static let shared = WebSocketManager() + + @Published var connectionState: ConnectionState = .disconnected + + let events = PassthroughSubject() + + private var task: URLSessionWebSocketTask? + private var pingTimer: Timer? + private var reconnectTask: Task? + private var host: String? + + private let maxBackoff: TimeInterval = 60 + private var attempt = 0 + + private init() {} + + func connect(host: String) { + self.host = host + self.attempt = 0 + openConnection() + } + + func disconnect() { + reconnectTask?.cancel() + pingTimer?.invalidate() + task?.cancel(with: .goingAway, reason: nil) + task = nil + connectionState = .disconnected + } + + // MARK: - Private + + private func openConnection() { + guard let host, let url = URL(string: "ws://\(host)/ws") else { return } + connectionState = attempt == 0 ? .connecting : .reconnecting(attempt: attempt) + + let session = URLSession(configuration: .default) + task = session.webSocketTask(with: url) + task?.resume() + + connectionState = .connected + attempt = 0 + startPing() + listen() + } + + private func listen() { + task?.receive { [weak self] result in + guard let self else { return } + switch result { + case .failure: + Task { @MainActor in self.handleDisconnect() } + case .success(let message): + Task { @MainActor in + self.handle(message: message) + self.listen() + } + } + } + } + + private func handle(message: URLSessionWebSocketTask.Message) { + guard case .string(let text) = message, + let data = text.data(using: .utf8) else { return } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .convertFromSnakeCase + + guard let event = try? decoder.decode(WSEvent.self, from: data) else { return } + + if event.type == .ping { + sendPong() + return + } + + events.send(event) + } + + private func sendPong() { + task?.send(.string(#"{"type":"pong"}"#)) { _ in } + } + + private func startPing() { + pingTimer?.invalidate() + pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + self?.task?.sendPing { error in + if error != nil { + Task { @MainActor in self?.handleDisconnect() } + } + } + } + } + + private func handleDisconnect() { + pingTimer?.invalidate() + task = nil + connectionState = .disconnected + scheduleReconnect() + } + + private func scheduleReconnect() { + attempt += 1 + let delay = min(pow(2.0, Double(attempt - 1)), maxBackoff) + connectionState = .reconnecting(attempt: attempt) + + reconnectTask = Task { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled else { return } + await MainActor.run { self.openConnection() } + } + } +} diff --git a/Jarvis/Store/ServerSettings.swift b/Jarvis/Store/ServerSettings.swift new file mode 100644 index 0000000..8dec02a --- /dev/null +++ b/Jarvis/Store/ServerSettings.swift @@ -0,0 +1,48 @@ +// ServerSettings.swift +// Jarvis — persists the configured server host + +import Foundation + +@MainActor +final class ServerSettings: ObservableObject { + static let shared = ServerSettings() + + private let key = "jarvis_server_host" + + @Published var host: String? { + didSet { UserDefaults.standard.set(host, forKey: key) } + } + + var isConfigured: Bool { host != nil } + + private init() { + host = UserDefaults.standard.string(forKey: key) + } + + func save(host: String) async throws { + try await APIClient.shared.configure(host: host) + _ = try await APIClient.shared.checkHealth() + self.host = host + WebSocketManager.shared.connect(host: host) + } + + func reset() { + host = nil + WebSocketManager.shared.disconnect() + } + + /// Re-establish REST + WebSocket from a previously saved host on app launch. + func reconnectIfConfigured() async { + guard let host else { return } + try? await APIClient.shared.configure(host: host) + WebSocketManager.shared.connect(host: host) + } + + /// Point REST + WebSocket at a host the ConnectivityManager already verified + /// (skips the health check — the probe just confirmed it). + func activate(host: String) async { + try? await APIClient.shared.configure(host: host) + self.host = host + WebSocketManager.shared.connect(host: host) + } +} diff --git a/Jarvis/Store/StoryStore.swift b/Jarvis/Store/StoryStore.swift new file mode 100644 index 0000000..21f5c58 --- /dev/null +++ b/Jarvis/Store/StoryStore.swift @@ -0,0 +1,143 @@ +// StoryStore.swift +// Jarvis — central observable store. Views read from here, never hit the API directly. + +import Foundation +import Combine +import SwiftData + +@MainActor +final class StoryStore: ObservableObject { + static let shared = StoryStore() + + @Published var stories: [StorySummary] = [] + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var error: APIError? + @Published var lastSyncedAt: Date? + @Published var hasMore = false + @Published var selectedTopic: String? = nil + + private var nextCursor: String? + private var cancellables = Set() + private let ws = WebSocketManager.shared + private let api = APIClient.shared + + private init() { + subscribeToWebSocket() + } + + // MARK: - Load + + func loadStories(refresh: Bool = false) async { + guard !isLoading else { return } + isLoading = true + error = nil + + if refresh { + nextCursor = nil + stories = [] + } + + do { + let result = try await api.fetchStories( + cursor: nextCursor, + topic: selectedTopic + ) + stories = refresh ? result.data : stories + result.data + nextCursor = result.nextCursor + hasMore = result.hasMore + lastSyncedAt = Date() + } catch let e as APIError { + error = e + } catch {} + + isLoading = false + } + + func loadMore() async { + guard hasMore, !isLoadingMore, let cursor = nextCursor else { return } + isLoadingMore = true + do { + let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic) + stories += result.data + nextCursor = result.nextCursor + hasMore = result.hasMore + } catch {} + isLoadingMore = false + } + + func setTopic(_ topic: String?) async { + selectedTopic = topic + await loadStories(refresh: true) + } + + // MARK: - WebSocket + + private func subscribeToWebSocket() { + ws.events + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + self?.handle(event: event) + } + .store(in: &cancellables) + } + + private func handle(event: WSEvent) { + switch event.type { + + case .storyUpdated: + guard let id = event.storyId, + let score = event.signalScore, + let count = event.sourceCount else { return } + updateStory(id: id, signalScore: score, sourceCount: count) + Task { await refetch(storyId: id) } + + case .storyCreated: + Task { await loadStories(refresh: true) } + + case .storyStale: + guard let id = event.storyId else { return } + removeStory(id: id) + + case .feedHealth: + NotificationCenter.default.post(name: .feedHealthChanged, object: event) + + default: + break + } + } + + private func updateStory(id: String, signalScore: Int, sourceCount: Int) { + guard let idx = stories.firstIndex(where: { $0.id == id }) else { return } + let old = stories[idx] + let updated = StorySummary( + id: old.id, + headline: old.headline, + summary: old.summary, + topic: old.topic, + signalScore: signalScore, + scoreBreakdown: old.scoreBreakdown, + sourceCount: sourceCount, + sources: old.sources, + consensus: old.consensus, + conflict: old.conflict, + updatedAt: old.updatedAt, + createdAt: old.createdAt + ) + stories[idx] = updated + stories.sort { $0.signalScore > $1.signalScore } + } + + private func removeStory(id: String) { + stories.removeAll { $0.id == id } + } + + private func refetch(storyId: String) async { + // Lightweight: only update what changed via WS patch above. + // Full detail is fetched lazily when user taps into the story. + } +} + +extension Notification.Name { + static let feedHealthChanged = Notification.Name("feedHealthChanged") +} diff --git a/Jarvis/Views/Connectivity/ConnectivityView.swift b/Jarvis/Views/Connectivity/ConnectivityView.swift new file mode 100644 index 0000000..4188fb8 --- /dev/null +++ b/Jarvis/Views/Connectivity/ConnectivityView.swift @@ -0,0 +1,322 @@ +// ConnectivityView.swift +// Jarvis — server address, a Remote Access card (Use when remote + +// host + live status), and an Auto-connect automation card. The app can't start +// the VPN tunnel (iOS), so when the remote is down we offer a one-tap jump. + +import SwiftUI + +struct ConnectivityView: View { + @EnvironmentObject var connectivity: ConnectivitySettings + @EnvironmentObject var manager: ConnectivityManager + @Environment(\.dismiss) private var dismiss + + private var directReach: Reachability { + manager.status[connectivity.directHost.trimmingCharacters(in: .whitespaces)] ?? .unknown + } + private var remoteReach: Reachability { + manager.status[connectivity.remoteHost.trimmingCharacters(in: .whitespaces)] ?? .unknown + } + private var providerName: String { connectivity.provider.displayName } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + ScrollView { + VStack(alignment: .leading, spacing: 26) { + header + serverSection + remoteAccessSection + automationSection + providerSection + actions + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + } + .preferredColorScheme(.dark) + .presentationDragIndicator(.visible) + .task { await recheck() } + } + + // MARK: - Header + + private var header: some View { + HStack { + Text("Connectivity") + .font(.system(size: 22, weight: .heavy)) + .foregroundStyle(.white) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(Color(hex: "888888")) + .frame(width: 44, height: 44) + } + } + .padding(.top, 8) + } + + // MARK: - Server (LAN) + + private var serverSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionLabel("SERVER") + Card { + cardRow(icon: "server.rack") { + fieldStack(title: "Direct (LAN) host", + text: $connectivity.directHost, + placeholder: "192.168.30.50:8080", + reach: directReach) + } + } + } + } + + // MARK: - Remote access + + private var remoteAccessSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionLabel("REMOTE ACCESS") + Card { + VStack(spacing: 0) { + cardRow(icon: "globe.badge.chevron.backward") { + Toggle(isOn: $connectivity.remoteEnabled) { + VStack(alignment: .leading, spacing: 3) { + Text("Use \(providerName) when remote") + .font(.system(size: 16, weight: .heavy)) + .foregroundStyle(.white) + Text("Connects via VPN when not on home network") + .font(.system(size: 12)) + .foregroundStyle(Color(hex: "888888")) + .fixedSize(horizontal: false, vertical: true) + } + } + .tint(Palette.orange) + } + + if connectivity.remoteEnabled { + divider + cardRow(icon: "externaldrive.connected.to.line.below") { + fieldStack(title: "\(providerName) host", + text: $connectivity.remoteHost, + placeholder: connectivity.provider.remoteHint, + reach: remoteReach) + } + divider + statusRow + } + } + } + } + } + + private var statusRow: some View { + HStack(spacing: 10) { + dot(for: connectivity.remoteEnabled ? remoteReach : .unknown) + Text(statusText) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(statusColor) + Spacer() + if remoteReach != .reachable && connectivity.provider.canOpenApp { + Button { connectivity.provider.openApp() } label: { + Text("Open \(providerName)") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, 14) + .frame(height: 36) + .background(Palette.orange) + .clipShape(Capsule()) + } + } + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + } + + private var statusText: String { + switch remoteReach { + case .reachable: return "\(providerName) connected" + case .checking: return "Checking \(providerName)…" + default: return "\(providerName) not active" + } + } + private var statusColor: Color { + switch remoteReach { + case .reachable: return Color(hex: "33C25E") + case .checking: return Palette.healthFailing + default: return Color(hex: "888888") + } + } + + // MARK: - Automation + + private var automationSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionLabel("AUTOMATION") + Card { + VStack(spacing: 0) { + cardRow(icon: "wifi") { + Toggle(isOn: $connectivity.autoConnect) { + VStack(alignment: .leading, spacing: 3) { + Text("Auto-connect on network change") + .font(.system(size: 16, weight: .heavy)) + .foregroundStyle(.white) + Text("Prefers the LAN on a trusted network; switches to \(providerName) when away") + .font(.system(size: 12)) + .foregroundStyle(Color(hex: "888888")) + .fixedSize(horizontal: false, vertical: true) + } + } + .tint(Palette.orange) + } + if connectivity.autoConnect { + divider + cardRow(icon: "timer") { + HStack { + Text("Wait before switching") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.white) + Spacer() + Stepper(value: $connectivity.switchDelaySeconds, in: 1...60, step: 1) { + Text("\(connectivity.switchDelaySeconds)s") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundStyle(Palette.orange) + } + .labelsHidden() + .fixedSize() + Text("\(connectivity.switchDelaySeconds)s") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundStyle(Palette.orange) + .frame(width: 36, alignment: .trailing) + } + } + } + } + } + Text("iOS can't start the \(providerName) tunnel for Jarvis — keep the \(providerName) app connected when away. Jarvis only picks the reachable address.") + .font(.system(size: 11)) + .foregroundStyle(Color(hex: "666666")) + .lineSpacing(2) + } + } + + // MARK: - Provider + + private var providerSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionLabel("PROVIDER") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(VPNProvider.allCases) { p in + let selected = connectivity.provider == p + Button { connectivity.provider = p } label: { + Text(p.displayName) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(selected ? .black : Color(hex: "AAAAAA")) + .padding(.horizontal, 14) + .frame(height: 34) + .background(selected ? Palette.orange : Palette.surface) + .clipShape(Capsule()) + } + } + } + } + } + } + + // MARK: - Actions + + private var actions: some View { + HStack(spacing: 12) { + Button { Task { await recheck() } } label: { + Text("Re-check") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity).frame(height: 50) + .background(Palette.surface2) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + Button { Task { await manager.resolveAndActivate(); await recheck() } } label: { + Text("Apply") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.black) + .frame(maxWidth: .infinity).frame(height: 50) + .background(connectivity.isConfigured ? Palette.orange : Palette.surface2) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(!connectivity.isConfigured) + } + } + + // MARK: - Building blocks + + private func sectionLabel(_ t: String) -> some View { + Text(t) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .kerning(0.8) + .foregroundStyle(Color(hex: "555555")) + } + + private func Card(@ViewBuilder _ content: () -> Content) -> some View { + content() + .background(Palette.surface) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + + private func cardRow(icon: String, @ViewBuilder _ content: () -> Content) -> some View { + HStack(alignment: .center, spacing: 14) { + Image(systemName: icon) + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(Color(hex: "999999")) + .frame(width: 26) + content() + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .frame(minHeight: 44) + } + + private var divider: some View { + Rectangle().fill(Palette.surface2).frame(height: 1).padding(.leading, 54) + } + + private func fieldStack(title: String, text: Binding, placeholder: String, reach: Reachability) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: 12)) + .foregroundStyle(Color(hex: "888888")) + HStack(spacing: 8) { + TextField("", text: text, + prompt: Text(placeholder).foregroundColor(Color(hex: "444444"))) + .font(.system(size: 16, weight: .regular, design: .monospaced)) + .foregroundStyle(Palette.orange) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.URL) + if reach != .unknown { dot(for: reach) } + } + } + } + + // MARK: - Reachability helpers + + private func recheck() async { + await manager.check(connectivity.directHost) + if connectivity.remoteActiveInPolicy { + await manager.check(connectivity.remoteHost) + } + } + + private func color(for r: Reachability) -> Color { + switch r { + case .reachable: return Color(hex: "33C25E") + case .unreachable: return Color(hex: "C25555") + case .checking: return Palette.healthFailing + case .unknown: return Color(hex: "555555") + } + } + private func dot(for r: Reachability) -> some View { + Circle().fill(color(for: r)).frame(width: 9, height: 9) + } +} diff --git a/Jarvis/Views/Feeds/AddFeedSheet.swift b/Jarvis/Views/Feeds/AddFeedSheet.swift new file mode 100644 index 0000000..bd586fb --- /dev/null +++ b/Jarvis/Views/Feeds/AddFeedSheet.swift @@ -0,0 +1,114 @@ +// AddFeedSheet.swift +// Jarvis — add a new RSS feed: URL + name + confirm. + +import SwiftUI + +struct AddFeedSheet: View { + /// Returns true on success. + let onAdd: (String, String) async -> Bool + + @Environment(\.dismiss) private var dismiss + @State private var url = "" + @State private var name = "" + @State private var isSubmitting = false + @State private var errorMessage: String? + + private var canSubmit: Bool { + !url.trimmingCharacters(in: .whitespaces).isEmpty && + !name.trimmingCharacters(in: .whitespaces).isEmpty && + !isSubmitting + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Add feed") + .font(.system(size: 22, weight: .heavy)) + .foregroundStyle(.white) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(Color(hex: "888888")) + .frame(width: 44, height: 44) + } + } + .padding(.top, 8) + + field(label: "FEED URL", text: $url, + placeholder: "https://example.com/rss", + mono: true, keyboard: .URL) + .padding(.top, 12) + + field(label: "DISPLAY NAME", text: $name, + placeholder: "Example News", + mono: false, keyboard: .default) + .padding(.top, 16) + + if let errorMessage { + Text(errorMessage) + .font(.system(size: 13)) + .foregroundStyle(Color(hex: "C25555")) + .padding(.top, 14) + } + + Button { + Task { await submit() } + } label: { + HStack { + Spacer() + if isSubmitting { + ProgressView().tint(.black) + } else { + Text("Add feed").font(.system(size: 16, weight: .bold)).foregroundStyle(.black) + } + Spacer() + } + .frame(height: 52) + .background(canSubmit ? Palette.orange : Palette.surface2) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(!canSubmit) + .padding(.top, 28) + + Spacer() + } + .padding(.horizontal, 20) + } + .preferredColorScheme(.dark) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + + private func field(label: String, text: Binding, placeholder: String, + mono: Bool, keyboard: UIKeyboardType) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(label) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .kerning(0.8) + .foregroundStyle(Color(hex: "666666")) + TextField("", text: text, + prompt: Text(placeholder).foregroundColor(Color(hex: "444444"))) + .font(.system(size: 15, weight: .regular, design: mono ? .monospaced : .default)) + .foregroundStyle(mono ? Palette.orange : .white) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(keyboard) + .padding(14) + .background(Palette.surface) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Palette.surface2, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + + private func submit() async { + isSubmitting = true + errorMessage = nil + let ok = await onAdd(url.trimmingCharacters(in: .whitespaces), + name.trimmingCharacters(in: .whitespaces)) + isSubmitting = false + if ok { dismiss() } else { errorMessage = "Couldn’t add feed. Check the URL and try again." } + } +} diff --git a/Jarvis/Views/Feeds/FeedManagerView.swift b/Jarvis/Views/Feeds/FeedManagerView.swift new file mode 100644 index 0000000..b07e1d9 --- /dev/null +++ b/Jarvis/Views/Feeds/FeedManagerView.swift @@ -0,0 +1,329 @@ +// FeedManagerView.swift +// Jarvis — feed health & management. Healthy vs. needs-attention, live health +// updates over WebSocket, swipe to delete, add-feed sheet. + +import SwiftUI + +@MainActor +final class FeedManagerViewModel: ObservableObject { + @Published var feeds: [Feed] = [] + @Published var isLoading = false + @Published var error: String? + + private let api = APIClient.shared + + func load() async { + isLoading = true + error = nil + do { + feeds = try await api.fetchFeeds() + } catch let e as APIError { + error = e.errorDescription + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func add(url: String, name: String) async -> Bool { + do { + let feed = try await api.addFeed(url: url, name: name) + feeds.append(feed) + return true + } catch let e as APIError { + error = e.errorDescription + return false + } catch { + self.error = error.localizedDescription + return false + } + } + + func delete(_ feed: Feed) async { + // Optimistic removal; restore on failure. + let snapshot = feeds + feeds.removeAll { $0.id == feed.id } + do { + try await api.deleteFeed(id: feed.id) + } catch { + feeds = snapshot + self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription + } + } + + /// Apply a live `feed.health` WebSocket event. + func applyHealth(_ event: WSEvent) { + guard let id = event.feedId, let health = event.health, + let idx = feeds.firstIndex(where: { $0.id == id }) else { return } + let f = feeds[idx] + feeds[idx] = Feed( + id: f.id, name: f.name, url: f.url, health: health, + pollIntervalSeconds: f.pollIntervalSeconds, + failureCount: event.failureCount ?? f.failureCount, + lastFetchedAt: f.lastFetchedAt, articleCountToday: f.articleCountToday + ) + } +} + +struct FeedManagerView: View { + @EnvironmentObject var ws: WebSocketManager + @EnvironmentObject var settings: ServerSettings + @StateObject private var vm = FeedManagerViewModel() + + @State private var query = "" + @State private var showAdd = false + @State private var showConnectivity = false + + private var filtered: [Feed] { + guard !query.isEmpty else { return vm.feeds } + return vm.feeds.filter { + $0.name.localizedCaseInsensitiveContains(query) || + $0.url.localizedCaseInsensitiveContains(query) + } + } + private var healthy: [Feed] { filtered.filter { $0.health == .active } } + private var attention: [Feed] { filtered.filter { $0.health != .active } } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + VStack(spacing: 0) { + header + serverCard + searchBar + feedList + } + } + .preferredColorScheme(.dark) + .presentationDragIndicator(.visible) + .sheet(isPresented: $showAdd) { + AddFeedSheet { url, name in + await vm.add(url: url, name: name) + } + } + .sheet(isPresented: $showConnectivity) { + ConnectivityView() + } + .task { await vm.load() } + .onReceive(NotificationCenter.default.publisher(for: .feedHealthChanged)) { note in + if let event = note.object as? WSEvent { vm.applyHealth(event) } + } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .center) { + JarvisWordmark(size: 26) + Spacer() + Button { showAdd = true } label: { + HStack(spacing: 5) { + Image(systemName: "plus").font(.system(size: 13, weight: .heavy)) + Text("Add feed").font(.system(size: 14, weight: .bold)) + } + .foregroundStyle(.black) + .padding(.horizontal, 14) + .frame(height: 36) + .background(Palette.orange) + .clipShape(Capsule()) + } + } + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, 14) + } + + // MARK: - Platform connection card + + private var serverCard: some View { + Button { showConnectivity = true } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("PLATFORM") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .kerning(0.8) + .foregroundStyle(Color(hex: "555555")) + Text(settings.host ?? "not configured") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundStyle(Palette.orange) + } + Spacer() + HStack(spacing: 6) { + Circle().fill(ws.connectionState.dotColor).frame(width: 7, height: 7) + Text(ws.connectionState.label.uppercased()) + .font(.system(size: 10, weight: .bold)) + .kerning(0.6) + .foregroundStyle(ws.connectionState.isLive ? Color(hex: "33C25E") : Color(hex: "888888")) + } + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Color(hex: "555555")) + } + .padding(14) + .background(Palette.surface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + + // MARK: - Search + + private var searchBar: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 14)) + .foregroundStyle(Color(hex: "555555")) + TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Color(hex: "555555"))) + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(.white) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + .padding(.horizontal, 14) + .frame(height: 44) + .background(Palette.surface2) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + + // MARK: - List + + private var feedList: some View { + List { + if vm.isLoading && vm.feeds.isEmpty { + loadingRow + } + if !healthy.isEmpty { + section(title: "HEALTHY", feeds: healthy) + } + if !attention.isEmpty { + section(title: "NEEDS ATTENTION", feeds: attention) + } + if !vm.isLoading && filtered.isEmpty { + emptyRow + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.black) + .refreshable { await vm.load() } + } + + private func section(title: String, feeds: [Feed]) -> some View { + Section { + ForEach(feeds) { feed in + FeedRowView(feed: feed) + .listRowBackground(Color.black) + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) + .listRowSeparatorTint(Palette.hairline) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { await vm.delete(feed) } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } header: { + Text(title) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .kerning(0.8) + .foregroundStyle(Color(hex: "555555")) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 6, trailing: 16)) + } + } + + private var loadingRow: some View { + ProgressView().tint(Palette.orange) + .frame(maxWidth: .infinity) + .listRowBackground(Color.black) + .listRowSeparator(.hidden) + } + + private var emptyRow: some View { + Text(query.isEmpty ? (vm.error ?? "No feeds configured.") : "No feeds match “\(query)”.") + .font(.system(size: 13)) + .foregroundStyle(Color(hex: "555555")) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowBackground(Color.black) + .listRowSeparator(.hidden) + } +} + +// MARK: - Feed row + +struct FeedRowView: View { + let feed: Feed + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "dot.radiowaves.up.forward") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color(hex: "777777")) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 4) { + Text(feed.name) + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + Text(metaLine) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Color(hex: "666666")) + .lineLimit(1) + } + + Spacer(minLength: 8) + healthDot + } + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + + private var metaLine: String { + let interval = pollText(feed.pollIntervalSeconds) + let base = "Polls \(interval) · \(feed.articleCountToday) today" + if feed.health == .failing { + return base + " · ⚠ \(retryText)" + } + if feed.health == .dead { + return base + " · dead" + } + return base + } + + private var retryText: String { + guard let last = feed.lastFetchedAt else { return "retrying" } + let next = last.addingTimeInterval(Double(feed.pollIntervalSeconds)) + let secs = Int(next.timeIntervalSinceNow) + return secs > 0 ? "retry \(secs)s" : "retrying…" + } + + @ViewBuilder + private var healthDot: some View { + // Live-updating countdown for failing feeds. + if feed.health == .failing { + TimelineView(.periodic(from: .now, by: 1)) { _ in + dot(color: Palette.healthFailing) + } + } else { + dot(color: feed.health == .active ? Palette.healthActive : Palette.healthDead) + } + } + + private func dot(color: Color) -> some View { + Circle() + .fill(color) + .frame(width: 10, height: 10) + .overlay(Circle().stroke(color.opacity(0.9), lineWidth: 3).blur(radius: 2)) + } + + private func pollText(_ secs: Int) -> String { + if secs % 3600 == 0 { return "every \(secs / 3600) hr" } + if secs >= 60 { return "every \(secs / 60) min" } + return "every \(secs) s" + } +} diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift new file mode 100644 index 0000000..310081a --- /dev/null +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -0,0 +1,245 @@ +// SignalFeedView.swift +// Jarvis — home screen. Stories ranked by signal score, live connection state, +// topic filters, pull-to-refresh, infinite scroll. + +import SwiftUI +import SwiftData + +// Reusable wordmark: J in KisaniOrange, the rest white, weight 800, kerning -2. +struct JarvisWordmark: View { + var size: CGFloat = 30 + var body: some View { + (Text("J").foregroundColor(Palette.orange) + Text("arvis").foregroundColor(.white)) + .font(.system(size: size, weight: .heavy)) + .kerning(-2) + } +} + +struct SignalFeedView: View { + @EnvironmentObject var store: StoryStore + @EnvironmentObject var ws: WebSocketManager + @Environment(\.modelContext) private var modelContext + + @Query private var cachedStories: [CachedStory] + @Query private var cachedArticles: [CachedArticle] + @State private var showFeeds = false + + /// Stories that have full article content cached → eligible for the green dot. + private var cachedStoryIds: Set { Set(cachedArticles.map(\.storyId)) } + + /// Live stories when online; cached fallback when the feed is empty & offline. + private var displayStories: [StorySummary] { + if !store.stories.isEmpty { + return store.stories.sorted { $0.signalScore > $1.signalScore } + } + if !ws.connectionState.isLive { + return cachedStories + .map(StorySummary.init(cached:)) + .sorted { $0.signalScore > $1.signalScore } + } + return [] + } + + var body: some View { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 0) { + header + syncBar + topicPills + columnHeaders + Divider().overlay(Palette.hairline) + feedList + } + } + .navigationBarHidden(true) + .navigationDestination(for: StorySummary.self) { story in + StoryDetailView(story: story) + } + .navigationDestination(for: ArticleRoute.self) { route in + ArticleReaderView(route: route) + } + .sheet(isPresented: $showFeeds) { + FeedManagerView() + } + } + .preferredColorScheme(.dark) + .onChange(of: store.stories) { _, newValue in + cacheStories(newValue) + } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .center) { + JarvisWordmark(size: 30) + Spacer() + ConnectionBanner(state: ws.connectionState) + Button { + showFeeds = true + } label: { + Image(systemName: "dot.radiowaves.up.forward") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(hex: "888888")) + .frame(width: 44, height: 44) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + + // MARK: - Sync bar + + private var syncBar: some View { + Text(syncText) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Color(hex: "5A5A5A")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + + private var syncText: String { + let ago = syncedMinutesAgo(store.lastSyncedAt) + let phrase = (ago == "just now" || ago == "never") ? ago : "\(ago) ago" + if ws.connectionState.isLive { + return "Synced \(phrase) · \(cachedStoryIds.count) cached" + } else { + return "Last synced \(phrase)" + } + } + + // MARK: - Topic pills + + private var topicPills: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Topic.filters, id: \.label) { filter in + let selected = store.selectedTopic == filter.slug + Button { + Task { await store.setTopic(filter.slug) } + } label: { + Text(filter.label) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(selected ? .black : Color(hex: "AAAAAA")) + .padding(.horizontal, 14) + .frame(height: 32) + .background(selected ? Palette.orange : Palette.surface) + .clipShape(Capsule()) + } + } + } + .padding(.horizontal, 16) + } + .padding(.bottom, 14) + } + + // MARK: - Column headers + + private var columnHeaders: some View { + HStack { + Text("STORY") + .kerning(0.8) + Spacer() + Text("SIGNAL ↓") + .kerning(0.8) + } + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(Color(hex: "555555")) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + + // MARK: - Feed list + + private var feedList: some View { + ScrollView { + LazyVStack(spacing: 0) { + if displayStories.isEmpty { + emptyState + } else { + ForEach(displayStories) { story in + NavigationLink(value: story) { + StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id)) + } + .buttonStyle(.plain) + .onAppear { + if story.id == displayStories.last?.id { + Task { await store.loadMore() } + } + } + } + + if store.isLoadingMore { + ProgressView() + .tint(Palette.orange) + .padding(.vertical, 20) + } + } + } + } + .refreshable { + await store.loadStories(refresh: true) + } + } + + private var emptyState: some View { + VStack(spacing: 12) { + if store.isLoading { + ProgressView().tint(Palette.orange) + } else { + Image(systemName: ws.connectionState.isLive ? "antenna.radiowaves.left.and.right" : "wifi.slash") + .font(.system(size: 30)) + .foregroundStyle(Color(hex: "333333")) + Text(ws.connectionState.isLive ? "No signals yet" : "Offline — no cached stories") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(Color(hex: "555555")) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 80) + } + + // MARK: - Caching + + private func cacheStories(_ stories: [StorySummary]) { + for summary in stories { + let id = summary.id + let existing = try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id }) + ) + if let found = existing?.first { + found.signalScore = summary.signalScore + found.sourceCount = summary.sourceCount + found.updatedAt = summary.updatedAt + } else { + modelContext.insert(CachedStory(from: summary)) + } + } + try? modelContext.save() + } +} + +// Map a cached story back into a StorySummary for offline rendering. +extension StorySummary { + init(cached: CachedStory) { + self.init( + id: cached.id, + headline: cached.headline, + summary: cached.summary, + topic: cached.topic, + signalScore: cached.signalScore, + scoreBreakdown: ScoreBreakdown(sourceAuthority: 0, freshness: 0, localRelevance: 0, + crossSourceConfirmation: 0, topicImportance: 0), + sourceCount: cached.sourceCount, + sources: [], + consensus: cached.consensus, + conflict: cached.conflict, + updatedAt: cached.updatedAt, + createdAt: cached.updatedAt + ) + } +} diff --git a/Jarvis/Views/Home/StoryRowView.swift b/Jarvis/Views/Home/StoryRowView.swift new file mode 100644 index 0000000..8f73c0d --- /dev/null +++ b/Jarvis/Views/Home/StoryRowView.swift @@ -0,0 +1,89 @@ +// StoryRowView.swift +// Jarvis — one row in the signal feed. Everything fades with the signal score. + +import SwiftUI + +struct StoryRowView: View { + let story: StorySummary + /// True when the story's articles are cached in SwiftData (offline-ready). + var isCached: Bool = false + + private var sourceNames: [String] { story.sources.map(\.name) } + + var body: some View { + HStack(alignment: .top, spacing: 0) { + SignalStripe(score: story.signalScore) + + VStack(alignment: .leading, spacing: 9) { + // Title (+ cached dot) + HStack(alignment: .top, spacing: 8) { + Text(story.headline) + .font(.system(size: 17, weight: .heavy)) + .foregroundStyle(Signal.titleColor(story.signalScore)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + + if isCached { + CachedDot() + .padding(.top, 6) + } + } + + // Source chips + if !sourceNames.isEmpty { + SourceChips(names: sourceNames, total: story.sourceCount) + } + + // Metadata + Text(metaLine) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Color(hex: "666666")) + } + .padding(.leading, 14) + .padding(.vertical, 16) + + Spacer(minLength: 12) + + // Signal score column + Text("\(story.signalScore)") + .font(.system(size: 22, weight: .heavy, design: .monospaced)) + .foregroundStyle(Signal.scoreColor(story.signalScore)) + .padding(.trailing, 16) + .padding(.top, 16) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black) + .overlay(alignment: .bottom) { + Rectangle().fill(Palette.hairline).frame(height: 0.5) + } + .contentShape(Rectangle()) + } + + private var metaLine: String { + let sources = "\(story.sourceCount) source\(story.sourceCount == 1 ? "" : "s")" + return "\(sources) · \(Topic.label(story.topic)) · \(story.updatedAt.timeAgoShort()) ago" + } +} + +#Preview { + let sources = [ + StorySource(id: "1", name: "Daily Monitor", url: "", publishedAt: Date(), isBreaking: true), + StorySource(id: "2", name: "NilePost", url: "", publishedAt: Date(), isBreaking: false), + StorySource(id: "3", name: "The EastAfrican", url: "", publishedAt: Date(), isBreaking: false), + ] + let breakdown = ScoreBreakdown(sourceAuthority: 28, freshness: 22, localRelevance: 15, crossSourceConfirmation: 16, topicImportance: 10) + return VStack(spacing: 0) { + ForEach([97, 64, 33, 14], id: \.self) { score in + StoryRowView( + story: StorySummary( + id: "\(score)", headline: "Bank of Uganda cuts interest rates by 50 basis points", + summary: "", topic: "finance", signalScore: score, scoreBreakdown: breakdown, + sourceCount: 7, sources: sources, consensus: nil, conflict: nil, + updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()), + isCached: score == 64 + ) + } + } + .background(Color.black) + .preferredColorScheme(.dark) +} diff --git a/Jarvis/Views/Onboarding/OnboardingView.swift b/Jarvis/Views/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..82ef826 --- /dev/null +++ b/Jarvis/Views/Onboarding/OnboardingView.swift @@ -0,0 +1,128 @@ +// OnboardingView.swift + +import SwiftUI + +struct OnboardingView: View { + @EnvironmentObject var settings: ServerSettings + @EnvironmentObject var connectivity: ConnectivitySettings + @State private var host = "" + @State private var isConnecting = false + @State private var errorMessage: String? + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 0) { + Spacer().frame(height: 48) + + // Wordmark + Group { + Text("J").foregroundColor(Color("KisaniOrange")) + + Text("arvis").foregroundColor(.white) + } + .font(.system(size: 48, weight: .black, design: .default)) + .kerning(-2) + + Text("Point Jarvis at your platform\nto start receiving signals.") + .font(.system(size: 16)) + .foregroundColor(Color(.systemGray)) + .lineSpacing(4) + .padding(.top, 10) + .padding(.bottom, 40) + + // Field label + Text("Server address") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(Color(.systemGray2)) + .kerning(0.8) + .textCase(.uppercase) + .padding(.bottom, 8) + + // Input field + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.system(size: 18)) + .foregroundColor(host.isEmpty ? Color(.systemGray3) : Color("KisaniOrange")) + TextField("192.168.1.42:8080", text: $host) + .font(.system(size: 15, design: .monospaced)) + .foregroundColor(Color("KisaniOrange")) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.URL) + } + .padding(14) + .background(host.isEmpty ? Color(.systemGray6).opacity(0.1) : Color("KisaniOrange").opacity(0.07)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(host.isEmpty ? Color(.systemGray5) : Color("KisaniOrange"), lineWidth: 0.5) + ) + .cornerRadius(12) + + Text("http:// or https:// · local network or VPN") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color(.systemGray4)) + .padding(.top, 8) + .padding(.bottom, 32) + + if let errorMessage { + Text(errorMessage) + .font(.system(size: 13)) + .foregroundColor(.red.opacity(0.8)) + .padding(.bottom, 12) + } + + // Connect button + Button { + Task { await connect() } + } label: { + HStack { + Spacer() + if isConnecting { + ProgressView().tint(.white) + } else { + Text("Connect to Jarvis") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + } + Spacer() + } + .frame(height: 52) + .background(host.isEmpty ? Color(.systemGray5) : Color("KisaniOrange")) + .cornerRadius(14) + } + .disabled(host.isEmpty || isConnecting) + + Button("Scan QR code instead") {} + .font(.system(size: 14)) + .foregroundColor(Color(.systemGray3)) + .frame(maxWidth: .infinity) + .frame(height: 48) + + Spacer() + + Text("Self-hosted · no account needed") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color(.systemGray5)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 24) + } + .padding(.horizontal, 24) + } + } + + private func connect() async { + isConnecting = true + errorMessage = nil + do { + try await settings.save(host: host) + // Seed the Direct/LAN endpoint so connectivity can fail over to a VPN later. + if connectivity.directHost.trimmingCharacters(in: .whitespaces).isEmpty { + connectivity.directHost = host.trimmingCharacters(in: .whitespaces) + } + } catch { + errorMessage = error.localizedDescription + } + isConnecting = false + } +} diff --git a/Jarvis/Views/Reader/ArticleReaderView.swift b/Jarvis/Views/Reader/ArticleReaderView.swift new file mode 100644 index 0000000..1a111a4 --- /dev/null +++ b/Jarvis/Views/Reader/ArticleReaderView.swift @@ -0,0 +1,250 @@ +// ArticleReaderView.swift +// Jarvis — full article. Caches to SwiftData on load; reads from cache offline. + +import SwiftUI +import SwiftData + +@MainActor +final class ArticleReaderViewModel: ObservableObject { + @Published var article: Article? + @Published var siblings: [TimelineEntry] = [] + @Published var isLoading = false + @Published var error: String? + + private let api = APIClient.shared + + func load(route: ArticleRoute, context: ModelContext) async { + isLoading = true + error = nil + do { + let art = try await api.fetchArticle(id: route.articleId) + article = art + cache(art, context: context) + + if let detail = try? await api.fetchStory(id: route.storyId) { + siblings = detail.timeline + .filter { $0.articleId != route.articleId } + .sorted { $0.publishedAt < $1.publishedAt } + } + } catch { + // Offline: fall back to the SwiftData cache. + loadCached(route: route, context: context) + if article == nil { + self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription + } + } + isLoading = false + } + + private func cache(_ art: Article, context: ModelContext) { + let id = art.id + let existing = try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id }) + ) + if existing?.first == nil { + context.insert(CachedArticle(from: art)) + try? context.save() + } + } + + private func loadCached(route: ArticleRoute, context: ModelContext) { + let id = route.articleId + if let cached = (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id }) + ))?.first { + article = Article(cached: cached) + } + let sid = route.storyId + let arts = (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.storyId == sid }) + )) ?? [] + siblings = arts + .filter { $0.id != route.articleId } + .map { TimelineEntry(articleId: $0.id, source: $0.source, headline: $0.headline, + publishedAt: $0.publishedAt, isBreaking: false) } + } +} + +struct ArticleReaderView: View { + let route: ArticleRoute + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @StateObject private var vm = ArticleReaderViewModel() + @Query private var cachedArticles: [CachedArticle] + + private var article: Article? { vm.article } + private var isCached: Bool { cachedArticles.contains { $0.id == route.articleId } } + + private var backTitle: String { + let h = route.parentHeadline + return h.count > 30 ? String(h.prefix(30)).trimmingCharacters(in: .whitespaces) + "…" : h + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + ScrollView { + if let article { + content(article) + } else if vm.isLoading { + ProgressView().tint(Palette.orange).padding(.top, 80) + } else { + Text(vm.error ?? "Article unavailable.") + .font(.system(size: 14)) + .foregroundStyle(Color(hex: "555555")) + .padding(.top, 80) + } + } + } + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { backButton } + } + .toolbarBackground(Color.black, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .preferredColorScheme(.dark) + .task { await vm.load(route: route, context: modelContext) } + } + + private var backButton: some View { + Button { dismiss() } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left").font(.system(size: 15, weight: .bold)) + Text(backTitle).font(.system(size: 16, weight: .regular)).lineLimit(1) + } + .foregroundStyle(Palette.orange) + } + } + + @ViewBuilder + private func content(_ article: Article) -> some View { + VStack(alignment: .leading, spacing: 16) { + // Source pill + timestamp + cached badge + HStack(spacing: 10) { + Text(article.source) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Palette.orange) + .clipShape(Capsule()) + Text(article.publishedAt.clockShort) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .foregroundStyle(Color(hex: "666666")) + if isCached { CachedBadge(text: "Cached") } + Spacer() + } + + Text(article.headline) + .font(.system(size: 26, weight: .heavy)) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + + heroImage(article.imageUrl) + + if let author = article.author, !author.isEmpty { + Text("By \(author)") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(Color(hex: "777777")) + } + + Text(article.body) + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(Palette.bodyText) // #4A4A4A + .lineSpacing(10) // ~1.65 line-height at 16pt + .fixedSize(horizontal: false, vertical: true) + + if !vm.siblings.isEmpty { + Divider().overlay(Palette.hairline).padding(.vertical, 8) + clusterSection + } + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + } + + private func heroImage(_ urlString: String?) -> some View { + let fallback = RoundedRectangle(cornerRadius: 10).fill(Palette.surface2) + return Group { + if let urlString, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: .fill) + case .empty: + fallback.overlay(ProgressView().tint(Color(hex: "555555"))) + case .failure: + fallback.overlay(Image(systemName: "photo") + .font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A"))) + @unknown default: + fallback + } + } + } else { + fallback.overlay(Image(systemName: "photo") + .font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A"))) + } + } + .frame(height: 200) + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private var clusterSection: some View { + VStack(alignment: .leading, spacing: 0) { + Text("MORE FROM THIS CLUSTER") + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .kerning(0.8) + .foregroundStyle(Color(hex: "555555")) + .padding(.bottom, 12) + + ForEach(vm.siblings.prefix(3)) { entry in + NavigationLink(value: ArticleRoute(articleId: entry.articleId, + storyId: route.storyId, + parentHeadline: route.parentHeadline)) { + clusterRow(entry) + } + .buttonStyle(.plain) + } + } + } + + private func clusterRow(_ entry: TimelineEntry) -> some View { + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(entry.source) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Palette.orange) + Spacer() + Text(entry.publishedAt.clockShort) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Color(hex: "555555")) + } + Text(entry.headline) + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(Color(hex: "CFCFCF")) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } + .padding(.vertical, 12) + .overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) } + .contentShape(Rectangle()) + } +} + +// Reconstruct an Article from its cached copy for offline display. +extension Article { + init(cached: CachedArticle) { + self.init( + id: cached.id, + storyId: cached.storyId, + source: cached.source, + sourceUrl: "", + headline: cached.headline, + body: cached.body, + imageUrl: cached.imageUrl, + author: cached.author, + publishedAt: cached.publishedAt + ) + } +} diff --git a/Jarvis/Views/RootTabView.swift b/Jarvis/Views/RootTabView.swift new file mode 100644 index 0000000..66fd036 --- /dev/null +++ b/Jarvis/Views/RootTabView.swift @@ -0,0 +1,34 @@ +// RootTabView.swift + +import SwiftUI + +struct RootTabView: View { + @EnvironmentObject var store: StoryStore + + var body: some View { + TabView { + SignalFeedView() + .tabItem { + Label("Today", systemImage: "square.grid.2x2") + } + + LatestView() + .tabItem { + Label("Latest", systemImage: "newspaper") + } + + SavedView() + .tabItem { + Label("Saved", systemImage: "bookmark") + } + + SearchView() + .tabItem { + Label("Search", systemImage: "magnifyingglass") + } + } + .tint(Color("KisaniOrange")) + .preferredColorScheme(.dark) + .task { await store.loadStories(refresh: true) } + } +} diff --git a/Jarvis/Views/SecondaryTabs.swift b/Jarvis/Views/SecondaryTabs.swift new file mode 100644 index 0000000..b624166 --- /dev/null +++ b/Jarvis/Views/SecondaryTabs.swift @@ -0,0 +1,178 @@ +// SecondaryTabs.swift +// Jarvis — Latest / Saved / Search. Required by RootTabView; they reuse the +// signal-feed components rather than introducing new surfaces. + +import SwiftUI +import SwiftData + +// Shared destinations so taps work inside each tab's own NavigationStack. +private extension View { + func jarvisDestinations() -> some View { + self + .navigationDestination(for: StorySummary.self) { StoryDetailView(story: $0) } + .navigationDestination(for: ArticleRoute.self) { ArticleReaderView(route: $0) } + } +} + +private struct TabHeader: View { + let title: String + var body: some View { + HStack { + JarvisWordmark(size: 26) + Text(title) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(Color(hex: "666666")) + .padding(.top, 6) + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 12) + } +} + +private struct StoryList: View { + let stories: [StorySummary] + let cachedIds: Set + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(stories) { story in + NavigationLink(value: story) { + StoryRowView(story: story, isCached: cachedIds.contains(story.id)) + } + .buttonStyle(.plain) + } + } + } + } +} + +// MARK: - Latest (most recently formed stories first) + +struct LatestView: View { + @EnvironmentObject var store: StoryStore + @Query private var cachedArticles: [CachedArticle] + + private var latest: [StorySummary] { + store.stories.sorted { $0.createdAt > $1.createdAt } + } + + var body: some View { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + VStack(spacing: 0) { + TabHeader(title: "Latest") + Divider().overlay(Palette.hairline) + StoryList(stories: latest, cachedIds: Set(cachedArticles.map(\.storyId))) + } + } + .navigationBarHidden(true) + .jarvisDestinations() + } + .preferredColorScheme(.dark) + } +} + +// MARK: - Saved (offline cache) + +struct SavedView: View { + @Query private var cachedStories: [CachedStory] + @Query private var cachedArticles: [CachedArticle] + + private var stories: [StorySummary] { + cachedStories.map(StorySummary.init(cached:)).sorted { $0.signalScore > $1.signalScore } + } + + var body: some View { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + VStack(spacing: 0) { + TabHeader(title: "Saved · offline") + Divider().overlay(Palette.hairline) + if stories.isEmpty { + VStack(spacing: 12) { + Image(systemName: "bookmark") + .font(.system(size: 28)).foregroundStyle(Color(hex: "333333")) + Text("Nothing cached yet") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(Color(hex: "555555")) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + StoryList(stories: stories, cachedIds: Set(cachedArticles.map(\.storyId))) + } + } + } + .navigationBarHidden(true) + .jarvisDestinations() + } + .preferredColorScheme(.dark) + } +} + +// MARK: - Search + +struct SearchView: View { + @EnvironmentObject var store: StoryStore + @Query private var cachedArticles: [CachedArticle] + @State private var query = "" + + private var results: [StorySummary] { + guard !query.isEmpty else { return [] } + return store.stories + .filter { $0.headline.localizedCaseInsensitiveContains(query) + || $0.summary.localizedCaseInsensitiveContains(query) } + .sorted { $0.signalScore > $1.signalScore } + } + + var body: some View { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + VStack(spacing: 0) { + TabHeader(title: "Search") + searchBar + Divider().overlay(Palette.hairline) + if query.isEmpty { + placeholder("Search stories by headline") + } else if results.isEmpty { + placeholder("No matches for “\(query)”") + } else { + StoryList(stories: results, cachedIds: Set(cachedArticles.map(\.storyId))) + } + } + } + .navigationBarHidden(true) + .jarvisDestinations() + } + .preferredColorScheme(.dark) + } + + private var searchBar: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 14)).foregroundStyle(Color(hex: "555555")) + TextField("", text: $query, + prompt: Text("Search").foregroundColor(Color(hex: "555555"))) + .font(.system(size: 15)) + .foregroundStyle(.white) + .autocorrectionDisabled() + } + .padding(.horizontal, 14) + .frame(height: 44) + .background(Palette.surface2) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal, 16) + .padding(.bottom, 10) + } + + private func placeholder(_ text: String) -> some View { + Text(text) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(Color(hex: "555555")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Jarvis/Views/Shared/CachedBadge.swift b/Jarvis/Views/Shared/CachedBadge.swift new file mode 100644 index 0000000..1e89caa --- /dev/null +++ b/Jarvis/Views/Shared/CachedBadge.swift @@ -0,0 +1,43 @@ +// CachedBadge.swift +// Jarvis — offline-cache indicators (a bare dot, and a labelled green badge). + +import SwiftUI + +/// Tiny green dot — used in the signal feed rows when a story is cached offline. +struct CachedDot: View { + var size: CGFloat = 7 + var body: some View { + Circle() + .fill(Palette.cachedGreen) + .frame(width: size, height: size) + .overlay(Circle().stroke(Color(hex: "3E7E3E"), lineWidth: 0.5)) + } +} + +/// Labelled green badge — "Cached", or "All N articles cached · available offline". +struct CachedBadge: View { + let text: String + + var body: some View { + HStack(spacing: 6) { + CachedDot(size: 6) + Text(text) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Color(hex: "6FBF6F")) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Palette.cachedGreen.opacity(0.18)) + .clipShape(Capsule()) + } +} + +#Preview { + VStack(spacing: 16) { + CachedDot() + CachedBadge(text: "Cached") + CachedBadge(text: "All 7 articles cached · available offline") + } + .padding() + .background(Color.black) +} diff --git a/Jarvis/Views/Shared/ConnectionBanner.swift b/Jarvis/Views/Shared/ConnectionBanner.swift new file mode 100644 index 0000000..7574161 --- /dev/null +++ b/Jarvis/Views/Shared/ConnectionBanner.swift @@ -0,0 +1,60 @@ +// ConnectionBanner.swift +// Jarvis — Live / Offline / Reconnecting indicator driven by WebSocketManager. + +import SwiftUI + +extension ConnectionState { + var isLive: Bool { + if case .connected = self { return true } + return false + } + + var label: String { + switch self { + case .connected: return "Live" + case .connecting: return "Connecting" + case .reconnecting: return "Reconnecting" + case .disconnected: return "Offline" + } + } + + var dotColor: Color { + switch self { + case .connected: return Color(hex: "33C25E") + case .connecting, + .reconnecting: return Palette.healthFailing + case .disconnected: return Color(hex: "555555") + } + } +} + +/// Compact Live/Offline pill — a colored dot + state label. +struct ConnectionBanner: View { + let state: ConnectionState + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(state.dotColor) + .frame(width: 7, height: 7) + Text(state.label.uppercased()) + .font(.system(size: 11, weight: .bold)) + .kerning(0.6) + .foregroundStyle(state.isLive ? Color(hex: "33C25E") : Color(hex: "888888")) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Palette.surface) + .clipShape(Capsule()) + } +} + +#Preview { + VStack(spacing: 12) { + ConnectionBanner(state: .connected) + ConnectionBanner(state: .reconnecting(attempt: 2)) + ConnectionBanner(state: .disconnected) + } + .padding() + .background(Color.black) +} diff --git a/Jarvis/Views/Shared/SignalStripe.swift b/Jarvis/Views/Shared/SignalStripe.swift new file mode 100644 index 0000000..e41be24 --- /dev/null +++ b/Jarvis/Views/Shared/SignalStripe.swift @@ -0,0 +1,31 @@ +// SignalStripe.swift +// Jarvis — the 3pt left-edge stripe whose color is tied to the signal score. + +import SwiftUI + +struct SignalStripe: View { + let score: Int + var width: CGFloat = 3 + + var body: some View { + Rectangle() + .fill(Signal.stripeColor(score)) + .frame(width: width) + } +} + +#Preview { + HStack(spacing: 24) { + ForEach([97, 78, 55, 35, 12, 0], id: \.self) { s in + VStack { + SignalStripe(score: s) + .frame(height: 60) + Text("\(s)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Signal.scoreColor(s)) + } + } + } + .padding() + .background(Color.black) +} diff --git a/Jarvis/Views/Shared/SourceChips.swift b/Jarvis/Views/Shared/SourceChips.swift new file mode 100644 index 0000000..7db6033 --- /dev/null +++ b/Jarvis/Views/Shared/SourceChips.swift @@ -0,0 +1,42 @@ +// SourceChips.swift +// Jarvis — names the first 2 sources, collapses the rest into "+N". + +import SwiftUI + +struct SourceChips: View { + /// Source names in display order. + let names: [String] + /// Total number of sources (may exceed `names.count`). + let total: Int + var maxNamed: Int = 2 + + private var named: [String] { Array(names.prefix(maxNamed)) } + private var overflow: Int { max(0, total - named.count) } + + var body: some View { + HStack(spacing: 6) { + ForEach(named, id: \.self) { name in + chip(name) + } + if overflow > 0 { + chip("+\(overflow)", muted: true) + } + } + } + + private func chip(_ text: String, muted: Bool = false) -> some View { + Text(text) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(muted ? Color(hex: "777777") : Color(hex: "CCCCCC")) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Palette.surface2) + .clipShape(Capsule()) + } +} + +#Preview { + SourceChips(names: ["Daily Monitor", "NilePost", "The EastAfrican"], total: 7) + .padding() + .background(Color.black) +} diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift new file mode 100644 index 0000000..cc7aa65 --- /dev/null +++ b/Jarvis/Views/Shared/Theme.swift @@ -0,0 +1,162 @@ +// Theme.swift +// Jarvis — shared design tokens, signal-score fade math, formatting, nav routes. +// Every hex value here is from the spec; nothing is invented. + +import SwiftUI + +// MARK: - Hex colors + +extension Color { + init(hex: String) { + let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + var v: UInt64 = 0 + Scanner(string: s).scanHexInt64(&v) + let r, g, b: Double + switch s.count { + case 6: + r = Double((v >> 16) & 0xFF) / 255 + g = Double((v >> 8) & 0xFF) / 255 + b = Double(v & 0xFF) / 255 + default: + r = 0; g = 0; b = 0 + } + self = Color(.sRGB, red: r, green: g, blue: b, opacity: 1) + } +} + +// MARK: - Palette + +enum Palette { + static let orange = Color("KisaniOrange") // #FF5C00 + static let black = Color.black // #000000 + static let surface = Color(hex: "111111") + static let surface2 = Color(hex: "1A1A1A") + static let hairline = Color(hex: "1A1A1A") + + // health + static let healthActive = Color(hex: "2A5A2A") + static let healthFailing = Color(hex: "AA6600") + static let healthDead = Color(hex: "5A1A1A") + + // blocks + static let consensusBorder = Color(hex: "FF5C00") + static let consensusFill = Color(hex: "1F1206") // dark orange + static let conflictBorder = Color(hex: "5A1A1A") // dark red + static let conflictFill = Color(hex: "1A0C0C") // dark red + + static let cachedGreen = Color(hex: "2A5A2A") + static let bodyText = Color(hex: "4A4A4A") +} + +// MARK: - Signal-score fade +// +// Spec anchors: +// stripe : 97 → #FF5C00, fades to #1A1A1A at 0 +// title : 97 → white, 20 → #444444, 0–19 near invisible +// score : 80–100 full orange · 50–79 dimmed orange · 20–49 dark brown · 0–19 near invisible + +enum Signal { + private static func lerp(_ a: (Double, Double, Double), + _ b: (Double, Double, Double), + _ t: Double) -> Color { + let t = max(0, min(1, t)) + return Color(.sRGB, + red: a.0 + (b.0 - a.0) * t, + green: a.1 + (b.1 - a.1) * t, + blue: a.2 + (b.2 - a.2) * t, + opacity: 1) + } + + /// 3pt left stripe color: #1A1A1A (0) → #FF5C00 (97+). + static func stripeColor(_ score: Int) -> Color { + let t = Double(score) / 97.0 + return lerp((0x1A/255, 0x1A/255, 0x1A/255), // #1A1A1A + (0xFF/255, 0x5C/255, 0x00/255), // #FF5C00 + t) + } + + /// Story title color: white (97+) → #444444 (20) → near invisible (0). + static func titleColor(_ score: Int) -> Color { + if score >= 97 { return .white } + if score >= 20 { + let t = Double(score - 20) / Double(97 - 20) + return lerp((0x44/255, 0x44/255, 0x44/255), // #444444 + (1, 1, 1), // white + t) + } + // 0–19 near invisible: #1C1C1C → #444444 + let t = Double(score) / 20.0 + return lerp((0x1C/255, 0x1C/255, 0x1C/255), + (0x44/255, 0x44/255, 0x44/255), + t) + } + + /// Signal score number color: stays in orange/brown hue, fades with score. + /// near-invisible brown (0) → full #FF5C00 (100). + static func scoreColor(_ score: Int) -> Color { + let t = Double(score) / 100.0 + return lerp((0x2A/255, 0x18/255, 0x10/255), // near-invisible brown + (0xFF/255, 0x5C/255, 0x00/255), // #FF5C00 + t) + } +} + +// MARK: - Topic display + +enum Topic { + static func label(_ slug: String) -> String { + switch slug.lowercased() { + case "finance": return "Finance" + case "tech": return "Tech" + case "politics": return "Politics" + case "africa": return "Africa" + default: return slug.prefix(1).uppercased() + slug.dropFirst() + } + } + + /// Topic pills shown on the home screen. + static let filters: [(label: String, slug: String?)] = [ + ("All", nil), + ("Finance", "finance"), + ("Tech", "tech"), + ("Politics", "politics"), + ("Africa", "africa"), + ] +} + +// MARK: - Time formatting + +extension Date { + /// Short relative string e.g. "just now", "3 min", "2 hr", "4 d". + func timeAgoShort(reference: Date = Date()) -> String { + let secs = max(0, Int(reference.timeIntervalSince(self))) + switch secs { + case 0..<60: return "just now" + case 60..<3600: return "\(secs / 60) min" + case 3600..<86400: return "\(secs / 3600) hr" + default: return "\(secs / 86400) d" + } + } + + /// "08:03" style monospaced clock for timeline / source chips. + var clockShort: String { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: self) + } +} + +func syncedMinutesAgo(_ date: Date?, reference: Date = Date()) -> String { + guard let date else { return "never" } + return date.timeAgoShort(reference: reference) +} + +// MARK: - Navigation routes + +/// Route into the article reader. Carries the parent story context so the +/// back button can show the truncated headline. +struct ArticleRoute: Hashable { + let articleId: String + let storyId: String + let parentHeadline: String +} diff --git a/Jarvis/Views/Story/StoryDetailView.swift b/Jarvis/Views/Story/StoryDetailView.swift new file mode 100644 index 0000000..968a860 --- /dev/null +++ b/Jarvis/Views/Story/StoryDetailView.swift @@ -0,0 +1,244 @@ +// StoryDetailView.swift +// Jarvis — full story: consensus / conflict / coverage timeline. + +import SwiftUI +import SwiftData + +@MainActor +final class StoryDetailViewModel: ObservableObject { + @Published var detail: StoryDetail? + @Published var isLoading = false + @Published var error: String? + + private let api = APIClient.shared + + func load(id: String) async { + isLoading = true + error = nil + do { + detail = try await api.fetchStory(id: id) + } catch let e as APIError { + error = e.errorDescription + } catch { + self.error = error.localizedDescription + } + isLoading = false + } +} + +struct StoryDetailView: View { + let story: StorySummary + + @Environment(\.dismiss) private var dismiss + @StateObject private var vm = StoryDetailViewModel() + @Query private var cachedArticles: [CachedArticle] + + // Prefer freshly-loaded detail; fall back to the summary we navigated with. + private var topic: String { story.topic } + private var score: Int { vm.detail?.signalScore ?? story.signalScore } + private var headline: String { vm.detail?.headline ?? story.headline } + private var summary: String { vm.detail?.summary ?? story.summary } + private var consensus: String? { vm.detail?.consensus ?? story.consensus } + private var conflict: String? { vm.detail?.conflict ?? story.conflict } + private var sourceCount: Int { vm.detail?.sourceCount ?? story.sourceCount } + private var timeline: [TimelineEntry] { + (vm.detail?.timeline ?? []).sorted { $0.publishedAt < $1.publishedAt } + } + + /// Unique source names in coverage order. + private var sourceNames: [String] { + var seen = Set(); var out: [String] = [] + for e in timeline where !seen.contains(e.source) { seen.insert(e.source); out.append(e.source) } + if out.isEmpty { out = story.sources.map(\.name) } + return out + } + + private var cachedIds: Set { + Set(cachedArticles.filter { $0.storyId == story.id }.map(\.id)) + } + private var allCached: Bool { + !timeline.isEmpty && timeline.allSatisfy { cachedIds.contains($0.articleId) } + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + ScrollView { + VStack(alignment: .leading, spacing: 20) { + categoryRow + Text(headline) + .font(.system(size: 28, weight: .heavy)) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + + if !summary.isEmpty { + Text(summary) + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(Color(hex: "9A9A9A")) + .lineSpacing(4) + } + + SourceChips(names: sourceNames, total: sourceCount) + + if allCached { + Text("All \(timeline.count) articles cached · available offline") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Color(hex: "6FBF6F")) + } + + if let consensus { consensusBlock(consensus) } + if let conflict { conflictBlock(conflict) } + + timelineSection + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + } + } + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { backButton } + } + .toolbarBackground(Color.black, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .preferredColorScheme(.dark) + .task { await vm.load(id: story.id) } + } + + // MARK: - Pieces + + private var backButton: some View { + Button { dismiss() } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left").font(.system(size: 15, weight: .bold)) + Text("Signal feed").font(.system(size: 16, weight: .regular)) + } + .foregroundStyle(Palette.orange) + } + } + + private var categoryRow: some View { + HStack(spacing: 10) { + Text(Topic.label(topic).uppercased()) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .kerning(0.8) + .foregroundStyle(Color(hex: "888888")) + Text("·").foregroundStyle(Color(hex: "444444")) + Text("\(score) signal") + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(Signal.scoreColor(score)) + Spacer() + } + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(Palette.surface) + .clipShape(Capsule()) + } + + private func consensusBlock(_ text: String) -> some View { + infoBlock(title: "CONSENSUS", text: text, + border: Palette.consensusBorder, fill: Palette.consensusFill, + titleColor: Palette.orange) + } + + private func conflictBlock(_ text: String) -> some View { + infoBlock(title: "CONFLICTING REPORTS", text: text, + border: Palette.conflictBorder, fill: Palette.conflictFill, + titleColor: Color(hex: "C25555")) + } + + private func infoBlock(title: String, text: String, border: Color, fill: Color, titleColor: Color) -> some View { + HStack(spacing: 0) { + Rectangle().fill(border).frame(width: 3) + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .kerning(0.8) + .foregroundStyle(titleColor) + Text(text) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(Color(hex: "C8C8C8")) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + Spacer(minLength: 0) + } + .background(fill) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var timelineSection: some View { + VStack(alignment: .leading, spacing: 0) { + Text("COVERAGE TIMELINE") + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .kerning(0.8) + .foregroundStyle(Color(hex: "555555")) + .padding(.bottom, 14) + + if vm.isLoading && timeline.isEmpty { + ProgressView().tint(Palette.orange).padding(.vertical, 20) + } else if timeline.isEmpty { + Text(vm.error ?? "No coverage available.") + .font(.system(size: 13)) + .foregroundStyle(Color(hex: "555555")) + } else { + ForEach(Array(timeline.enumerated()), id: \.element.id) { index, entry in + NavigationLink(value: ArticleRoute(articleId: entry.articleId, + storyId: story.id, + parentHeadline: headline)) { + timelineRow(entry, isFirst: index == 0, + isLast: index == timeline.count - 1, + cached: cachedIds.contains(entry.articleId)) + } + .buttonStyle(.plain) + } + } + } + .padding(.top, 8) + } + + private func timelineRow(_ entry: TimelineEntry, isFirst: Bool, isLast: Bool, cached: Bool) -> some View { + HStack(alignment: .top, spacing: 14) { + // Node + connector + VStack(spacing: 0) { + Circle() + .fill(isFirst ? Palette.orange : Color(hex: "333333")) + .frame(width: 11, height: 11) + .overlay(Circle().stroke(Color.black, lineWidth: 2)) + if !isLast { + Rectangle().fill(Color(hex: "222222")).frame(width: 1.5) + } + } + .frame(width: 11) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(entry.source) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(isFirst ? Palette.orange : Color(hex: "999999")) + if entry.isBreaking { + Text("BREAKING") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(Palette.conflictBorder) + .clipShape(Capsule()) + } + Spacer() + Text(entry.publishedAt.clockShort) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Color(hex: "555555")) + if cached { CachedDot(size: 6) } + } + Text(entry.headline) + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(Color(hex: "D0D0D0")) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } + .padding(.bottom, isLast ? 0 : 20) + } + .contentShape(Rectangle()) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..640b747 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Jarvis (iOS) + +A SwiftUI iOS client for a self-hosted RSS **news-correlation** platform. It shows +stories ranked by a server-computed **signal score**, caches full articles for offline +reading, and receives live updates over WebSocket. + +> This repo is the **iOS app only**. The backend (RSS ingest, clustering, scoring, +> REST + WebSocket) is a separate service — see [`docs/backend/`](docs/backend/) for the +> build/handoff spec and [`API/contract.md`](API/contract.md) for the wire contract. + +## Architecture + +- **REST on launch → WebSocket for live updates.** The server computes signal scores; + the app only renders them. +- **Offline-first** via SwiftData (`CachedStory`, `CachedArticle`). +- **No auth** — local network / self-hosted (reach it over LAN or Tailscale). +- Data flows through `StoryStore` / dedicated view-models; views never call the API + directly. + +``` +Jarvis/ + Models/ Codable + SwiftData models + Networking/ APIClient (REST), WebSocketManager + Store/ StoryStore, ServerSettings + Connectivity/ ConnectivitySettings, ConnectivityManager (LAN ⇄ Tailscale) + Views/ + Home/ Story/ Reader/ Feeds/ Connectivity/ Shared/ +``` + +## Requirements + +- Xcode 16+ (developed on Xcode 26), iOS 17.0+ deployment target (SwiftData). +- [`XcodeGen`](https://github.com/yonaskolb/XcodeGen) — the `.xcodeproj` is generated + from [`project.yml`](project.yml) and is **not** committed. + +## Build & run + +```bash +brew install xcodegen # once +xcodegen generate # produces Jarvis.xcodeproj +open Jarvis.xcodeproj # ⌘R in Xcode, or: + +xcodebuild -project Jarvis.xcodeproj -scheme Jarvis \ + -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build +``` + +## Connecting to a backend + +On first launch, enter the server as `host:port` (the app prepends `http://` and +`ws://`), e.g. `10.10.1.70:8098`. Remote access via Tailscale is configured in the +in-app **Connectivity → Remote Access** card; the app auto-selects the LAN address when +it's reachable and the Tailscale address when away. + +## CI + +GitHub Actions builds a matrix of macOS runners × Debug/Release on every push and PR — +see [`.github/workflows/ci.yml`](.github/workflows/ci.yml). + +## Status + +Frontend complete and verified against a live backend (signal feed, story detail, +article reader, feed manager, connectivity). Open work is tracked in +[Issues](../../issues) / [`docs/BACKLOG.md`](docs/BACKLOG.md). diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md new file mode 100644 index 0000000..7b15866 --- /dev/null +++ b/docs/BACKLOG.md @@ -0,0 +1,32 @@ +# Backlog + +Seed list for the issue tracker. Each becomes a GitHub issue (see +`scripts/create-issues.sh`). `[client]` = iOS, `[backend]` = coordinate with the +backend service. + +1. **[client][backend] Add `Sport` (F1) and `World` topic pills.** The 47 feeds now + include heavy F1 + global-news coverage that gets squeezed into finance/tech/ + politics/africa. Add pills in `Topic.filters`; backend classifier must emit `sport` + / `world` slugs to match. +2. **[client] Wire the Tailscale remote host in Connectivity.** Populate the Remote + Access card's host so away-from-home auto-selects the Tailscale address. Verify the + LAN⇄Tailscale switch via `/health` probing. +3. **[client] SSID-based trusted networks.** Pin trusted Wi-Fi by name ("Add current + network"). Needs the *Access WiFi Information* entitlement (real device + paid + account); no-ops in the simulator. Currently approximated by LAN reachability. +4. **[client] Optional in-app WireGuard tunnel.** NetworkExtension packet-tunnel so + Jarvis can bring up a WireGuard tunnel itself (WireGuard/Netmaker configs only). + Needs entitlement + real device. Deferred. +5. **[client] App icon + launch assets.** `AppIcon` is an empty placeholder. +6. **[client] Flesh out Latest / Saved / Search tabs.** Currently minimal reuse of the + feed components; add proper empty/loading states and dedicated behavior. +7. **[client] Verify hero image loading.** `AsyncImage` against real feed `imageUrl`s + (ATS, redirects, grey fallback) in the article reader. +8. **[client] Infinite scroll / pagination at scale.** Validate `loadMore`/cursor + round-trips against the live ~850-story dataset; guard against dupes. +9. **[backend] Topic-classifier coverage for the 47 feeds.** Ensure science/tech/world/ + sport map sensibly into the supported topic slugs. +10. **[ci] Add unit/UI tests.** The build matrix compiles but runs no tests yet; add a + test target and a `test` matrix leg. +11. **[client] Offline polish.** Green "cached" dot + Saved tab depend on opening an + article; consider pre-caching top stories for true offline-first. diff --git a/docs/IOS_CLIENT_EXPECTATIONS.md b/docs/IOS_CLIENT_EXPECTATIONS.md new file mode 100644 index 0000000..4885047 --- /dev/null +++ b/docs/IOS_CLIENT_EXPECTATIONS.md @@ -0,0 +1,188 @@ +# What the iOS Client Actually Requires (derived from client source) + +This is reverse-engineered from the Swift `Codable` models (`Jarvis/Models/Models.swift`) +and the two decoders (`APIClient.swift`, `WebSocketManager.swift`) — i.e. the real +arbiter of what the backend must emit. Where this disagrees with +`BACKEND_HANDOFF.md`, **this wins**. Read alongside the pre-handover checklist. + +## Decoder facts (both REST and WS use the same config) + +```swift +decoder.dateDecodingStrategy = .iso8601 // Foundation .withInternetDateTime +decoder.keyDecodingStrategy = .convertFromSnakeCase +``` + +Three consequences that drive everything below: + +1. **Dates: `YYYY-MM-DDTHH:MM:SSZ` only** — UTC, literal `Z`, **no fractional seconds, + no `+00:00` offset**. `.iso8601` rejects microseconds. This is the #1 break. +2. **Key casing is forgiving** — because of `.convertFromSnakeCase`, **either** + `signalScore` **or** `signal_score` decodes correctly (camelCase has no underscores + so it passes through; snake_case is converted). So you are NOT forced into camelCase + — but camelCase matches the contract examples, so prefer it and stay consistent. +3. **A Swift non-optional property that is missing OR `null` throws** — and because + lists are decoded as `[StorySummary]` / `[Feed]`, **one bad element fails the entire + array**, so the whole page/response is dropped and the screen goes empty. The store + swallows the error silently (no crash, no log). So "mostly correct" = "empty feed." + Every required field on every object must be present, non-null, and the right type. + +--- + +## Required vs nullable — by endpoint + +Legend: **R** = required (must be present, non-null, correct type) · **N** = nullable +(may be `null`, but the **key should still be present**). + +### `GET /stories` → `PaginatedStories` +| Field | Req | Type | Notes | +|---|---|---|---| +| `data` | R | array | each element a **StorySummary** (below) | +| `nextCursor` | N | string\|null | pass back verbatim as `after` | +| `hasMore` | R | bool | | +| `total` | R | int | | + +**StorySummary** (one per `data[]`): +| Field | Req | Type | Notes | +|---|---|---|---| +| `id` | R | string | | +| `headline` | R | string | canonical story headline | +| `summary` | R | string | **non-null** — must synthesize a cross-source summary | +| `topic` | R | string | one of `finance/tech/politics/africa` | +| `signalScore` | R | int | integer, not float/string | +| `scoreBreakdown` | R | object | all 5 ints R (below); **sum == signalScore** | +| `sourceCount` | R | int | | +| `sources` | R | array | **REQUIRED here** (see gap #1); may be `[]` but key must exist | +| `consensus` | N | string\|null | | +| `conflict` | N | string\|null | | +| `updatedAt` | R | date | `...Z` | +| `createdAt` | R | date | `...Z` | + +**scoreBreakdown**: `sourceAuthority`, `freshness`, `localRelevance`, +`crossSourceConfirmation`, `topicImportance` — **all R, all int**. + +**StorySource** (each `sources[]`): `id` R, `name` R, `url` R, `publishedAt` R (date), +`isBreaking` R (bool). + +### `GET /stories/{id}` → `StoryDetail` +Same as StorySummary **except**: **no `sources` field**, and **adds `timeline`** (R, array). +`consensus`/`conflict` N. `summary` R. + +**TimelineEntry** (each `timeline[]`): `articleId` R, `source` R, `headline` R, +`publishedAt` R (date), `isBreaking` R (bool). Client renders the **first** entry with +the orange node and shows a BREAKING badge where `isBreaking == true`. + +### `GET /articles/{id}` → `Article` +| Field | Req | Type | +|---|---|---| +| `id`, `storyId`, `source`, `sourceUrl`, `headline`, `body` | R | string | +| `publishedAt` | R | date | +| `imageUrl` | N | string\|null | +| `author` | N | string\|null | + +`body` is shown as the full article — give the richest text you have (HTML-stripped). + +### `GET /feeds` → `{ "data": [Feed] }` +Must be wrapped in `{"data": [...]}`. **Feed**: +| Field | Req | Type | Notes | +|---|---|---|---| +| `id`,`name`,`url` | R | string | | +| `health` | R | enum | **exactly** `"active"`\|`"failing"`\|`"dead"` — any other string fails the whole array | +| `pollIntervalSeconds` | R | int | | +| `failureCount` | R | int | | +| `lastFetchedAt` | N | date\|null | `...Z` or null | +| `articleCountToday` | R | int | | + +### `GET /health` → `ServerHealth` +`status` R (string), `version` R (string), `storiesCount` R (int), `feedsCount` R (int), +`uptime` R (int). All required — a missing one fails onboarding's connect check. + +--- + +## What the client SENDS (match these exactly) + +- `GET /stories` query params: **`limit`** (int, default 20), **`after`** (cursor), + **`topic`** (slug), **`min_signal`** (int, **snake_case** — read this literal name). + The app currently always sends `limit=20`, sends `topic` only when a pill ≠ All is + selected, and does **not** currently send `min_signal` (but support it). +- `POST /feeds` JSON body: **`{"url": ..., "name": ..., "pollIntervalSeconds": ...}`** + (the app currently always sends `pollIntervalSeconds: 900`). Respond **201** + the Feed. +- `DELETE /feeds/{id}` → must be **HTTP 204**, empty body. The client explicitly checks + `statusCode == 204` and treats anything else as a failure (it rolls back the deletion). + +--- + +## WebSocket — minimum fields per event + +The client ignores events that lack the fields it needs (no error, just no effect). + +| Event `type` | Client requires | Effect | +|---|---|---| +| `story.updated` | **`storyId` + `signalScore` + `sourceCount`** (all three) | patches the row in place, re-sorts by score. Missing any → ignored. | +| `story.created` | `type` only | triggers a full `GET /stories` refetch | +| `story.stale` | `storyId` | removes that story from the feed | +| `feed.health` | `feedId` + `health` (valid enum) | updates the feed's health dot; `failureCount` used if present | +| `ping` | — | client auto-replies `{"type":"pong"}`; **server should send ping every 30s** | + +WS date fields (`updatedAt`/`createdAt`) are currently **not used** by the client after +the in-place patch, so they're not load-bearing — but if you send them, still use `...Z`. + +--- + +## RSS → iOS: what comes from the feed vs what you must MANUFACTURE + +The app never sees an RSS field directly — it sees **Stories** (clusters) and +**Articles**. RSS only fills the article layer; everything story-level is computed. + +| iOS field | Source | Notes | +|---|---|---| +| `Article.headline` | RSS `title` | | +| `Article.body` | RSS `content`/`description` | HTML-stripped; full text if you fetch the page | +| `Article.publishedAt` | RSS `pubDate`/`published` | → UTC, fall back to now if absent | +| `Article.author` | RSS `author`/`dc:creator` | nullable | +| `Article.imageUrl` | RSS `media:content`/`enclosure`/first `` | nullable | +| `Article.sourceUrl` | RSS `link` | | +| `Article.source` | the **feed's** name | | +| `Article.id`, `storyId` | **you generate / assign** | storyId from clustering | +| **`Story.headline`** | **computed** | pick canonical from the cluster | +| **`Story.summary`** | **computed** | cross-source summary — must be non-null | +| **`Story.topic`** | **computed** | classify into finance/tech/politics/africa | +| **`signalScore` + breakdown** | **computed** | the 5-component rubric | +| **`sourceCount`, `sources[]`, `timeline[]`** | **computed** | cluster aggregation | +| **`consensus`, `conflict`** | **computed** | nullable | +| **`isBreaking`** | **computed** | heuristic (earliest in cluster / recency / RSS flag) | + +So plugging in real feeds = (1) parse entries → Articles, (2) **cluster** them into +Stories, (3) **score + classify + summarize**, (4) shape into the exact objects above. +Steps 2–3 are the whole product; RSS gives you almost none of it. + +--- + +## GAPS / CORRECTIONS vs `BACKEND_HANDOFF.md` + +1. **`sources[]` is REQUIRED on `GET /stories` list items** (not just the contract's + nice-to-have). It is **absent** on `GET /stories/{id}` (which has `timeline` instead). + Easy to forget because the two endpoints differ — omitting `sources` on the list + endpoint makes the **entire home feed decode fail → blank screen**. Highest-risk item. +2. **camelCase is NOT mandatory** — the handoff overstated this. `.convertFromSnakeCase` + means snake_case decodes fine too. The real hard requirement is the **date format**, + not the casing. (Still: pick one and be consistent; camelCase matches the contract.) +3. **One bad object fails the whole response, not just that object.** Stricter than the + handoff's "drops the object." Per-element validity matters — especially the `health` + enum and any required field being null. +4. **`summary` must be non-null** on both story endpoints. RSS has per-article + descriptions, but the *story* summary is synthesized — don't leave it null. +5. **`health` must be exactly `active`/`failing`/`dead`.** A stray value (e.g. `"error"`, + `"ok"`, `"unknown"`) fails the whole `/feeds` array → empty feed manager. +6. **`min_signal` is snake_case** in the query string even though responses are camelCase + — read the literal param name `min_signal`. +7. Numeric fields must be **JSON numbers**, not strings (`"signalScore": 91`, not `"91"`). + +--- + +## Fastest way to de-risk before the app ever connects + +Decode-test each endpoint with Swift's exact rules. A 5-line check that mirrors +`.iso8601`: every date must match `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$`. Then assert +`sources` exists on `/stories` items, `timeline` on `/stories/{id}`, and that no +required field is null. (The pre-handover checklist §A/§B has runnable versions.) +If those pass, the app will populate on first connect. diff --git a/docs/KICKOFF_PROMPT.md b/docs/KICKOFF_PROMPT.md new file mode 100644 index 0000000..5a91075 --- /dev/null +++ b/docs/KICKOFF_PROMPT.md @@ -0,0 +1,147 @@ +# Jarvis iOS — Claude Code Kickoff Prompt + +Paste this entire prompt into Claude Code to begin. + +--- + +## Prompt + +You are building **Jarvis** — a SwiftUI iOS app that connects to a self-hosted RSS news correlation platform and displays stories ranked by signal score. + +Read `CLAUDE.md` before writing a single line of code. It contains every architecture decision already made. Do not deviate from it. + +--- + +## Your job + +Build the remaining SwiftUI views. The networking layer, models, stores, and onboarding screen are already written. You are building the UI on top of them. + +### Views to build + +1. **SignalFeedView** — the home screen + - Wordmark `Jarvis` with `J` in #FF5C00, rest white, weight 800, kerning -2 + - Live/Offline connection indicator (driven by `WebSocketManager.shared.connectionState`) + - Sync bar: "Synced X min ago · N cached" when live, "Last synced X min ago" when offline + - Topic filter pills: All · Finance · Tech · Politics · Africa (calls `store.setTopic()`) + - Column headers: Story / Signal ↓ + - List of `StoryRowView` sorted by `signalScore` descending + - Pull to refresh calls `store.loadStories(refresh: true)` + - Infinite scroll: call `store.loadMore()` when last row appears + - Tap row → navigate to `StoryDetailView` + +2. **StoryRowView** — one row in the signal feed + - Left edge stripe: 3pt wide, color opacity tied to signal score (97 = #FF5C00, fades to #1a1a1a at 0) + - Story title, dimmed proportionally to score (97 = white, 20 = #444) + - Signal score number, same fade behaviour + - Source count, topic, time ago in monospaced font + - Source chips (first 2 named, rest "+N") + - Small green dot if story is fully cached offline + +3. **StoryDetailView** — full story + - Back button "Signal feed" + - Category label + signal score pill (e.g. "Finance · 97 signal") + - Large headline, weight 800 + - Source chips + - "All N articles cached · available offline" line if cached + - Consensus block: orange left border, dark orange background + - Conflicting block: dark red left border, dark red background (only if `conflict != nil`) + - Coverage timeline: chronological list of `TimelineEntry`, first item has orange node + - Tapping a timeline entry → navigate to `ArticleReaderView` + +4. **ArticleReaderView** — full article + - Back button showing parent story headline (truncated to 30 chars) + - Source pill in #FF5C00, timestamp in monospaced + - Cached badge inline (green, only if article is in SwiftData) + - Large headline weight 800 + - Hero image placeholder (AsyncImage with grey fallback) + - Body text, #4a4a4a, size 16, line spacing 1.65 + - Divider then "More from this cluster" section + - 2–3 other articles from same story as tappable rows + +5. **FeedManagerView** — feed health and management + - Wordmark `Jarvis` header + "+ Add feed" button + - Platform connection card: server URL, live/offline dot (from `WebSocketManager`) + - Search bar to filter feed list + - Two sections: "Healthy" and "Needs attention" + - Each row: RSS icon, feed name, poll interval + article count, health dot + - Health dot: green (#2a5a2a) = active, amber (#AA6600) = failing with retry countdown, red (#5a1a1a) = dead + - Swipe to delete calls `APIClient.shared.deleteFeed(id:)` + - "+ Add feed" sheet: URL input + name field + confirm button + +--- + +## Design rules — non-negotiable + +- Background: `Color.black` / `#000000` everywhere +- Accent: `Color("KisaniOrange")` = `#FF5C00` +- Surface cards: `#111111`, `#1A1A1A` +- All screens: `.preferredColorScheme(.dark)` +- Font weight: 800 for headlines, 700 for labels, 400 for body — never use 600 +- Monospaced font for: timestamps, signal scores, server URLs, feed metadata +- No gradients. No shadows. Flat surfaces only. +- Minimum tap target: 44pt +- Signal score stripe and text both fade: score 80–100 = full orange, 50–79 = dimmed orange, 20–49 = dark brown, 0–19 = near invisible + +--- + +## Data flow rules — non-negotiable + +- Views never call `APIClient` directly — always go through `StoryStore` or a dedicated `@StateObject` ViewModel +- Signal scores are server-computed — never calculate or modify them in the UI +- WebSocket events trigger REST refetches — the store handles this already +- SwiftData (`CachedStory`, `CachedArticle`) is the offline source — query it when the server is unreachable +- Use `@EnvironmentObject var store: StoryStore` in views that need stories +- Use `@EnvironmentObject var ws: WebSocketManager` for connection state + +--- + +## Xcode project setup + +1. Create new Xcode project: iOS App, named **Jarvis**, Swift, SwiftUI +2. Set minimum deployment target: **iOS 17.0** (required for SwiftData) +3. Add all provided `.swift` files to the project +4. In `Assets.xcassets` add a new Color Set named `KisaniOrange`: + - Any / Dark: `#FF5C00` +5. Add `Assets.xcassets` Color Set `AppBackground`: `#000000` +6. The SwiftData model container is already configured in `JarvisApp.swift` + +--- + +## File structure to produce + +``` +Jarvis/ + Views/ + Home/ + SignalFeedView.swift + StoryRowView.swift + Story/ + StoryDetailView.swift + Reader/ + ArticleReaderView.swift + Feeds/ + FeedManagerView.swift + AddFeedSheet.swift + Shared/ + SignalStripe.swift ← reusable 3pt stripe component + SourceChips.swift ← reusable chips row + CachedBadge.swift ← reusable green cached indicator + ConnectionBanner.swift ← offline warning banner +``` + +--- + +## Start here + +1. Read `CLAUDE.md` +2. Create the Xcode project and add all existing files +3. Build `SignalStripe.swift`, `SourceChips.swift`, `CachedBadge.swift` — shared components used by multiple views +4. Build `StoryRowView.swift` +5. Build `SignalFeedView.swift` +6. Build `StoryDetailView.swift` +7. Build `ArticleReaderView.swift` +8. Build `FeedManagerView.swift` + `AddFeedSheet.swift` +9. Run on simulator, fix layout issues, verify dark mode renders correctly +10. Confirm WebSocket connection state drives the live/offline indicator on the home screen + +Do not skip steps. Do not simplify the signal stripe fade logic. Do not use placeholder colours — every hex value is specified above. diff --git a/docs/backend/BACKEND_HANDOFF.md b/docs/backend/BACKEND_HANDOFF.md new file mode 100644 index 0000000..b6533aa --- /dev/null +++ b/docs/backend/BACKEND_HANDOFF.md @@ -0,0 +1,336 @@ +# Jarvis Backend — Build & Deploy Handoff (for Codex on Proxmox) + +You are building the **server side** of Jarvis: a self-hosted RSS news-correlation +platform. A finished SwiftUI iOS client already exists and will connect to you +**unmodified** — so the API shapes below are not suggestions, they are a contract. +The canonical spec is `API/contract.md` in this repo; this document adds the +implementation plan, the algorithms, the client-compat gotchas, and the Proxmox +deployment. + +**Stack (already decided):** Python 3.11+ · FastAPI · Uvicorn · SQLite (via SQLModel) · +scikit-learn TF-IDF for correlation · rule-based scoring. No external API keys. +No auth (local network / self-hosted only). + +--- + +## 0. TL;DR of what to build + +A single FastAPI app that does four jobs: + +1. **Ingest** — poll RSS feeds on a schedule, parse articles, track feed health. +2. **Correlate** — cluster articles from different sources into *Stories* (TF-IDF + cosine). +3. **Score** — compute a server-side `signalScore` (5 components) + topic + consensus/conflict. +4. **Serve** — the REST API under `/api/v1` and a WebSocket at `/ws`, broadcasting live events. + +Then deploy it as a systemd service inside a Proxmox **LXC container**, bound to +`0.0.0.0:8080`, reachable from the iPhone on the LAN. + +--- + +## 1. NON-NEGOTIABLE client-compatibility rules + +The iOS client decodes JSON with `keyDecodingStrategy = .convertFromSnakeCase` +and `dateDecodingStrategy = .iso8601`, and it **silently drops any object that +fails to decode**. So: + +1. **Dates must be exactly `YYYY-MM-DDTHH:MM:SSZ`** — UTC, the literal `Z`, and + **no fractional seconds**. Pydantic's default (`...+00:00` with microseconds) + will FAIL to decode and stories will vanish. Force the format with a serializer + (see §4). +2. **JSON keys: camelCase** (`signalScore`, `sourceCount`, `pollIntervalSeconds`, + `publishedAt`, `nextCursor`, `hasMore`, …). FastAPI: set field aliases via + `alias_generator=to_camel`, serialize with `response_model_by_alias=True` (default). +3. **Exact query-param names** the client sends to `GET /stories`: + `limit`, `after` (the cursor), `topic`, `min_signal`. Read those names verbatim. +4. **`DELETE /feeds/:id` must return HTTP 204** (no body). The client checks for 204. +5. **`POST /feeds` returns 201** with the created feed object. The client sends + `{"url": ..., "name": ..., "pollIntervalSeconds": ...}` — accept camelCase. +6. **Base URL is `http:///api/v1`** and **WebSocket is `ws:///ws`** — + the client builds these strings literally, so REST lives under `/api/v1` and the + socket at the root path `/ws`. Plain `http`/`ws` (not TLS) is expected. +7. **No CORS needed** (native app, not a browser), but harmless to enable `*`. +8. Error responses use `{"error": {"code", "message", "status"}}` (see contract §Error). + +--- + +## 2. Endpoints (all under `/api/v1` unless noted) + +Full example payloads are in `API/contract.md`. Summary: + +| Method | Path | Returns | Notes | +|---|---|---|---| +| GET | `/health` | `{status, version, storiesCount, feedsCount, uptime}` | client calls on launch to validate connection | +| GET | `/stories` | `{data[], nextCursor, hasMore, total}` | sorted by `signalScore` desc; params `limit`(≤100, def 20), `after`, `topic`, `min_signal` | +| GET | `/stories/{id}` | `StoryDetail` (adds `timeline[]`) | 404 `story_not_found` if missing | +| GET | `/articles/{id}` | `Article` (full body for offline cache) | 404 if missing | +| GET | `/feeds` | `{data: [Feed]}` | all feeds + health | +| POST | `/feeds` | `Feed` (201) | body `{url, name, pollIntervalSeconds?}`; 400 `invalid_url` if not a real feed | +| DELETE | `/feeds/{id}` | 204 | 404 `feed_not_found` if missing | +| WS | `/ws` (root, not `/api/v1`) | event stream | see §6 | + +### Object shapes (camelCase, dates as `...Z`) + +- **StorySummary**: `id, headline, summary, topic, signalScore, scoreBreakdown, + sourceCount, sources[], consensus, conflict, updatedAt, createdAt` +- **scoreBreakdown**: `{sourceAuthority, freshness, localRelevance, + crossSourceConfirmation, topicImportance}` (ints, sum == `signalScore`) +- **StorySource**: `{id, name, url, publishedAt, isBreaking}` +- **StoryDetail**: StorySummary fields **minus `sources`**, **plus** `timeline[]` +- **TimelineEntry**: `{articleId, source, headline, publishedAt, isBreaking}` +- **Article**: `{id, storyId, source, sourceUrl, headline, body, imageUrl, author, publishedAt}` +- **Feed**: `{id, name, url, health, pollIntervalSeconds, failureCount, lastFetchedAt, articleCountToday}` + - `health ∈ {"active","failing","dead"}` +- **topic** slugs used by the app's filter pills: `finance`, `tech`, `politics`, `africa` + (classify everything into one of these; fall back to the best match). + +--- + +## 3. Suggested project layout + +``` +server/ + app/ + main.py # FastAPI app, lifespan: init DB, seed, start poller + config.py # tunables: thresholds, lexicons, source-authority map + db.py # SQLModel engine/session, init_db() + models.py # Feed, Article, Story (SQLModel tables) + schemas.py # Pydantic response models (camelCase aliases + Z dates) + ingest.py # RSS poll + parse + feed-health state machine + correlate.py # TF-IDF clustering: assign articles -> stories + scoring.py # signal-score components, topic classify, consensus/conflict + events.py # WebSocket ConnectionManager + broadcast + 30s ping loop + scheduler.py # asyncio loop: poll due feeds, correlate, score, sweep stale + routers/ + stories.py articles.py feeds.py health.py ws.py + seed_feeds.py # default feed list inserted on first run + requirements.txt # already in this folder + jarvis.service # systemd unit (see §8) + README.md +``` + +--- + +## 4. Data model (SQLite via SQLModel) + +- **Feed**: `id (pk, "feed_"+short uuid)`, `name`, `url`, `health`, + `poll_interval_seconds`, `failure_count`, `last_fetched_at (nullable)`, `created_at`. + `articleCountToday` is **computed** at query time (count of this feed's articles + with `created_at >= today 00:00 UTC`). +- **Article**: `id ("art_"+uuid)`, `feed_id (fk)`, `story_id (fk, nullable)`, + `source` (feed name), `source_url`, `headline`, `body`, `image_url (nullable)`, + `author (nullable)`, `published_at`, `guid (unique — dedup key)`, `created_at`, + `match_text` (lowercased headline + body, for TF-IDF). +- **Story**: `id ("story_"+uuid)`, `headline`, `summary`, `topic`, `signal_score`, + the 5 breakdown ints, `consensus (nullable)`, `conflict (nullable)`, + `source_count`, `is_stale (bool)`, `created_at`, `updated_at`. + +Store **all datetimes as naive UTC**. Serialize with this exact helper so the +client's `.iso8601` accepts them: + +```python +from datetime import datetime, timezone +from pydantic import field_serializer + +def iso_z(dt: datetime) -> str: + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") # NO microseconds, literal Z +``` + +Apply via `@field_serializer("publishedAt","updatedAt","createdAt","lastFetchedAt")` +returning `iso_z(value)` (return `None` for null `lastFetchedAt`). + +Camel aliases: + +```python +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +class Schema(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) +``` + +--- + +## 5. The algorithms + +### 5a. Ingest (`ingest.py`) +- Fetch each due feed with `httpx.AsyncClient` (timeout ~10s), parse bytes with + `feedparser` (run in `asyncio.to_thread`, it's blocking). +- For each entry: dedup by `guid` (entry.id or link). Extract `headline` (title), + `body` (richest of `content[].value` / `summary`, HTML-stripped with BeautifulSoup), + `image_url` (media:content / enclosure / first ``), `author`, `published_at` + (`entry.published_parsed` → UTC; fall back to now). +- **Feed-health state machine** on each poll: + - success → `health="active"`, `failure_count=0`, `last_fetched_at=now`. + - failure → `failure_count += 1`; `failing` at ≥1, `dead` at ≥5 (tunable). + - On any health **change**, broadcast a `feed.health` WS event. + +### 5b. Correlate (`correlate.py`) +Goal: same real-world event across sources → one Story; stable Story `id`s. +- Consider only articles from a rolling window (e.g. last **72h**). +- Single `TfidfVectorizer` fit per cycle over (existing recent stories' aggregate + text + the new unclustered articles' `match_text`). English stopwords, `ngram_range=(1,2)`. +- **Greedy online assignment** per new article (process oldest→newest): + - cosine-compare against current cluster representatives; + - if `max_sim >= 0.22` (tunable) **and** within the time window → join that story; + - else → create a **new** Story seeded by this article (append its vector as a new rep). +- After assignment, recompute each touched story's aggregates: `source_count` + (distinct feeds), canonical `headline`/`summary` (take the highest-authority or + earliest article), then re-score (§5c). Track which stories were **created** vs + **updated** to emit the right WS event. + +### 5c. Score (`scoring.py`) — rule-based, must sum to `signalScore` +Component caps chosen so totals land like the contract example (91 = 28+22+15+16+10): + +| Component | Range | Rule | +|---|---|---| +| `sourceAuthority` | 0–30 | sum of per-source authority weights (default 6; majors higher via config map), capped 30 | +| `freshness` | 0–25 | `round(25 * max(0, 1 - age_hours_of_newest / 48))` | +| `localRelevance` | 0–15 | hits against a local lexicon (place names, currency, institutions) scaled, capped 15 | +| `crossSourceConfirmation` | 0–20 | `min(20, (distinct_sources - 1) * 5)` | +| `topicImportance` | 0–10 | per-topic map: politics/finance=10, africa=8, tech=7, else 5 | + +`signalScore = clamp(sum, 0, 100)`. + +- **Topic classification**: keyword lexicons per `{finance, tech, politics, africa}`; + pick the highest-scoring; tie-break to `africa`. Keep lexicons in `config.py`. +- **Consensus** (set when ≥2 sources): e.g. `"All N sources confirm the report. + {SourceA} and {SourceB} agree."` — template is fine for v1. +- **Conflict** (nullable, default null): v1 heuristic — set a generic line only if a + headline contains dispute markers (`deny`, `dispute`, `contradict`, `reject`), + else `null`. (Leave a TODO to upgrade with NLP/LLM later.) + +### 5d. Staleness sweep (`scheduler.py`) +- Periodically (e.g. every 5 min), any non-stale Story with **no new article in 24h** + → set `is_stale=True`, broadcast `story.stale`. **Exclude stale stories** from + `GET /stories` by default. + +### 5e. Pagination +Keyset cursor over `(signal_score DESC, id DESC)`. `nextCursor` = base64 of +`"{signal_score}:{id}"` of the last row; on `after`, decode and filter +`(signal_score < s) OR (signal_score == s AND id < id)`. `hasMore` = more rows exist; +`total` = total non-stale stories (optionally filtered by topic/min_signal). + +--- + +## 6. WebSocket `/ws` + +On connect: `accept()`, register the socket. The server emits these (client refetches +full objects via REST as needed): + +```jsonc +{"type":"story.updated","storyId":"story_x","signalScore":94,"sourceCount":9,"updatedAt":"...Z"} +{"type":"story.created","storyId":"story_y","headline":"...","signalScore":42,"topic":"politics","createdAt":"...Z"} +{"type":"story.stale","storyId":"story_z","updatedAt":"...Z"} +{"type":"feed.health","feedId":"feed_a","health":"failing","failureCount":3,"updatedAt":"...Z"} +``` + +- Send `{"type":"ping"}` every **30s**; the client replies `{"type":"pong"}` (you can + ignore it). The client ALSO sends WebSocket-protocol pings — Uvicorn/websockets + auto-answers those, no action needed. +- Keep a `ConnectionManager` with `broadcast(dict)`; the ingest/correlate/scoring code + calls it. Drop dead sockets on send failure. +- Reconnect/backoff is handled entirely client-side — just accept new connections. + +--- + +## 7. Proxmox deployment (recommended: unprivileged LXC) + +A Python web service is a perfect fit for an **LXC container** (lighter than a VM). + +1. **Create the container** (Proxmox shell or UI): + ```bash + # Debian 12 template, on the Proxmox host: + pveam update && pveam available | grep debian-12 + pveam download local debian-12-standard_*_amd64.tar.zst + pct create 140 local:vztmpl/debian-12-standard_*_amd64.tar.zst \ + --hostname jarvis-api --cores 2 --memory 1024 --swap 512 \ + --net0 name=eth0,bridge=vmbr0,ip=dhcp \ + --rootfs local-lvm:8 --unprivileged 1 --features nesting=1 + pct start 140 && pct enter 140 + ``` + Note the container's DHCP IP (`ip a`) — that's the host the iPhone points at. A + **static IP / DHCP reservation** is recommended so the app's saved host stays valid. + +2. **Inside the container:** + ```bash + apt update && apt install -y python3 python3-venv python3-pip git + adduser --system --group jarvis + mkdir -p /opt/jarvis && chown jarvis:jarvis /opt/jarvis + # copy the server/ folder here (git clone, scp, or pct push) + cd /opt/jarvis + python3 -m venv .venv && . .venv/bin/activate + pip install -r requirements.txt + ``` + +3. **systemd service** (`/etc/systemd/system/jarvis.service`): + ```ini + [Unit] + Description=Jarvis news-correlation API + After=network-online.target + Wants=network-online.target + + [Service] + User=jarvis + WorkingDirectory=/opt/jarvis + ExecStart=/opt/jarvis/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080 + Restart=always + RestartSec=3 + Environment=JARVIS_DB=/opt/jarvis/jarvis.db + + [Install] + WantedBy=multi-user.target + ``` + ```bash + systemctl daemon-reload && systemctl enable --now jarvis + systemctl status jarvis + ``` + +4. **Verify from the Proxmox host or your Mac (same LAN):** + ```bash + curl http://:8080/api/v1/health + curl http://:8080/api/v1/stories | head + ``` + +5. **Point the app at it:** launch Jarvis on the iPhone/sim → onboarding → enter + `http://:8080` **as `:8080`** (the app prepends + `http://` and `ws://` itself — enter just `host:port`, e.g. `192.168.30.50:8080`). + +> Networking notes: bind `0.0.0.0` (not `127.0.0.1`) so the LAN can reach it. If the +> Proxmox host or container runs a firewall, allow TCP **8080**. No TLS/auth by design — +> keep this on a trusted VLAN or behind the homelab firewall/VPN, per the contract. + +--- + +## 8. Acceptance checklist (done = the app works end-to-end) + +- [ ] `GET /api/v1/health` returns ok JSON; onboarding "Connect" succeeds. +- [ ] `GET /api/v1/stories` returns camelCase, `...Z` dates, sorted by `signalScore` desc. +- [ ] Stories actually cluster multi-source articles (not 1 story per article). +- [ ] `scoreBreakdown` ints sum exactly to `signalScore`. +- [ ] `GET /stories/{id}` includes a chronological `timeline[]`. +- [ ] `GET /articles/{id}` returns full `body` (offline reader works). +- [ ] `GET /feeds` shows health states; `POST /feeds` (201) and `DELETE` (204) work. +- [ ] WebSocket `/ws` connects; the app's header shows **LIVE**; a score change pushes + `story.updated` and the feed reorders. +- [ ] Runs as a systemd service in the Proxmox LXC and survives a container reboot. + +--- + +## 9. Hand-back + +The iOS client is in `../Jarvis` (SwiftUI, builds with `xcodegen` + Xcode 26 / iOS 17). +While wiring the frontend I had to fix three bugs in the provided client layer that +also document real expectations of your server — worth knowing: +- `StoryStore` patches stories in-place on `story.updated` using `signalScore` + + `sourceCount` from the event (so those two fields are required on that event). +- The client treats **HTTP `http://`** and **`ws://`** (no TLS). +- On relaunch it reconnects REST+WS from the saved host, then immediately calls + `GET /stories` — so the server should be ready to serve quickly after boot. + +Questions for whoever runs this: which RSS feeds to seed by default, and the local +lexicon (which country/region is "local" for `localRelevance`)? The contract examples +are Uganda/East-Africa centric (Daily Monitor, NilePost, BoU) — default `seed_feeds.py` +and the local lexicon to that unless told otherwise. +``` diff --git a/docs/backend/CODEX_PREHANDOVER_CHECKLIST.md b/docs/backend/CODEX_PREHANDOVER_CHECKLIST.md new file mode 100644 index 0000000..5b263b5 --- /dev/null +++ b/docs/backend/CODEX_PREHANDOVER_CHECKLIST.md @@ -0,0 +1,150 @@ +# Jarvis Backend — Pre-Handover Checklist (Codex → integration) + +Run every item before declaring the backend done. Most have a command + expected +result, so "done" is **verified**, not assumed. Set the host once: + +```bash +HOST=http://:8080 # e.g. http://192.168.30.50:8080 +``` + +> The single biggest cause of a "connected but empty" app is **date format** and +> **key casing** (§A). If §A doesn't pass, nothing else matters — the client drops +> the objects silently. + +--- + +## A. Contract compatibility (the silent killers) + +- [ ] **Dates are `YYYY-MM-DDTHH:MM:SSZ`** — UTC, literal `Z`, NO fractional seconds. + ```bash + curl -s "$HOST/api/v1/stories" | python3 -c ' + import sys,json,re + d=json.load(sys.stdin)["data"] + bad=[s[k] for s in d for k in ("updatedAt","createdAt") + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", s[k])] + print("DATES OK" if not bad else ("BAD DATES: "+str(bad[:3])))' + ``` +- [ ] **Keys are camelCase** — `signalScore`, `scoreBreakdown`, `sourceCount`, + `pollIntervalSeconds`, `publishedAt`, `nextCursor`, `hasMore`, `isBreaking`, + `sourceUrl`, `imageUrl`, `storyId`, `articleCountToday`, `lastFetchedAt`. + No snake_case anywhere in any response body. +- [ ] **`scoreBreakdown` ints sum exactly to `signalScore`.** + ```bash + curl -s "$HOST/api/v1/stories" | python3 -c ' + import sys,json + d=json.load(sys.stdin)["data"] + bad=[s["id"] for s in d if sum(s["scoreBreakdown"].values())!=s["signalScore"]] + print("SCORES OK" if not bad else "SUM MISMATCH: "+str(bad))' + ``` +- [ ] **Stories sorted by `signalScore` descending** in `GET /stories`. +- [ ] **Topic is always one of** `finance | tech | politics | africa` (the app's pills). +- [ ] Unknown/extra fields are fine, but **no required field is missing or null** + where the client expects a value (e.g. `headline`, `topic`, `signalScore`, + `sourceCount`, `scoreBreakdown`, `updatedAt`, `createdAt`, `sources`). + +## B. Endpoints behave exactly as the client calls them + +- [ ] `GET /api/v1/health` → `{status,version,storiesCount,feedsCount,uptime}` (200). +- [ ] `GET /api/v1/stories?limit=20` honors `limit` (≤100), returns + `{data,nextCursor,hasMore,total}`. +- [ ] **Pagination round-trips:** take `nextCursor`, pass it as `after`, get the next + page with no overlap and no dupes. + ```bash + C=$(curl -s "$HOST/api/v1/stories?limit=2" | python3 -c 'import sys,json;print(json.load(sys.stdin)["nextCursor"])') + curl -s "$HOST/api/v1/stories?limit=2&after=$C" | python3 -c 'import sys,json;print("page2 ids:",[s["id"] for s in json.load(sys.stdin)["data"]])' + ``` +- [ ] `GET /api/v1/stories?topic=finance` filters; `&min_signal=80` filters. +- [ ] `GET /api/v1/stories/{id}` → `StoryDetail` **with `timeline[]`** (chronological). +- [ ] Bad id → **404** with `{"error":{"code":"story_not_found","message":...,"status":404}}`. +- [ ] `GET /api/v1/articles/{id}` → full `body` present (not truncated to the RSS blurb + if avoidable); `storyId` matches. +- [ ] `GET /api/v1/feeds` → `{data:[Feed]}` with `health`, `failureCount`, + `articleCountToday`, `lastFetchedAt`. +- [ ] `POST /api/v1/feeds` with `{"url","name","pollIntervalSeconds"}` → **201** + feed body. + ```bash + curl -s -X POST "$HOST/api/v1/feeds" -H 'Content-Type: application/json' \ + -d '{"url":"https://feeds.bbci.co.uk/news/world/rss.xml","name":"BBC World","pollIntervalSeconds":900}' -i | head -1 + ``` +- [ ] Invalid feed URL → **400** `invalid_url`. +- [ ] `DELETE /api/v1/feeds/{id}` → **204** no body; deleting missing id → 404 `feed_not_found`. + +## C. Correlation & scoring are actually working + +- [ ] Articles from **different sources** about the same event land in **one** Story + (spot-check: a Story with `sourceCount >= 2` whose `timeline` shows ≥2 distinct sources). +- [ ] Not the degenerate case of **one Story per article** (clustering threshold too high) + or **everything in one Story** (threshold too low). +- [ ] `sources[]` / `timeline[]` list the right outlets; exactly one entry flagged the + earliest/`isBreaking` per the seed logic. +- [ ] `consensus` text appears when `sourceCount >= 2`; `conflict` is `null` unless a + real dispute marker is present. +- [ ] `freshness` decays over time (an old Story scores lower than a fresh one with the + same sources). + +## D. WebSocket `/ws` + +- [ ] `ws:///ws` accepts a connection (test below). + ```bash + python3 - <<'PY' + import asyncio, websockets, json + async def main(): + async with websockets.connect("ws://:8080/ws") as ws: + print("connected; waiting for an event/ping (15s)...") + try: print(await asyncio.wait_for(ws.recv(), 15)) + except asyncio.TimeoutError: print("no message in 15s (ok if idle)") + asyncio.run(main()) + PY + ``` +- [ ] Server sends `{"type":"ping"}` ~every 30s and tolerates the client's + `{"type":"pong"}` reply. +- [ ] A score change broadcasts `story.updated` **with `signalScore` AND `sourceCount`** + (the client requires both on that event). +- [ ] A brand-new cluster broadcasts `story.created`; a stale one broadcasts `story.stale`. +- [ ] A feed health transition broadcasts `feed.health` with `feedId`, `health`, + `failureCount`, `updatedAt`. +- [ ] Multiple simultaneous clients all receive broadcasts; a dropped client doesn't + crash the loop. + +## E. Ingest robustness + +- [ ] A dead/unreachable feed URL drives `active → failing → dead` and increments + `failureCount` without crashing the poller. +- [ ] Duplicate articles (same guid/link re-published) are **not** inserted twice. +- [ ] HTML is stripped from `body`; `imageUrl` is populated when the feed provides one. +- [ ] Missing `published_at` falls back sanely (now) rather than erroring. +- [ ] Poller respects each feed's `pollIntervalSeconds` (doesn't hammer sources). + +## F. Deployment on Proxmox + +- [ ] Runs under **systemd** (`systemctl status jarvis` = active/running), not a stray shell. +- [ ] Binds **`0.0.0.0:8080`** (reachable from the LAN, not just localhost): + `curl http://:8080/api/v1/health` works **from your Mac**, not only inside the container. +- [ ] **Survives reboot:** `reboot` the LXC, then `curl .../health` succeeds with the + service back up and data intact. +- [ ] SQLite DB persists at a fixed path (e.g. `/opt/jarvis/jarvis.db`) and isn't wiped on restart. +- [ ] Container has a **static IP / DHCP reservation** so the app's saved host stays valid. +- [ ] Firewall (if any) allows inbound TCP **8080**. +- [ ] Logs are reachable: `journalctl -u jarvis -n 50` shows clean startup + poll cycles. + +## G. End-to-end with the real app (the real acceptance) + +- [ ] Onboarding: enter `:8080` → **Connect succeeds**. +- [ ] Home feed populates, ranked by signal, fade rendering looks right. +- [ ] Header shows **LIVE** (green) — WebSocket connected. +- [ ] Tap a story → detail with timeline → tap an article → reader shows full body. +- [ ] Pull-to-refresh works; opening an article caches it (green dot / offline badge appears). +- [ ] Feed manager (radio icon, top-right) lists feeds with correct health dots; add + swipe-delete work. + +## H. Handover packet (give these back for integration) + +- [ ] The **container IP/host** and confirmation the service is enabled on boot. +- [ ] One **sample `GET /stories` response** and one **`GET /stories/{id}`** (so I can + diff against the client decoder). +- [ ] The **seed feed list** used and the **`localRelevance` region/lexicon** chosen. +- [ ] Any deviations from `BACKEND_HANDOFF.md` (endpoints, fields, thresholds) called out. +- [ ] `requirements.txt` frozen (`pip freeze`) and the repo/commit deployed. + +--- + +**Definition of done:** §A–§F all green, §G walks through on a real device, and §H is +handed back. At that point ping me and I'll run the client-side integration pass. diff --git a/docs/backend/requirements.txt b/docs/backend/requirements.txt new file mode 100644 index 0000000..7e08c8f --- /dev/null +++ b/docs/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlmodel==0.0.22 +feedparser==6.0.11 +httpx==0.28.1 +scikit-learn==1.6.1 +numpy==2.2.1 +beautifulsoup4==4.12.3 diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..5b763fb --- /dev/null +++ b/project.yml @@ -0,0 +1,42 @@ +name: Jarvis +options: + bundleIdPrefix: com.kisani + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true + +targets: + Jarvis: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: Jarvis + info: + path: Jarvis/Info.plist + properties: + CFBundleDisplayName: Jarvis + UILaunchScreen: + UIColorName: "" + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + UIUserInterfaceStyle: Dark + NSLocalNetworkUsageDescription: "Jarvis connects to your self-hosted news platform on the local network." + NSAppTransportSecurity: + NSAllowsArbitraryLoads: true + NSAllowsLocalNetworking: true + LSApplicationQueriesSchemes: + - tailscale + - zerotier + - wireguard + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + TARGETED_DEVICE_FAMILY: "1" + SWIFT_VERSION: "5.0" + CODE_SIGNING_ALLOWED: "NO" + CODE_SIGNING_REQUIRED: "NO" + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + ENABLE_USER_SCRIPT_SANDBOXING: "YES" diff --git a/scripts/create-issues.sh b/scripts/create-issues.sh new file mode 100755 index 0000000..1d4e990 --- /dev/null +++ b/scripts/create-issues.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Populate the GitHub issue tracker from docs/BACKLOG.md. +# Run AFTER the repo exists and `gh` is authenticated: bash scripts/create-issues.sh +set -euo pipefail + +gh label create client --color 1D76DB --description "iOS app" 2>/dev/null || true +gh label create backend --color 0E8A16 --description "Backend coordination" 2>/dev/null || true +gh label create ci --color 5319E7 --description "CI / build" 2>/dev/null || true +gh label create connectivity --color FBCA04 --description "Networking / VPN" 2>/dev/null || true + +issue() { gh issue create --title "$1" --label "$2" --body "$3"; } + +issue "Add Sport (F1) and World topic pills" "enhancement,client,backend" \ +"The 47 feeds include heavy F1 + global-news coverage squeezed into finance/tech/politics/africa. Add pills in \`Topic.filters\`; backend classifier must emit \`sport\` / \`world\` slugs to match." + +issue "Wire the Tailscale remote host in Connectivity" "enhancement,client,connectivity" \ +"Populate the Remote Access card's host so away-from-home auto-selects the Tailscale address. Verify the LAN⇄Tailscale switch via /health probing." + +issue "SSID-based trusted networks" "enhancement,client,connectivity" \ +"Pin trusted Wi-Fi by name (\"Add current network\"). Needs the Access WiFi Information entitlement (real device + paid account); no-ops in the simulator. Currently approximated by LAN reachability." + +issue "Optional in-app WireGuard tunnel" "enhancement,client,connectivity" \ +"NetworkExtension packet-tunnel so Jarvis can bring up a WireGuard tunnel itself (WireGuard/Netmaker configs only). Needs entitlement + real device. Deferred." + +issue "App icon + launch assets" "enhancement,client" \ +"AppIcon is an empty placeholder." + +issue "Flesh out Latest / Saved / Search tabs" "enhancement,client" \ +"Currently minimal reuse of the feed components; add proper empty/loading states and dedicated behavior." + +issue "Verify hero image loading in the reader" "client" \ +"AsyncImage against real feed imageUrls (ATS, redirects, grey fallback) in the article reader." + +issue "Validate infinite scroll / pagination at scale" "client" \ +"Validate loadMore/cursor round-trips against the live ~850-story dataset; guard against dupes." + +issue "Topic-classifier coverage for the 47 feeds" "backend" \ +"Ensure science/tech/world/sport map sensibly into the supported topic slugs." + +issue "Add unit/UI tests + test matrix leg" "ci" \ +"The build matrix compiles but runs no tests yet; add a test target and a test matrix leg." + +issue "Offline-first polish" "enhancement,client" \ +"Green cached dot + Saved tab depend on opening an article; consider pre-caching top stories for true offline-first." + +echo "Done. Created issues from docs/BACKLOG.md."