Likes, profiles, and one small UX correction
We added likes and a profile view to the community in one stretch. About the small decisions along the way — and the one I had to walk back.
Likes, profiles, and one small UX correction
Two things landed in the community: likes and viewing other users’ profiles. Both touch lists and detail views, so they aren’t single-spot features — they sit scattered across the codebase, in every place a user appears.
Likes: factories happen on the second instance
Likes had to attach to both overview and freeboard. Two document types behaving almost identically. I could’ve copy-pasted two routes, but I’d already lived through the same situation once with comments — and a factory came out of that.
Comments use register_comment_routes(router, document_type). Define once, register on both routers. Likes have the same shape, so I went the same way:
# routes/factory/like/factory.py
def register_like_routes(router, document_type):
@router.put("/{document_id}/like/set", status_code=204)
async def set_like(...): ...
@router.put("/{document_id}/like/unset", status_code=204)
async def unset_like(...): ...
Then one line in each router:
register_like_routes(router, DocumentType.OVERVIEW)
register_like_routes(router, DocumentType.FREEBOARD)
Factories are made on the second instance. The first time, just write it (you don’t yet know the abstraction’s shape). When the second one arrives and you see the pattern, extract. Comments was the first; likes is the second — exactly the right moment.
Where does is_liked belong
I briefly thought about a “things I liked” view — like Instagram saves. But the user shut it down: “likes are just likes, no collected view.” So just count + toggle.
A different question remained, though: where does is_liked (does this user like this post) attach?
I considered putting it only in the smallest set of places. The user’s instinct was firmer: “shouldn’t is_liked attach to everything?” Right — if the heart shows on the list and again on the detail screen, every response needs to know consistently.
So I unified through enrichment:
# routes/achiever/overview/enrich.py
async def enrich_overview_response(response, achiever_id):
achiever_response, _ = await asyncio.gather(
fetch_achiever_response(response.created_by, ...),
apply_is_liked_to_response(response, achiever_id, DocumentType.OVERVIEW),
)
response.achiever_response = achiever_response
return response
Routes used to do fetch_achiever_response + manual assignment ad hoc, scattered across endpoints. Adding is_liked gave me a reason to consolidate. The same 4-5 line pattern that was repeated 6 times across the file is gone. (Cleanup-by-side-effect.)
asyncio.gather runs the achiever fetch and the likes lookup in parallel — slightly faster than serial, and reads more directly.
The prototype trap in optimistic toggle
Client-side, the like toggle is optimistic: tap → UI flips immediately, API in the background, rollback on error. Fast and natural.
Building “a copy of the model with isLiked toggled” hit the class-instance copy issue:
// Doesn't work — turns into a plain object, prototype methods gone
const next = {...overview, isLiked: !overview.isLiked};
The fix:
function withToggledLike<T>(model: T, nextIsLiked: boolean): T {
const next = Object.assign(Object.create(Object.getPrototypeOf(model)), model);
next.isLiked = nextIsLiked;
next.likesCount = Math.max(0, model.likesCount + (nextIsLiked ? 1 : -1));
return next;
}
Object.create(Object.getPrototypeOf(model)) produces a new object with the same prototype; Object.assign copies the fields. OverviewModel/FreeboardModel are simple data classes here so methods don’t matter today — but if some instanceof check elsewhere depends on the prototype, breaking it is a debugging nightmare. So preserve it on principle.
Profile: projection isn’t “don’t trust”, it’s “send only what should be visible”
Next, profile. Tap a nickname → see profile. Display: nickname + introduction, that’s it.
The cheapest server option was extending GET /api/achiever/get to take an achiever_id param. But that endpoint is for self and returns a full payload (email, phone, birthdate, streaks, etc.). Exposing all that to other users would be bad.
So a new endpoint with explicit projection:
@router.get("/{achiever_id}/profile/get")
async def get_achiever_profile(...) -> AchieverProfileResponseModel:
achiever_entity = await achiever_collection.find_one(
{'_id': ObjectId(achiever_id)},
{'_id': 1, 'nick_name': 1, 'introduction': 1}, # only these
)
...
The response model is separate too: AchieverProfileResponseModel. Three fields. If someone in the future absent-mindedly adds e_mail, they have to explicitly add it to the response model definition — the intent shows up in the diff.
Public surface is defined by what you did not include. Not “is it safe to omit this?” but “is it safe to send only this?” The latter is a whitelist; the former is a blacklist. Whitelists are always safer.
Mobile and desktop took different paths
Same profile view, but the two platforms got different treatments.
Mobile: inform-achiever, a full page. Tap nickname → push to a new route.
Desktop: AchieverProfileModal, a global modal. Mounted once at the top of App.tsx; setting viewedAchieverIdAtom from anywhere triggers it.
// App.tsx
<AchieverProfileModal /> // always mounted, triggered by atom
// anywhere
const setViewedAchieverId = useSetAtom(viewedAchieverIdAtom);
<button onClick={() => setViewedAchieverId(authorId)}>{nickName}</button>
Desktop went modal because it already runs a SplitLayout (list + detail). Tucking another detail into the detail panel would be confusing. A small info popover is more natural as a modal.
Mobile could’ve been a modal too — and my first decision was a fullscreen modal. That’s the one I had to walk back.
A fullscreen modal, really?
I shipped it, the user immediately:
“hm, it’s coming up as a fullscreen modal? I’d rather it just navigate.”
They’re right. A profile is subordinate information (post → author), not a separate flow (e.g., starting a new post). Fullscreen modals fit separate flows; plain push fits subordinate navigation.
Removed presentation: "fullScreenModal", swapped router.dismiss() → router.back(). A 30-second change, but the meaning is bigger — the slide direction shifts from bottom-up (modal) to right-to-left (push), and the user reads “this isn’t a separate task, it’s a step deeper in the same flow.”
Modal vs. push isn’t visual, it’s semantic. Fullscreen modal says “I’m leaving to do something else; I’ll come back.” Push says “I’m going one level deeper; I can step back.” Pick wrong and the mental model wobbles.
Wrap
What this work left in the codebase:
- Like factory (set/unset PUT, 204) — one line registers likes for any new document type
- Enrichment pattern —
enrich_*_response()bundles achiever_response + is_liked, used everywhere withToggledLikehelper — optimistic copy that preserves prototypeAchieverProfileResponseModel— whitelist projection for a safe public surface- Mobile push, desktop modal — same data, different presentation
Two small features, but a fair amount of pattern cleanup followed. Adding is_liked enrichment was the excuse to consolidate the scattered achiever-fetch calls. The factory pattern stabilized on its second instance. And projection forced me to re-examine how I think about public surfaces — whitelist over blacklist, always.
The walk-back on the fullscreen modal at the end was the smallest thing in this whole stretch, but probably the most useful reminder. When user feedback arrives in 30 seconds, applying it in the next 30 seconds keeps the flow intact. Small corrections like that are what build the feel of a product.