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” Section with 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 :ScanEvent history, 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_id guard (e.g. scoping by an IDENTIFIES timestamp) — 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:

  1. it was scanned while attached to this objectevent.object_id == this object id (all normal post-attachment scans; also makes it reassignment-safe, since another object’s scans carry a different object_id); or
  2. 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’s created_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 three Label rows.

Interactions

  1. 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.
  2. “Visa alla” → pushes ScanHistoryScreen: the same rows, scrollable, all events (up to the query limit).
  3. Tap place (on the line or a history row) → openInAppleMaps(latitude, longitude) via an https://maps.apple.com/?ll=<lat>,<lng> URL (or MKMapItem.openInMaps). Only when coords exist.
  4. Tap who → push a minimal PersonStubScreen (“Profil – kommer snart”); no data.
  5. 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’s created_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 $limit
    

    with $creator_user = "user:" + object.created_by and $window_seconds = CREATION_SCAN_WINDOW (default 600). DISTINCT guards against an object carrying multiple tags that share an event.

  • scannedBy = raw uuid (strip the user: prefix from scanning_user) so iOS can do the “self” check; scannedByName resolved from profiles.full_name (one batched Postgres query over the distinct user ids). at is the ISO timestamp string.

No createObject change 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 denormalized lastSeenInfo/last_scanned_at set at creation.)

iOS components

  • PlingsKit: ScanHistoryOperation (scanHistory(objectId, limit)) + a ScanEventInfo decodable model (at, latitude, longitude, scannedBy, scannedByName). The card line keeps reading the existing lastSeenInfo (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 ScanEventInfo rows + “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 → PersonStubScreen placeholder.
  • Add to the repo ROADMAPs (backlog): in-app interactive map (replaces the Apple Maps hand-off, e.g. a MapKit screen with the pin + nearby objects), and person/profile management (who-tap → real profile; scannedByName already 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.
  • scanHistory Neo4j failure → empty list (history shows “inga skanningar ännu”); the card line still renders from lastSeenInfo (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 → scanHistory returns 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_id match) newest-first, respects limit; (b) includes a creation-trigger scan (same created_by, inside the window, object_id null); (c) excludes a pre-creation scan by a different user and one outside the window (the registration/test/6-months cases); (d) strips the user: prefix for scannedBy and joins profiles for scannedByName; (e) returns empty for an unauthorized viewer (mock Neo4j + Postgres). No createObject change to test.
  • iOS: PlingsKit decode test for ScanEventInfo / ScanHistoryOperation; app build; LastSeenLine truncation + chip-omission verified in preview/build. Device test.
  • quality-control-enforcer on the API (new resolver) and the iOS diff.

Cross-repo merge order

  1. Plings-API first (scanHistory query only — no schema/DB migration, no backfill).
  2. Plings-iOS second (compact line, history, Apple Maps, stubs).
  3. Plings-Web: docs only (this spec + API doc), on dev.