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 <noreply@anthropic.com>
This commit is contained in:
20
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -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
|
||||||
16
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
16
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -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
|
||||||
11
.github/pull_request_template.md
vendored
Normal file
11
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
-
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- [ ] `xcodegen generate && xcodebuild ... build` passes
|
||||||
|
- [ ] Verified on simulator against a live/mock backend
|
||||||
|
|
||||||
|
## Related issues
|
||||||
|
Closes #
|
||||||
47
.github/workflows/ci.yml
vendored
Normal file
47
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -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
|
||||||
323
API/contract.md
Normal file
323
API/contract.md
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
# Jarvis API Contract
|
||||||
|
|
||||||
|
Base URL: `http://<host>:<port>/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://<host>:<port>/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 |
|
||||||
53
CLAUDE.md
Normal file
53
CLAUDE.md
Normal file
@@ -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)
|
||||||
|
```
|
||||||
20
Jarvis/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
20
Jarvis/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Jarvis/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
13
Jarvis/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Jarvis/Assets.xcassets/Contents.json
Normal file
6
Jarvis/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Jarvis/Assets.xcassets/KisaniOrange.colorset/Contents.json
Normal file
38
Jarvis/Assets.xcassets/KisaniOrange.colorset/Contents.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
111
Jarvis/Connectivity/ConnectivityManager.swift
Normal file
111
Jarvis/Connectivity/ConnectivityManager.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Jarvis/Connectivity/ConnectivitySettings.swift
Normal file
73
Jarvis/Connectivity/ConnectivitySettings.swift
Normal file
@@ -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 <provider> 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<String>(); 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 }
|
||||||
|
}
|
||||||
101
Jarvis/Connectivity/VPNProvider.swift
Normal file
101
Jarvis/Connectivity/VPNProvider.swift
Normal file
@@ -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 <provider>".
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
Jarvis/Info.plist
Normal file
50
Jarvis/Info.plist
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Jarvis</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>tailscale</string>
|
||||||
|
<string>zerotier</string>
|
||||||
|
<string>wireguard</string>
|
||||||
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>Jarvis connects to your self-hosted news platform on the local network.</string>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string></string>
|
||||||
|
</dict>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
</array>
|
||||||
|
<key>UIUserInterfaceStyle</key>
|
||||||
|
<string>Dark</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
35
Jarvis/JarvisApp.swift
Normal file
35
Jarvis/JarvisApp.swift
Normal file
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
214
Jarvis/Models/Models.swift
Normal file
214
Jarvis/Models/Models.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
159
Jarvis/Networking/APIClient.swift
Normal file
159
Jarvis/Networking/APIClient.swift
Normal file
@@ -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<T: Decodable>(_ 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<T: Decodable>(_ 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<T: Decodable>(_ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
Jarvis/Networking/WebSocketManager.swift
Normal file
128
Jarvis/Networking/WebSocketManager.swift
Normal file
@@ -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<WSEvent, Never>()
|
||||||
|
|
||||||
|
private var task: URLSessionWebSocketTask?
|
||||||
|
private var pingTimer: Timer?
|
||||||
|
private var reconnectTask: Task<Void, Never>?
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Jarvis/Store/ServerSettings.swift
Normal file
48
Jarvis/Store/ServerSettings.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
143
Jarvis/Store/StoryStore.swift
Normal file
143
Jarvis/Store/StoryStore.swift
Normal file
@@ -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<AnyCancellable>()
|
||||||
|
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")
|
||||||
|
}
|
||||||
322
Jarvis/Views/Connectivity/ConnectivityView.swift
Normal file
322
Jarvis/Views/Connectivity/ConnectivityView.swift
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
// ConnectivityView.swift
|
||||||
|
// Jarvis — server address, a Remote Access card (Use <provider> 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<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||||
|
content()
|
||||||
|
.background(Palette.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cardRow<Content: View>(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<String>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
Jarvis/Views/Feeds/AddFeedSheet.swift
Normal file
114
Jarvis/Views/Feeds/AddFeedSheet.swift
Normal file
@@ -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<String>, 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." }
|
||||||
|
}
|
||||||
|
}
|
||||||
329
Jarvis/Views/Feeds/FeedManagerView.swift
Normal file
329
Jarvis/Views/Feeds/FeedManagerView.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
245
Jarvis/Views/Home/SignalFeedView.swift
Normal file
245
Jarvis/Views/Home/SignalFeedView.swift
Normal file
@@ -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<String> { 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<CachedStory>(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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
Jarvis/Views/Home/StoryRowView.swift
Normal file
89
Jarvis/Views/Home/StoryRowView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
128
Jarvis/Views/Onboarding/OnboardingView.swift
Normal file
128
Jarvis/Views/Onboarding/OnboardingView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
250
Jarvis/Views/Reader/ArticleReaderView.swift
Normal file
250
Jarvis/Views/Reader/ArticleReaderView.swift
Normal file
@@ -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<CachedArticle>(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<CachedArticle>(predicate: #Predicate { $0.id == id })
|
||||||
|
))?.first {
|
||||||
|
article = Article(cached: cached)
|
||||||
|
}
|
||||||
|
let sid = route.storyId
|
||||||
|
let arts = (try? context.fetch(
|
||||||
|
FetchDescriptor<CachedArticle>(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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Jarvis/Views/RootTabView.swift
Normal file
34
Jarvis/Views/RootTabView.swift
Normal file
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
178
Jarvis/Views/SecondaryTabs.swift
Normal file
178
Jarvis/Views/SecondaryTabs.swift
Normal file
@@ -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<String>
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
Jarvis/Views/Shared/CachedBadge.swift
Normal file
43
Jarvis/Views/Shared/CachedBadge.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
60
Jarvis/Views/Shared/ConnectionBanner.swift
Normal file
60
Jarvis/Views/Shared/ConnectionBanner.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
31
Jarvis/Views/Shared/SignalStripe.swift
Normal file
31
Jarvis/Views/Shared/SignalStripe.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
42
Jarvis/Views/Shared/SourceChips.swift
Normal file
42
Jarvis/Views/Shared/SourceChips.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
162
Jarvis/Views/Shared/Theme.swift
Normal file
162
Jarvis/Views/Shared/Theme.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
244
Jarvis/Views/Story/StoryDetailView.swift
Normal file
244
Jarvis/Views/Story/StoryDetailView.swift
Normal file
@@ -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<String>(); 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<String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
63
README.md
Normal file
63
README.md
Normal file
@@ -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).
|
||||||
32
docs/BACKLOG.md
Normal file
32
docs/BACKLOG.md
Normal file
@@ -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.
|
||||||
188
docs/IOS_CLIENT_EXPECTATIONS.md
Normal file
188
docs/IOS_CLIENT_EXPECTATIONS.md
Normal file
@@ -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 `<img>` | 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.
|
||||||
147
docs/KICKOFF_PROMPT.md
Normal file
147
docs/KICKOFF_PROMPT.md
Normal file
@@ -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.
|
||||||
336
docs/backend/BACKEND_HANDOFF.md
Normal file
336
docs/backend/BACKEND_HANDOFF.md
Normal file
@@ -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://<host>/api/v1`** and **WebSocket is `ws://<host>/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 `<img>`), `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://<container-ip>:8080/api/v1/health
|
||||||
|
curl http://<container-ip>:8080/api/v1/stories | head
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Point the app at it:** launch Jarvis on the iPhone/sim → onboarding → enter
|
||||||
|
`http://<container-ip>:8080` **as `<container-ip>: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.
|
||||||
|
```
|
||||||
150
docs/backend/CODEX_PREHANDOVER_CHECKLIST.md
Normal file
150
docs/backend/CODEX_PREHANDOVER_CHECKLIST.md
Normal file
@@ -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://<container-ip>: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://<host>/ws` accepts a connection (test below).
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import asyncio, websockets, json
|
||||||
|
async def main():
|
||||||
|
async with websockets.connect("ws://<container-ip>: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://<container-ip>: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 `<container-ip>: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.
|
||||||
8
docs/backend/requirements.txt
Normal file
8
docs/backend/requirements.txt
Normal file
@@ -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
|
||||||
42
project.yml
Normal file
42
project.yml
Normal file
@@ -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"
|
||||||
46
scripts/create-issues.sh
Executable file
46
scripts/create-issues.sh
Executable file
@@ -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."
|
||||||
Reference in New Issue
Block a user