Compact “Senast sedd” + scan history — Design
Compact “Senast sedd” + scan history — Design
Created: Wed 17 Jun 2026 10:25:08 CEST Updated: Wed 17 Jun 2026 10:25:08 CEST - Initial design Document Version: 1.0 - Initial design Security Classification: Internal Technical Documentation Target Audience: iOS Developer, Backend Developer Author: Paul Wisén
Goal
Make the iOS object view’s “Senast sedd” smaller and more subtle, but interactive, and
surface the event history we already log. The current full-width Section with three
large rows becomes a single compact line; its parts are tappable:
- time → expands inline to show the last 3 events, with “Visa alla” → a full history screen
- place → opens Apple Maps at the coordinate
- who → a person-profile stub (no person system yet)
Scope
In scope
- iOS: replace the “Senast sedd”
Sectionwith a compact inline line + inline history expansion (last 3) + a “Visa alla” full-history screen; place→Apple Maps; who→stub. - API: a new
scanHistory(objectId, limit)query over the:ScanEventhistory, using the guarded tag-walk below.
Out of scope (not needed / stubbed; added to ROADMAP)
- A synthetic create-event and a Neo4j backfill — both dropped. The guarded tag-walk surfaces the real creation-trigger scan via the tag, so we don’t synthesize events.
- A real in-app map (place→Apple Maps is the interim).
- Person/profile management (who→placeholder screen).
- Reassignment-aware refinement beyond the
object_idguard (e.g. scoping by anIDENTIFIEStimestamp) — later. - Pagination of full history beyond a simple
limit(a large default is enough now).
Data model — events belong to the tag; object history is a guarded tag-walk
A :ScanEvent records both (:ScanEvent)-[:SCANNED]->(:PlingsIdentifier) (the event is
attached to the tag — the conceptually-correct owner of a scan) and a denormalized
object_id captured at scan time. :ScanEvent properties (from native scan logging):
id, timestamp, identifier_type, ip_hash, object_id, latitude, longitude,
scanning_user ("user:<uuid>"), owner_at_time_of_scan ("org:<uuid>").
An object’s history walks object ← IDENTIFIES ← tag ← SCANNED ← event, then keeps an event
only when either:
- it was scanned while attached to this object —
event.object_id == this object id(all normal post-attachment scans; also makes it reassignment-safe, since another object’s scans carry a differentobject_id); or - it is the creation-trigger scan — scanned before the object existed, by the same
person who created the object (
event.scanning_user == "user:" + object.created_by), and within a short window of the object’screated_at(created_at - event.timestamp <= CREATION_SCAN_WINDOW, default 10 minutes, a named constant).
Guard (2) is why a tag scanned for unrelated reasons does not falsely date the object: registering a printed tag series, a loose-tag test, or a scan months before attachment are excluded because they fail the same-creator and/or time-window check. No synthetic create-event is written; the real scan that led to creation is what shows up.
On-card element (replaces the current Section)
A single subtle line in ObjectScreen, muted/secondary styling, clearly smaller than the
current Section. Three inline tap zones (chips), shown only when their data exists:
🕐 26 min sedan · 📍 Saltsjön · 👤 du
- The place chip shows a short form (locality only, e.g. “Saltsjön”) so the line never overflows at ~380 pt; the full place (“Saltsjön, Skog”) shows in the history rows.
- Chips are omitted gracefully: no coords → no place chip; no
scannedByName/not-self → who shows the name or is omitted (never a dangling separator). - This replaces the existing
Section("Senast sedd")block and its threeLabelrows.
Interactions
- Tap time → toggles an inline expansion directly under the line (no sheet). The expansion lists the last 3 events, each: relative-or-absolute time · place (reverse-geocoded on-device) · who. A “Visa alla” row sits at the bottom.
- “Visa alla” → pushes
ScanHistoryScreen: the same rows, scrollable, all events (up to the querylimit). - Tap place (on the line or a history row) →
openInAppleMaps(latitude, longitude)via anhttps://maps.apple.com/?ll=<lat>,<lng>URL (orMKMapItem.openInMaps). Only when coords exist. - Tap who → push a minimal
PersonStubScreen(“Profil – kommer snart”); no data. - Reverse-geocoding remains on-device (
CLGeocoder), per event, lazily — unchanged pattern.
Backend
New query scanHistory
type ScanEventInfo {
at: String
latitude: Float
longitude: Float
scannedBy: ID
scannedByName: String
}
extend type Query {
scanHistory(objectId: ID!, limit: Int = 20): [ScanEventInfo!]!
}
- Resolver (
app/scan_history_resolvers.py): ownership-checked first (reuse the existing object-permission check — only the owner/org may see who scanned their object). An unauthorized or unauthenticated viewer gets an empty list (no who-leak, no error). It needs the object’screated_at+created_by(one Postgres read) to apply the guard, then the guarded tag-walk in Neo4j:MATCH (obj:ObjectInstance {id: $oid})<-[:IDENTIFIES]-(:PlingsIdentifier)<-[:SCANNED]-(e:ScanEvent) WITH e, datetime($created_at) AS created WHERE e.object_id = $oid OR (e.scanning_user = $creator_user AND e.timestamp <= created AND duration.inSeconds(e.timestamp, created).seconds <= $window_seconds) RETURN DISTINCT e.id AS id, e.timestamp AS at, e.latitude AS latitude, e.longitude AS longitude, e.scanning_user AS scanning_user ORDER BY at DESC LIMIT $limitwith
$creator_user = "user:" + object.created_byand$window_seconds = CREATION_SCAN_WINDOW(default 600).DISTINCTguards against an object carrying multiple tags that share an event. scannedBy= raw uuid (strip theuser:prefix fromscanning_user) so iOS can do the “self” check;scannedByNameresolved fromprofiles.full_name(one batched Postgres query over the distinct user ids).atis the ISO timestamp string.
No
createObjectchange and no backfill. The guarded tag-walk surfaces the real creation-trigger scan through the tag, so neither a synthetic create-event nor a one-off Neo4j backfill is needed. (A manually-created object with no tag, or one never scanned, has an empty scan history — honest: it has not been scanned. Its card line still shows “Senast sedd” from the denormalizedlastSeenInfo/last_scanned_atset at creation.)
iOS components
- PlingsKit:
ScanHistoryOperation(scanHistory(objectId, limit)) + aScanEventInfodecodable model (at,latitude,longitude,scannedBy,scannedByName). The card line keeps reading the existinglastSeenInfo(denormalized latest); history uses the new query. - App:
LastSeenLine— the compact inline line (replaces the Section); renders the 3 chips, truncates place, owns the inline-expansion state.- Inline expansion renders up to 3
ScanEventInforows + “Visa alla”. ScanHistoryScreen— pushed “Visa alla” list of all events (reuses the row view).openInAppleMaps(lat:lng:)helper.PersonStubScreen— placeholder pushed on who-tap.- On-device reverse-geocode for the line + each history row (cache per coordinate to avoid re-geocoding the same point).
Stubs + ROADMAP additions
- place → Apple Maps is real now (not a stub).
- who →
PersonStubScreenplaceholder. - Add to the repo ROADMAPs (backlog): in-app interactive map (replaces the Apple Maps
hand-off, e.g. a
MapKitscreen with the pin + nearby objects), and person/profile management (who-tap → real profile;scannedByNamealready wired).
Error handling / edge cases
- No coords on an event → no place chip / “okänd plats” in history; place-tap disabled.
- Geocode fails/offline → show coords-less row (time + who); never blocks.
scanHistoryNeo4j failure → empty list (history shows “inga skanningar ännu”); the card line still renders fromlastSeenInfo(denormalized), so a backend hiccup never blanks the card.- Object never scanned (e.g. manually created, no tag, or tag scanned outside the guard) → empty scan history but a populated card line; the inline expansion shows “inga skanningar ännu”. This is honest — the card “Senast sedd” reflects last seen (incl. creation), the history reflects qualifying scans.
- Unrelated tag scans (registration, loose-tag test, scan long before attachment) → excluded by guard (2) (different scanner and/or outside the window).
- Unauthorized viewer →
scanHistoryreturns empty (no who-leak); card line already gates via the object-detail permission check.
Testing
- API (TDD, pytest):
scanHistory(a) returns post-attachment events (object_idmatch) newest-first, respectslimit; (b) includes a creation-trigger scan (samecreated_by, inside the window,object_idnull); (c) excludes a pre-creation scan by a different user and one outside the window (the registration/test/6-months cases); (d) strips theuser:prefix forscannedByand joinsprofilesforscannedByName; (e) returns empty for an unauthorized viewer (mock Neo4j + Postgres). NocreateObjectchange to test. - iOS: PlingsKit decode test for
ScanEventInfo/ScanHistoryOperation; app build;LastSeenLinetruncation + chip-omission verified in preview/build. Device test. - quality-control-enforcer on the API (new resolver) and the iOS diff.
Cross-repo merge order
- Plings-API first (
scanHistoryquery only — no schema/DB migration, no backfill). - Plings-iOS second (compact line, history, Apple Maps, stubs).
- Plings-Web: docs only (this spec + API doc), on
dev.