Skip to content

feat(lists): saved list views — shared/personal, forking, per-user defaults (G40, #81) - #105

Merged
Adron merged 2 commits into
devfrom
feat/saved-list-views-g40
Sep 17, 2026
Merged

Adron merged 2 commits into
devfrom
feat/saved-list-views-g40

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Implements GitHub issue #81 (G40). macOS shipped one hard-coded arrangement per list; on a shared list a collaborator could neither see the owner's arrangement nor keep one of their own. This lands all five /api/lists/{id}/views* routes end to end: Kit builders + DTOs, domain models + five ListsServicing methods, and a saved-views menu plus manage sheet on the rows pane.

Branch base. Branched from origin/fix/xcode27-document-ambiguity, not from devdev does not compile on Xcode 27 (SwiftUI added a Document protocol; see issue #98 / PR #99). That branch is dev plus the one-line-per-site qualification fix. Merge #99 first, or this PR carries it along.

Confirmed API facts this is built against

Captured live against the test account on 2026-09-15; the GET envelope was re-read on 2026-09-16 (200 {"views":[]}). All five ops are x-auth-type: sync-token and x-subscription-tier: free — Bearer-reachable on a free account, no subscription gate, and none was added.

Op Response
GET /api/lists/{id}/views {"views":[ListView]}
POST /api/lists/{id}/views → 201 {"view":ListView}
POST /api/lists/{id}/views/{viewId} (fork) → 201 {"view":ListView}
PUT /api/lists/{id}/views/{viewId} → 200 {"view":ListView}
DELETE /api/lists/{id}/views/{viewId} → 200 {"message":"View deleted"}

The live ListView row is exactly id, listId, userId, name, scope, config, isDefault, position.

⚠️ Two decode traps — the spec is wrong about this resource twice

Trap 1 — createdAt / updatedAt are REQUIRED in the schema and absent from every live response. POST, GET and PUT all omitted both. A Decodable mirroring the schema throws keyNotFound on every row. Both are modelled optional, with the reason recorded on ListViewDTO. Guarded by test_givenLiveViewRowWithoutTimestamps_whenListingViews_thenDecodesEveryField.

Trap 2 — the spec's response example is not a real payload. It shows config.filters: [{"key":"read","op":"eq","value":false}] surviving a write. That exact object was sent live and came back "filters": []. No fixture in this PR derives from the example; test_givenSpecExampleFilter_whenCreating_thenRoundTripsWhateverTheServerStored records the fact so nobody "fixes" the model back toward the document.

Related: the request body declares "config": {"type":"string"} — a generator artifact, and the reading issue #81 was filed on. config is an object; sending one returns 201.

filters grammar is UNCONFIRMED — and named as such in the code

The recon account's only list has an empty schema (zero columns), so every probe filter referenced a column key that does not exist and was dropped. That makes a grammar failure and a column-not-found indistinguishable. filters is therefore modelled as opaque JSON ([ListCellValue] in the domain, [ListJSONValue] on the wire) that round-trips untouched, with a comment saying so. No {key, op, value} shape is fabricated as if verified.

config is a four-key whitelist — issue #81's presumption is wrong

A live probe sent columns, visibleColumns, columnOrder, hiddenColumns, groupBy, sort, sortBy, sortDirection, rowHeight and a deliberate bogusKey. All ten were stripped silently, no 400. So config does not encode column order / visibility / sort, and this PR does not pretend it does — modelling those would be modelling storage that does not exist.

  • Server default on create: {"mode":"records","density":"comfortable","filters":[]}
  • mode: only records ever stuck (table/gallery/kanban/board/grid/list/cards all silently fell back, HTTP 200)
  • density: comfortable + compact accepted; spacious/cozy/dense/comfy silently fell back
  • PUT config is a whole-object REPLACE, not a merge — omitting density reset it to comfortable

density is therefore the only stored value with a visible client effect today, and it is what applying a view changes (row line-limit in both table and card mode). Every write sends the complete config so filters / search the web may have set are never clobbered.

Validation asymmetry, encoded on purpose

scope hard-failsPOST {"scope":"bogus_scope"}400 {"error":"scope must be \"personal\" or \"shared\"","code":"bad_request"}. So SavedListViewScope is a closed personal | shared enum with no round-trip escape hatch: preserving an unknown token would guarantee a 400 on the next write, the opposite of what ViewingPreference.other buys. An unreadable token collapses to .personal (mislabelling a private view as shared is the more harmful mistake).

mode and density default silently, so both carry .unknown(String): a token this build does not recognise is a real state, and collapsing it would overwrite the user's stored choice on the next save.

Fork works on a personal source view too, not only a shared one — so the UI offers it as plain "Duplicate" on every row.

What the UI does

A menu next to the existing view-mode picker in the rows pane, plus a manage sheet. Shared views (person.2) and personal views (person) get separate sections. Duplicate is surfaced at the top level, not buried — it is the spec's escape hatch and the action a collaborator reaches for. load() applies the caller's isDefault view, so two people open the same shared list into their own arrangements. Create / rename / delete / make-default all present, with optimistic UI + snapshot rollback and a pendingOperations debounce set. On a read-only (watcher) share, shared-view create/rename/delete are hidden, not disabled, per the project rule; personal views stay available.

A new ListsEvent.savedViewsChanged(listId:views:) carries the whole post-write collection rather than a single row — marking a view default clears the previous one, so a per-row event would leave a second window showing two defaults.

Also in this PR

ListJSONValue.init(from: ListCellValue) moved out of ListsService.swift (where it was fileprivate) into ListMappers.swift beside its inverse. G40 is the second writer of loose JSON; a drifted private copy of a lossless round-trip would silently corrupt whatever it disagreed about.

StubAPIClient gained sentBodies + an XCTestCase.lastSentJSON(_:) helper so domain tests can assert what exactly was sent — load-bearing here because config must go out as an object and PUT replaces whole.

Gate — real output

xcodebuild build -project InterlinedList.xcodeproj -scheme InterlinedList -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  ** BUILD SUCCEEDED **

xcodebuild test -project InterlinedList.xcodeproj -scheme InterlinedList -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  Executed 1001 tests, with 0 failures (0 unexpected) in 17.976 (18.517) seconds
  ** TEST SUCCEEDED **

swift test --package-path Packages/InterlinedKit --skip ContractTests
  Executed 494 tests, with 0 failures (0 unexpected) in 0.803 (0.955) seconds

swift test --package-path Packages/InterlinedDomain
  Executed 1034 tests, with 0 failures (0 unexpected) in 4.831 (4.919) seconds

swift test --package-path Packages/InterlinedPersistence
  Executed 140 tests, with 0 failures (0 unexpected) in 0.893 (0.908) seconds

grep -rn "^import InterlinedKit" App/Features App/Navigation App/MenuCommands
  (no output — zero hits, Decision 0003 holds)

Live ContractTests were SKIPPED (--skip ContractTests): the recon session rate-limited the test account. They were not run and no result from them is claimed.

Tests added (61 new)

Kit — ListViewsEndpointTests (17): test_givenSavedViewBuilders_whenConstructed_thenUseExpectedMethodPathAuth, test_givenLiveViewRowWithoutTimestamps_whenListingViews_thenDecodesEveryField, test_givenRenamedKey_whenListingViews_thenFailsTheDecodeRatherThanDegrading (the issue's explicit acceptance criterion), test_givenListWithNoSavedViews_whenListingViews_thenDecodesEmptyCollection, test_givenUnauthorizedCaller_whenListingViews_thenSurfacesUnauthorized, test_givenNamedPersonalView_whenCreating_thenSendsConfigAsObjectAndDecodesEnvelope, test_givenSpecExampleFilter_whenCreating_thenRoundTripsWhateverTheServerStored, test_givenOmittedOptionalFields_whenCreating_thenSkipsThemInTheBody, test_givenIllegalScope_whenCreating_thenSurfacesTheServersBadRequest, test_givenSharedView_whenForking_thenPostsToTheViewPathAndDecodesTheCopy, test_givenNoName_whenForking_thenSendsAnEmptyBodyAndLetsTheServerName, test_givenMissingSourceView_whenForking_thenSurfacesNotFound, test_givenFullConfig_whenUpdating_thenSendsEveryConfigKeyBecausePutReplaces, test_givenRenameOnly_whenUpdating_thenOmitsConfigEntirely, test_givenForbiddenUpdate_whenUpdatingSomeoneElsesView_thenSurfacesForbidden, test_givenExistingView_whenDeleting_thenSendsDeleteAndIgnoresTheMessageBody, test_givenMissingView_whenDeleting_thenSurfacesNotFound

Domain — SavedListViewsServiceTests (22): test_givenSharedAndPersonalViews_whenLoading_thenMapsScopeConfigAndDefault, test_givenUnknownModeAndDensity_whenLoading_thenCarriesTheTokensRatherThanRewritingThem, test_givenUnrecognisedScope_whenLoading_thenTreatsItAsPersonal, test_givenOpaqueFilters_whenLoading_thenRoundTripsThemUntouched, test_givenListWithNoViews_whenLoading_thenReturnsEmpty, test_givenUpstreamFailure_whenLoading_thenPropagatesTheAPIError, test_givenNamedPersonalView_whenCreating_thenSendsScopeTokenAndConfigObject, test_givenBlankName_whenCreating_thenThrowsWithoutCallingTheAPI, test_givenSurroundingWhitespace_whenCreating_thenSendsTheTrimmedName, test_givenServerRejectingScope_whenCreating_thenPropagatesTheBadRequest, test_givenServerNormalisingTheConfig_whenCreating_thenReturnsTheStoredArrangement, test_givenFullConfig_whenUpdating_thenSendsEveryConfigKey, test_givenBlankRename_whenUpdating_thenThrowsWithoutCallingTheAPI, test_givenOnlyTheDefaultFlag_whenUpdating_thenOmitsNameAndConfig, test_givenUpstreamFailure_whenUpdating_thenPropagatesTheAPIError, test_givenExistingView_whenDeleting_thenSendsDelete, test_givenMissingView_whenDeleting_thenPropagatesNotFound, test_givenSharedView_whenForking_thenReturnsAPersonalCopy, test_givenBlankForkName_whenForking_thenThrowsWithoutCallingTheAPI, test_givenNoForkName_whenForking_thenSendsAnEmptyBodyAndLetsTheServerName, test_givenMissingSourceView_whenForking_thenPropagatesNotFound, test_givenFreeAccount_whenUsingSavedViews_thenNoSubscriberGateApplies

App — SavedViewsViewModelTests (22): test_givenSharedAndPersonalViews_whenLoading_thenSplitsThemByScope, test_givenAViewMarkedDefault_whenLoading_thenAppliesItOnOpen, test_givenNoDefaultView_whenLoading_thenAppliesNoneAndFallsBackToTheServerDensity, test_givenListWithNoViews_whenLoading_thenLeavesEverythingEmpty, test_givenUpstreamFailure_whenLoading_thenSurfacesTheError, test_givenLoadedViews_whenSelecting_thenAppliesWithoutCallingTheService, test_givenUnknownViewID_whenSelecting_thenKeepsTheCurrentSelection, test_givenNameAndScope_whenCreating_thenCallsServiceAndAppliesTheNewView, test_givenBlankName_whenCreating_thenReportsValidationAndCallsNoService, test_givenNewDefaultView_whenCreating_thenDemotesThePreviousDefault, test_givenUpstreamFailure_whenCreating_thenSurfacesTheErrorAndAddsNothing, test_givenLoadedView_whenRenaming_thenSwapsTheLabelAndKeepsTheConfigUntouched, test_givenBlankRename_whenRenaming_thenReportsValidationAndCallsNoService, test_givenUpstreamFailure_whenRenaming_thenRestoresTheOriginalName, test_givenUnknownViewID_whenRenaming_thenDoesNothing, test_givenSecondView_whenMakingItDefault_thenExactlyOneViewIsDefault, test_givenUpstreamFailure_whenMakingDefault_thenRestoresThePreviousDefault, test_givenUnknownViewID_whenMakingDefault_thenCallsNoService, test_givenAppliedView_whenChangingDensity_thenSendsTheWholeConfig, test_givenNoAppliedView_whenChangingDensity_thenCallsNoService, test_givenTheSameDensity_whenChangingDensity_thenSkipsTheRoundTrip, test_givenUpstreamFailure_whenChangingDensity_thenRollsBack, test_givenLoadedView_whenDeleting_thenRemovesItAndClearsTheSelection, test_givenUpstreamFailure_whenDeleting_thenRestoresTheRowAndTheSelection, test_givenUnknownViewID_whenDeleting_thenCallsNoService, test_givenSharedView_whenForking_thenAppendsAPersonalCopyAndAppliesIt, test_givenBlankForkName_whenForking_thenReportsValidationAndCallsNoService, test_givenNoForkName_whenForking_thenLetsTheServerName, test_givenUpstreamFailure_whenForking_thenAddsNothing, plus the event-bus routing set: test_givenSavedViewsChangedForThisList_whenApplying_thenReplacesTheCollection, test_givenSavedViewsChangedForAnotherList_whenApplying_thenIsANoOp, test_givenTheAppliedViewSurvives_whenApplyingAnUpdate_thenKeepsTheSelection, test_givenListDeleted_whenApplying_thenClearsEverything

No SwiftUI view is rendered by any test; SavedViewsControl is verified by build and hand-check.

Coverage matrix

GET    /api/lists/{id}/views          → consumed by SavedViewsViewModel.load
POST   /api/lists/{id}/views          → consumed by SavedViewsViewModel.create
POST   /api/lists/{id}/views/{viewId} → consumed by SavedViewsViewModel.fork
PUT    /api/lists/{id}/views/{viewId} → consumed by SavedViewsViewModel.rename / .makeDefault / .setDensity
DELETE /api/lists/{id}/views/{viewId} → consumed by SavedViewsViewModel.delete

Risks & follow-ups

  • filters grammar still unknown. Re-probe once a list with a non-empty schema exists on the test account; until then the client preserves rather than interprets. Nothing here has to change when it is learned — only a reader would be added.
  • Shared-view writes by a non-owner are unverified. The read-only branch hides shared create/rename/delete on a watcher share on the assumption those 403; that assumption is untested (the recon account owns its only list). If shared writes turn out to be allowed for collaborators, the gate is too tight, not too loose.
  • Fork name collisions and position are server-assigned and untested against duplicates.
  • Live ContractTests for these routes were not added — they would need write access to a list, and this session was rate-limited. Worth adding under the existing env gate.
  • work-consolidation.md §1f still lists G40 as a gap; the doc update belongs to the doc-engineer track.

Refs #81 — deliberately not closed.

🤖 Generated with Claude Code

…faults

macOS shipped one hard-coded arrangement per list. On a shared list that
meant a collaborator could not see the owner's arrangement at all, and had
no way to keep one of their own — the five /api/lists/{id}/views routes
have existed the whole time and no prior parity sweep noticed them.

This lands the full surface: Kit builders and DTOs, domain models and the
five ListsServicing methods, and a saved-views menu plus manage sheet on
the rows pane. Shared and personal views get separate sections and glyphs,
"Duplicate" (the spec's escape hatch) sits at the top level rather than
buried in the sheet, and load() applies the caller's isDefault view so two
people open the same shared list into their own arrangements.

Everything is modelled against payloads captured live on 2026-09-15, not
against the OpenAPI document, because the document is wrong about this
resource twice over:

  * It marks ListView.createdAt/updatedAt REQUIRED and the API sends
    neither, on POST, GET or PUT. A schema-faithful decoder would have
    thrown on every row. Both are optional here.
  * Its response example shows config.filters surviving a write. That
    exact filter object was sent live and came back []. No fixture in this
    change is derived from the example.

It also declares the request body's config as {"type":"string"}, which is
a generator artifact — issue #81 was filed on that reading. config is an
object, and the server whitelists exactly four keys (mode, density,
filters, search), silently stripping columns/visibleColumns/columnOrder/
hiddenColumns/groupBy/sort/sortBy/sortDirection/rowHeight. So config does
NOT carry column order, visibility or sort, and this change does not
pretend otherwise: density is the only stored value with a visible client
effect today, and it is what applying a view changes.

The validation asymmetry is encoded deliberately. scope hard-fails (400
"scope must be \"personal\" or \"shared\""), so it is a closed enum with no
round-trip escape hatch — preserving an unknown token would guarantee a
400 on the next write. mode and density default silently, so both are
unknown-tolerant: a value this build does not recognise is a real state,
and collapsing it would overwrite the user's stored choice on save.

The filters element grammar is UNCONFIRMED and is named as such in the
code. The recon account's only list has an empty schema, so every probe
filter referenced a column that does not exist and was dropped, leaving a
grammar failure indistinguishable from a column-not-found. filters is
therefore carried as opaque JSON that round-trips untouched, and every
write sends the complete config because PUT replaces rather than merges —
a live PUT omitting density reset it from compact to comfortable.

Also moves ListJSONValue.init(from: ListCellValue) out of ListsService
into ListMappers beside its inverse: G40 is the second writer of loose
JSON, and a drifted private copy of a lossless round-trip would silently
corrupt whatever it disagreed about.

Refs #81

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron

Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Merge order — verified by actually integrating the stack

I built a throwaway branch off dev and merged the whole stack in order, so this is measured rather than predicted.

#99  fix/xcode27-document-ambiguity   clean
#87  fix/lists-detail-envelope         clean
#101 feat/lists-authoring-g50          clean   (stacked on #87)
#105 feat/saved-list-views-g40         CONFLICTS — 3 files
#106 refactor/apierror-response-body   clean

Result after resolving: ** BUILD SUCCEEDED **, App Executed 1035 tests, with 0 failures, Domain Executed 1039 tests, with 0 failures. The stack is sound; only the one merge needs hands on it.

The three conflicts, and what each actually is

1. ListMappers.swift — a true duplicate. #87 and #105 independently moved ListJSONValue.init(from: ListCellValue) out of ListsService.swift, for the same reason, with near-identical code.

Keep one copy, delete the other. Do not merge the hunks — concatenating them produces one function with two case .array bodies and it will not compile. Keep #105's doc comment (it names the second writer, which is the better explanation now one exists) and #87's body (explicit closures, because ListJSONValue.init(from:) is ambiguous against Decodable.init(from:)#87 hit that and fixed it).

2. ListsService.swift — genuinely additive. Two new ListsError cases, one from each branch (schemaChangeWouldLoseData, invalidViewName), plus their description arms. → Keep both, in both places.

3. ListRowsView.swift⚠️ a real semantic overlap, not additive. Both branches changed the same TableColumnForEach line:

They compose. Take #101's ListColumn access and #105's cellLineLimit:

TableColumnForEach(columns) { column in
    TableColumn(column.label) { (row: ListRow) in
        Text(row.fields[column.key]?.displayText ?? "")
            .lineLimit(cellLineLimit)
    }
}

Taking either side wholesale loses something real — #105's side reintroduces the key/label bug, #101's side drops the density control.

After the stack lands

#106 left its two consumers unwired to avoid a three-way stack. Once #87, #102 and #106 are on dev, each is one line:

  • ListsService.updateSchemasendCapturingFailure + details(as: ListSchemaConflictDTO.self), so the destructive-change confirmation names the columns
  • AppSettingsService → the same, for the 409's current document

ListSchemaConflictDTO already exists for the first.

Three conflicts, and only one of them was mechanical.

ListMappers.swift was a true duplicate: this branch and #87 independently moved
`ListJSONValue.init(from: ListCellValue)` out of `ListsService.swift`, for the
same reason, within days of each other. One copy survives — concatenating them
produces a function with two `case .array` bodies that does not compile. The
comment keeps this branch's framing, which names the second writer that prompted
the move, and dev's body, which uses explicit closures because
`ListJSONValue.init(from:)` is ambiguous against `Decodable.init(from:)`.

ListsService.swift was additive on both sides — one new `ListsError` case from
each branch, plus their `description` arms. Both kept.

ListRowsView.swift was neither. Both branches changed the same
`TableColumnForEach` line: dev moved `columns` from `[String]` to `[ListColumn]`
so the header reads `label` and the cell subscript uses `key`, and this branch
added a density-driven `cellLineLimit`. They compose, and taking either side
alone loses something real — dropping `ListColumn` reintroduces the empty-cell
bug for any column whose key differs from its label, and dropping `cellLineLimit`
silently ignores the saved view's density.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron
Adron merged commit 9880426 into dev Sep 17, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant