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>
189 lines
9.4 KiB
Markdown
189 lines
9.4 KiB
Markdown
# 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.
|