Skip to content

feat(profile): My Profile — land on your own account, five stat tiles, and both content columns (G32) - #90

Merged
Adron merged 1 commit into
devfrom
feat/my-profile-g32
Sep 17, 2026
Merged

Adron merged 1 commit into
devfrom
feat/my-profile-g32

Conversation

@Adron

@Adron Adron commented Sep 15, 2026

Copy link
Copy Markdown
Member

Summary

Closes #44 (G32). Unblocks #45, which needed this page to exist before the sidebar could point at it.

Opening Profile put up an "enter a username" prompt even though the session already knew who the user was — so the one profile everybody wants to see was the one that took the most typing to reach. It now lands on your own account, and the lookup field stays as the way to visit someone else.

Most of this was already on the wire and being thrown away

GET /api/users/{username} returns everything four of the five tiles need, and PublicProfileDTO has decoded it since the endpoint shipped:

{"followerCount":1,"followingCount":1,"publicMessageCount":31,"publicListCount":0,
 "headerImage":null,"bio":"…","joinedAt":"…","isPrivate":false}

publicMessageCount and publicListCount were dropped at the domain boundary. So this is a carry-through, not an integration — and it matters in one non-obvious place: withCounts rebuilds the profile after the follow-counts follow-up lands, and not carrying them there would blank two tiles every time that call returned. There is a test for exactly that.

The probe the issue asked for

"The public-lists equivalent still needs probing — find the route the web profile page calls."

Found: GET /api/users/{username}/lists → {lists[], pagination}, confirmed live. It is the public browse collection — distinct from GET /api/lists/watching, which is the caller's own watched surface and a different thing, exactly as the issue suspected.

Watch is the free branch of a gated route

POST /api/lists/{id}/watchers with no userId is the self-subscribe branch, and it is free: the subscription gates granting someone else access, not following a list that is already public to you. Gating it would make the Watch button on a public profile an upsell for something the web gives away.

It is modelled as its own watch(listId:) rather than an optional parameter on addWatcher, because those are different intents that happen to share a URL — and addWatcher's empty-id guard has to keep erroring rather than quietly becoming a self-subscribe. A test pins both halves.

The profileUnavailable guard

The issue flagged this and it has teeth. Decision 0002's empty state means "this user has no public messages, so there is nothing to project a profile from" — a statement about other people's public content. Shown for your own account, it tells a brand-new user with nothing posted yet that their own profile does not exist.

Suppressed for self, still fires for everyone else, tested in both directions — suppressing it universally would hide an accurate explanation for someone else's empty profile.

Two judgement calls worth stating

A stat tile whose count is unknown is omitted, not rendered as zero. "0 posts" and "we could not find out how many posts" look identical and mean opposite things, and a profile that claims zero when a call merely failed is worse than one showing four tiles.

Ownership is compared on id, not handle. A handle comparison goes wrong exactly where it matters — on a rename, when the session's cached handle and the profile's disagree.

One request, one number

PublicUserDocumentsView gains an onCountChange callback so the Documents tile reuses the fetch the column already makes. Asking again would be a duplicate request whose answer could disagree with what is on screen.

Handles

Normalised at the single load entry point, so a deep link, a typed handle and the self-landing all agree that adron, @Adron and /user/ADRON are the same person. Legal username punctuation ([A-Za-z0-9_.-]) passes through untouched rather than being sanitised against a charset the client would get subtly wrong.

Verification

  • xcodebuild build → ** BUILD SUCCEEDED **
  • xcodebuild test (App) → Executed 984 tests, with 0 failures · ** TEST SUCCEEDED ** (was 968)
  • swift test InterlinedDomain → Executed 1020 tests, with 0 failures (was 1012)
  • swift test InterlinedPersistence → Executed 140 tests, with 0 failures
  • swift test InterlinedKit --skip ContractTests → Executed 477 tests, with 0 failures — the live ContractTests are skipped here because the account is rate-limited from this session's API recon; they are unmodified by this PR.
  • Decision 0003 (anchored) → zero hits

New tests: ProfileViewModelTests (8), PublicUserListsViewModelTests (8), ProfileContentCountsTests (4), plus 4 self-watch cases in OwnedListsServiceTests.

Acceptance

  • ✅ Opening Profile while signed in shows your own account with no typing.
  • ✅ Public lists and public documents are visible for yourself and for others.
  • ✅ Full gate green.

🤖 Generated with Claude Code

https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9

…tat tiles and both content columns

Opening Profile put up an "enter a username" prompt even though the session
already knew who the user was, so the one profile everybody wants to see was the
one that took the most typing to reach. It now lands on your own account, and
the lookup field stays as the way to visit someone else.

Most of this was already on the wire and being thrown away. `GET /api/users/
{username}` returns publicMessageCount and publicListCount, and the kit has
decoded both since the endpoint shipped — the domain model dropped them at the
boundary, so the header could only ever show two of the web's five tiles. The
carry-through matters in one non-obvious place: `withCounts` rebuilds the profile
after the follow-counts follow-up lands, and not carrying them there would blank
two tiles every time that call returned.

The public-lists column is the piece the issue recorded as still needing a probe.
`GET /api/users/{username}/lists` answers `{lists, pagination}` — the public
browse collection, not `/api/lists/watching`, which is the caller's own watched
surface and a different thing. The column mirrors PublicUserDocumentsView's
self-contained shape so the profile view adds it in one line.

Watch uses the route's self-subscribe branch — POST watchers with no userId —
which is free, because the subscription gates granting someone else access, not
following a list that is already public to you. It is modelled as its own
`watch(listId:)` rather than an optional parameter on `addWatcher`: those are
different intents that happen to share a URL, and addWatcher's empty-id guard has
to keep erroring rather than quietly becoming a self-subscribe.

Two judgement calls worth stating. A stat tile whose count is unknown is omitted
rather than rendered as zero — "0 posts" and "we could not find out how many
posts" look identical and mean opposite things. And ownership is compared on id,
not handle, because a handle comparison goes wrong exactly where it matters: on
a rename, when the session's cached handle and the profile's disagree.

The `profileUnavailable` empty state is now suppressed for your own account.
It means "this user has no public messages, so there is nothing to project a
profile from" — a statement about other people's public content. Shown for
yourself it tells a brand-new user their own profile does not exist. Tested in
both directions, because suppressing it universally would hide an accurate
explanation for someone else's empty profile.

Handles are normalised at the single load entry point, so a deep link, a typed
handle and the self-landing all agree that "adron", "@Adron" and "/user/ADRON"
are the same person.

Refs #44

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9
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