Skip to content

feat(scheduled): show a queued post's destinations, edit its time - #71

Merged
Adron merged 2 commits into
devfrom
feat/scheduled-edit-content
Sep 13, 2026
Merged

Adron merged 2 commits into
devfrom
feat/scheduled-edit-content

Conversation

@Adron

@Adron Adron commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

Closes the buildable half of #55 and reports the rest as an upstream gap.

#55 asked for a full scheduled-post editor — time, content and cross-post destinations — to match what /help/messages describes. Read-only recon (GET/OPTIONS only, zero writes) found the content and destination halves have no route to call, and that the web does not ship that editor either. So this builds everything the API can actually support, models a live field the client was silently ignoring, and is explicit about the rest rather than offering controls that could never save.

The probe (2026-09-09, read-only)

Probe Result
OPTIONS /api/messages/{id} Allow: DELETE, GET, HEAD, OPTIONS, PATCH — PATCH is the only update verb a message has
OPTIONS /api/messages/scheduled Allow: GET, HEAD, OPTIONS (read-only)
GET/OPTIONS /api/messages/scheduled/{id} 404
OPTIONS /api/messages/{id}/schedule 404

That PATCH honours scheduledAt alone (2026-09-06 §1c · V1 write-probe): a content body returns 400 "No valid updates provided", and content sent alongside scheduledAt is accepted but silently discarded.

And the decisive one: the deployed web client issues no PATCH /api/messages/{id} anywhere in its bundles — its only PATCHes are /api/user/update and /api/notifications/*. The scheduled-post content/destination editor the help page describes is not shipped on the web either. This is an upstream API gap, not a macOS one.

Changes

  • Kit — model scheduledCrossPostConfig, a live message field the client never decoded (null once published). Shape taken from the web's own destination-badge component: { mastodonProviderIds, crossPostToBluesky, crossPostToLinkedIn }. crossPostToTwitter is decoded speculatively and documented as accepted-on-create / unconfirmed-on-read.
  • Domain — add ScheduledDestinations + Message.scheduledDestinations (fetch-time projection, not persisted — same trade-off as linkPreviews, so no SwiftData migration). Replace the narrow reschedule with a single updateScheduled(messageId:edit:) taking a ScheduledPostEdit. Every guard is client-side and runs before the request is built: an empty edit, a past date, or a content/destination change each throw a typed MessagesError naming exactly what was refused — so a doomed call is never sent and an unsupported edit can never look like it saved.
  • App — scheduled rows now show their destinations without opening anything (ScheduledDestinationsLabel distinguishes "not loaded" / "InterlinedList only" / a named list). RescheduleSheet becomes EditScheduledPostSheet: time editable, content and destinations shown read-only with a line explaining why. Composer scheduleSection gains a destinations summary and a "Post now instead" link, mirroring the web's combined schedule dialog additively — the per-network toggles stay where they are, so the composer is not restructured (deliberate: bug(compose): nothing gates on email verification, so posting fails at publish time (G38) #41 is in flight on that surface).
  • Fix found on the way — the optimistic reschedule copy was hand-built and dropped crossPostResults, crossPostLocations and linkPreviews, so rescheduling visibly wiped a row's previews and cross-post pills until the next refresh. Now routes through the exhaustive replacing(...) helper.
  • Contract testtest_givenLiveCredentials_whenOptioningScheduledRoutes_thenNoEditorRouteHasAppeared fails on purpose the day a scheduled-post editor route appears. That is the signal to reopen feat(scheduled): edit a queued post content and destinations, not just its time #55, so the constraint cannot quietly outlive its reason.

The second defect #55 asked about — ruled out

"Verify whether cross-post selections made in the composer are actually carried onto a scheduled post — if they are dropped, that is a second defect hiding inside this one."

They are carried. ComposerViewModel.submitNewPost sends the cross-post flags in the same createPost body as scheduledAt. Now pinned by test_givenScheduledPost_whenSubmitting_thenCrossPostFlagsTravelWithTheSchedule so a future composer refactor cannot quietly break it.

Tests

BDD quartets across four surfaces:

  • Domain service — content-edit refusal / both-fields-named / past-date / empty-edit, each asserting no API call was made
  • Domain mapper — all networks / omitted keys / config-selecting-nothing / null config (nil ≠ .none)
  • Kit decode — full config / partial / null / key-absent
  • App scheduled VM — past date rejects without touching the list / destinations survive the optimistic copy / time-only payload / error-clear
  • App composer — display order / Mastodon-toggled-without-ids / none / flags-travel-with-schedule

Verification

Run on the merged tree (origin/dev merged in — sibling PR #37 landed mid-flight; clean merge, no conflicts):

  • xcodebuild build** BUILD SUCCEEDED **
  • xcodebuild test (App) → Executed 760 tests, with 0 failures (0 unexpected)** TEST SUCCEEDED **
  • swift test InterlinedKitExecuted 403 tests, with 0 failures (0 unexpected)
  • swift test InterlinedDomainExecuted 783 tests, with 0 failures (0 unexpected)
  • swift test InterlinedPersistenceExecuted 135 tests, with 0 failures (0 unexpected)
  • grep -rE "^\s*import InterlinedKit" App/Features App/Navigation App/MenuCommandszero hits (Decision 0003)
  • ContractTests ran live (credentials exported in the shell profile) — 5/5 passed, including the new scheduled-route check. All read-only; no writes to the shared test account at any point.

Follow-ups

  1. feat(scheduled): edit a queued post content and destinations, not just its time #55 cannot be closed as written. Its acceptance criterion — "everything the web lets you change about a queued post can be changed on the Mac" — is met in the only sense available: the web can't change content or destinations either. Suggest re-scoping feat(scheduled): edit a queued post content and destinations, not just its time #55 to what shipped and filing the editor as an upstream API request.
  2. LinkedIn destination picker is still out of reach. The web's create body sends linkedInTargets and linkedInLinkAsFirstComment; CreateMessageRequest models neither. That is the Integrations/Organizations projection feat(scheduled): edit a queued post content and destinations, not just its time #55 said to sequence behind — it does not exist yet, so it was left alone rather than half-built.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D5HxPbjMhuxf1BkKUdqK9e

Adron and others added 2 commits September 9, 2026 10:07
GitHub #55 asked for a full scheduled-post editor — time, content and
cross-post destinations — to match what the web help page describes.
Read-only recon on 2026-09-09 found the content and destination halves
have no route to call:

  • OPTIONS /api/messages/[id] -> Allow: DELETE, GET, HEAD, OPTIONS, PATCH,
    so PATCH is the only update verb a message has;
  • /api/messages/scheduled/[id] and /api/messages/[id]/schedule both 404,
    and /api/messages/scheduled allows GET, HEAD, OPTIONS only;
  • that PATCH honours scheduledAt alone — a content body returns
    400 "No valid updates provided" and a content sent beside scheduledAt
    is silently discarded (2026-09-06 §1c V1 write-probe);
  • the deployed web client issues no PATCH /api/messages/[id] anywhere in
    its bundles, so it does not ship that editor either.

So this builds everything the API can actually support and is explicit
about the rest rather than offering controls that could not save.

Kit — model scheduledCrossPostConfig, a live message field the client
did not decode: { mastodonProviderIds, crossPostToBluesky,
crossPostToLinkedIn }, key names taken from the web's own badge
component. crossPostToTwitter is decoded speculatively and documented as
unconfirmed on read.

Domain — add ScheduledDestinations plus Message.scheduledDestinations
(fetch-time only, not persisted, matching the linkPreviews precedent).
Replace the narrow reschedule with a single updateScheduled(messageId:
edit:) taking a ScheduledPostEdit. It validates entirely client-side and
before any request: an empty edit, a past date, or a content/destination
change each throw a typed MessagesError naming exactly what was refused,
so a doomed call is never sent and an unsupported edit can never look
like it saved.

App — scheduled rows now show their destinations without opening
anything; the sheet becomes EditScheduledPostSheet, with the time
editable and the content and destinations shown read-only alongside a
line saying why. The composer's schedule dialog gains a destinations
summary and a "Post now instead" link, mirroring the web's combined
dialog additively rather than restructuring the composer. The optimistic
reschedule copy now routes through replacing(...), fixing a latent bug
where rescheduling dropped the row's link previews and cross-post pills.

Also answers the question the issue raised about a second defect: the
composer does NOT drop cross-post selections on a scheduled post — it
sends them in the same create body as scheduledAt. Pinned by a test.

A new live contract test fails on purpose if a scheduled-post editor
route ever appears, which is the signal to reopen #55.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Adron
Adron merged commit 6f9a99d into dev Sep 13, 2026
8 checks passed
@Adron
Adron deleted the feat/scheduled-edit-content branch September 13, 2026 18:30
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