feat(lists): saved list views — shared/personal, forking, per-user defaults (G40, #81) - #105
Conversation
…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>
Merge order — verified by actually integrating the stackI built a throwaway branch off Result after resolving: The three conflicts, and what each actually is1. → Keep one copy, delete the other. Do not merge the hunks — concatenating them produces one function with two 2. 3.
→ They compose. Take #101's 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
|
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>
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 + fiveListsServicingmethods, and a saved-views menu plus manage sheet on the rows pane.Confirmed API facts this is built against
Captured live against the test account on 2026-09-15; the
GETenvelope was re-read on 2026-09-16 (200 {"views":[]}). All five ops arex-auth-type: sync-tokenandx-subscription-tier: free— Bearer-reachable on a free account, no subscription gate, and none was added.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
ListViewrow is exactlyid, listId, userId, name, scope, config, isDefault, position.Trap 1 —
createdAt/updatedAtare REQUIRED in the schema and absent from every live response. POST, GET and PUT all omitted both. ADecodablemirroring the schema throwskeyNotFoundon every row. Both are modelled optional, with the reason recorded onListViewDTO. Guarded bytest_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_thenRoundTripsWhateverTheServerStoredrecords 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.configis an object; sending one returns 201.filtersgrammar is UNCONFIRMED — and named as such in the codeThe 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.
filtersis 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.configis a four-key whitelist — issue #81's presumption is wrongA live probe sent
columns,visibleColumns,columnOrder,hiddenColumns,groupBy,sort,sortBy,sortDirection,rowHeightand a deliberatebogusKey. All ten were stripped silently, no 400. Soconfigdoes not encode column order / visibility / sort, and this PR does not pretend it does — modelling those would be modelling storage that does not exist.{"mode":"records","density":"comfortable","filters":[]}mode: onlyrecordsever stuck (table/gallery/kanban/board/grid/list/cardsall silently fell back, HTTP 200)density:comfortable+compactaccepted;spacious/cozy/dense/comfysilently fell backPUTconfig is a whole-object REPLACE, not a merge — omittingdensityreset it tocomfortabledensityis 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 sofilters/searchthe web may have set are never clobbered.Validation asymmetry, encoded on purpose
scopehard-fails —POST {"scope":"bogus_scope"}→400 {"error":"scope must be \"personal\" or \"shared\"","code":"bad_request"}. SoSavedListViewScopeis a closedpersonal | sharedenum with no round-trip escape hatch: preserving an unknown token would guarantee a 400 on the next write, the opposite of whatViewingPreference.otherbuys. An unreadable token collapses to.personal(mislabelling a private view as shared is the more harmful mistake).modeanddensitydefault 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'sisDefaultview, 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 apendingOperationsdebounce 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 ofListsService.swift(where it wasfileprivate) intoListMappers.swiftbeside 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.StubAPIClientgainedsentBodies+ anXCTestCase.lastSentJSON(_:)helper so domain tests can assert what exactly was sent — load-bearing here becauseconfigmust go out as an object andPUTreplaces whole.Gate — real output
Live
ContractTestswere 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_thenSurfacesNotFoundDomain —
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_thenNoSubscriberGateAppliesApp —
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_thenClearsEverythingNo SwiftUI view is rendered by any test;
SavedViewsControlis verified by build and hand-check.Coverage matrix
Risks & follow-ups
filtersgrammar 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.watchershare 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.positionare server-assigned and untested against duplicates.ContractTestsfor 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