feat(presence): derivation foundation for online status (spec 006)#66
Merged
Conversation
PR 1 of the spec-006 (online presence) series — pure logic + types, no wiring yet. Establishes the base the write-path (touchLastSeen) and the /api/profiles isOnline join build on. Pieces (tasks T-610..T-612): - api/src/lib/presence.ts — derivePresence(user, now): the coarse "online" boolean. online ⟺ presenceVisible !== false AND lastSeenAt parses AND (now - lastSeenAt) <= 5min (INCLUSIVE boundary). Fails safe to offline for missing/garbage timestamps and the explicit opt-out. `now` is injectable for deterministic tests. PRESENCE_WINDOW_MS (5min) exported + pinned. - api/src/lib/users.ts — UserRecord gains `lastSeenAt?: string` and `presenceVisible?: boolean` (both optional → backwards-compatible; missing lastSeenAt reads offline, missing presenceVisible reads visible). Doc comments stress the raw timestamp NEVER leaves the server (SC-604) — only the derived boolean does. Design notes carried from the spec: - Coarse + server-derived: "interacted within 5 min", not "tab open now". No client heartbeat (privacy + cost). - The opt-out (presenceVisible:false) wins over any recency — even at the inclusive boundary. Pinned by test. Tests (T-610, 13 cases): 1m→online, 5m boundary→online, 5m+1ms→offline, 6m→offline, missing→offline, garbage/empty string→offline, opt-out overrides fresh, presenceVisible true/undefined→online when fresh, opt-out wins at boundary, future-timestamp (clock skew)→online, default-now path, window constant pinned. Out of scope for this PR (queued): touchLastSeen write-coalescing + with-presence handler wrapper (PR 2), /api/profiles isOnline join + response-privacy strip (PR 3), presence-visibility endpoint + UI indicator (later). All 341 API tests pass (+13); lint, API typecheck/build, API audit clean. https://claude.ai/code/session_018WA3SvALW1EmxJKVmRUGrR
There was a problem hiding this comment.
Pull request overview
This PR adds the backend foundation for spec 006 online presence by introducing server-side presence derivation logic and extending user records with dormant presence fields.
Changes:
- Adds
derivePresencewith a pinned inclusive 5-minute window and opt-out handling. - Adds unit coverage for boundary, invalid timestamp, opt-out, and default-clock behavior.
- Extends
UserRecordwith optionallastSeenAtandpresenceVisiblefields.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
api/src/lib/users.ts |
Adds optional presence-related fields to persisted user records. |
api/src/lib/presence.ts |
Introduces coarse online-status derivation logic and exported window constant. |
api/src/lib/presence.test.ts |
Covers the presence truth table and boundary conditions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR delivers
PR 1 of the spec 006 (online presence) series — pure logic + types, no wiring yet. The base that the write-path (
touchLastSeencoalescing) and the/api/profilesisOnlinejoin build on. Splitting 006-A this way keeps each review small and the privacy-critical derivation isolated.api/src/lib/presence.ts—derivePresence(user, now): the coarse online boolean.online ⟺ presenceVisible !== false AND lastSeenAt parses AND (now − lastSeenAt) ≤ 5min(inclusive boundary). Fails safe to offline for missing/garbage timestamps and the explicit opt-out.nowinjectable for deterministic tests.PRESENCE_WINDOW_MS(5min) exported + pinned.api/src/lib/users.ts—UserRecordgainslastSeenAt?: string+presenceVisible?: boolean(both optional → backwards-compatible: missinglastSeenAtreads offline, missingpresenceVisiblereads visible). Doc comments stress the raw timestamp never leaves the server (SC-604) — only the derived boolean does.Verified by
api/src/lib/presence.test.ts::1 minute ago → onlineapi/src/lib/presence.test.ts::exactly 5 minutes ago → online (inclusive boundary)— the boundary the spec calls out explicitlyapi/src/lib/presence.test.ts::one millisecond past 5 minutes → offlineapi/src/lib/presence.test.ts::6 minutes ago → offlineapi/src/lib/presence.test.ts::missing lastSeenAt → offline+garbage / unparseable → offline (defensive)api/src/lib/presence.test.ts::presenceVisible:false → offline even when freshly seen+opt-out wins even at the inclusive boundaryapi/src/lib/presence.test.ts::presenceVisible true/undefined + fresh → onlineapi/src/lib/presence.test.ts::a future lastSeenAt reads online (negative delta ≤ window)— clock-skew defensivenessapi/src/lib/presence.test.ts::exposes a 5-minute window constant— pins the thresholdConstitution compliance
lastSeenAtis explicitly never returned (enforced when the join lands in PR 3, asserted there)presenceVisible:false) overrides recency unconditionallyOut of scope (queued)
touchLastSeenwrite-coalescing (≤1 write/min) +withPresenceTrackinghandler wrapper + wire into authenticated handlers/api/profilesisOnlinejoin + response-privacy strip (userId + lastSeenAt never leave)POST /api/presence/visibilityopt-out endpoint +OnlineIndicatorUI on/find+ profile + edit-page toggle (+ Playwright DOM smoke for the indicator)Test plan
https://claude.ai/code/session_018WA3SvALW1EmxJKVmRUGrR
Generated by Claude Code