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:
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.
|
||||
Reference in New Issue
Block a user