Compact “Senast sedd” + scan history — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Shrink the iOS “Senast sedd” to a subtle inline line whose parts are tappable (time → inline scan history + “Visa alla” full screen; place → Apple Maps; who → person stub), backed by a new ownership-gated scanHistory GraphQL query.

Architecture: Object scan history is a guarded tag-walk over the existing :ScanEvent nodes — object ← IDENTIFIES ← tag ← SCANNED ← event, keeping an event only when event.object_id == this object (post-attachment / reassignment-safe) or it is the creation-trigger scan (same created_by, within 10 min of created_at). No schema/DB migration, no synthetic create-event, no backfill. iOS replaces the Section with a LastSeenView (compact line + inline expansion) and a ScanHistoryScreen.

Tech Stack: Plings-API (Python 3.10, Ariadne GraphQL, Neo4j async driver, SQLAlchemy async/Postgres, pytest + unittest.mock); Plings-iOS (SwiftUI app + PlingsKit Swift package, CoreLocation/MapKit, XCTest).

Spec: Plings-Web/docs/specs/2026-06-17-senast-sedd-compact-and-history-design.md


Preconditions

  • Base each worktree off origin/<prodbranch> after git fetch (NEVER the local checkout — both Plings-API and Plings-iOS local checkouts carry concurrent-session WIP). Confirm a clean checkout (ls tests/ for API, Operations.swift present for iOS) before editing.
  • No DB migration and no DB write in this planscanHistory is read-only; createObject is untouched.
  • Merge order: Plings-API first (query is additive/non-breaking), then Plings-iOS, then Plings-Web docs on dev.
  • origin/main on Plings-API has a committed venv/ (pre-existing, noted in its ROADMAP backlog) that can break git worktree checkout. If worktree add fails part-way on venv, base off the latest clean tag/commit and rebase before PR (it’s content-identical for our files), or retry — do NOT hand-edit the broken worktree.

File structure

Plings-API (worktree off origin/main)

  • Create: app/scan_history_resolvers.pyscanHistory query (guarded tag-walk + permission + name join).
  • Modify: app/graphql.py — add type ScanEventInfo, the scanHistory Query field, and register scan_history_query in make_executable_schema.
  • Create test: tests/test_scan_history.py.

Plings-iOS (worktree off origin/main)

  • Modify: PlingsKit/Sources/PlingsKit/API/Operations.swiftScanHistoryOperation.
  • Modify: PlingsKit/Sources/PlingsKit/API/Models.swiftScanEventInfo.
  • Create test: PlingsKit/Tests/PlingsKitTests/ScanHistoryDecodeTests.swift.
  • Create: App/Screens/ScanHistoryScreen.swift — “Visa alla” list + shared ScanEventRow + PersonStubScreen + openInAppleMaps helper.
  • Create: App/Views/LastSeenView.swift — the compact inline line + inline expansion (owns history loading + geocoding).
  • Modify: App/Screens/ObjectScreen.swift — replace the Section("Senast sedd") block + its helpers with LastSeenView.

PART A — Plings-API (merge first)

Task 0: Create the API worktree

  • Step 1: Fetch and branch off clean origin
    cd /Users/paul/Documents/GitHub/Plings/Plings-API
    git fetch origin
    git worktree add ../Plings-worktrees/Plings-API/scan-history -b task/scan-history origin/main
    cd ../Plings-worktrees/Plings-API/scan-history
    poetry install
    ls tests/ >/dev/null && echo "checkout OK"
    

    Expected: checkout OK and tests/ populated. (If worktree add errors on venv, see Preconditions.)


Task 1: SDL + resolver registration

Files: Modify app/graphql.py

  • Step 1: Add the ScanEventInfo type and scanHistory Query field

In the type_defs string, add this type next to PublicObject/other object types:

    type ScanEventInfo {
        at: String
        latitude: Float
        longitude: Float
        scannedBy: ID
        scannedByName: String
    }

Inside type Query { ... }, add (next to publicObject):

        scanHistory(objectId: ID!, limit: Int = 20): [ScanEventInfo!]!
  • Step 2: Import and register the resolver

At the top of app/graphql.py, with the other resolver imports:

from .scan_history_resolvers import query as scan_history_query

Add scan_history_query, to the make_executable_schema(type_defs, ...) argument list (next to object_detail_query,).

  • Step 3: Verify schema assembles (will fail until Task 2 creates the module)

Run: poetry run python -c "import app.graphql" Expected: ModuleNotFoundError: No module named 'app.scan_history_resolvers' — proceed to Task 2 (do NOT commit yet).


Task 2: scanHistory resolver (guarded tag-walk)

Files:

  • Create: app/scan_history_resolvers.py
  • Test: tests/test_scan_history.py

  • Step 1: Write the failing test
# Plings-API/tests/test_scan_history.py
import asyncio
import types
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
from app import scan_history_resolvers as shr


class _UC:
    def __init__(self, uid): self.id = uid


def _pg(object_row, profile_rows, membership_rows=()):
    # Two begin() blocks: (1) membership + object row, (2) profiles.
    conn1 = MagicMock()
    m_res = MagicMock(); m_res.__iter__ = lambda s: iter([(r,) for r in membership_rows])
    o_res = MagicMock(); o_res.fetchone = MagicMock(return_value=object_row)
    conn1.execute = AsyncMock(side_effect=[m_res, o_res])
    cm1 = MagicMock(); cm1.__aenter__ = AsyncMock(return_value=conn1); cm1.__aexit__ = AsyncMock(return_value=False)

    conn2 = MagicMock()
    p_res = MagicMock(); p_res.__iter__ = lambda s: iter(profile_rows)
    conn2.execute = AsyncMock(return_value=p_res)
    cm2 = MagicMock(); cm2.__aenter__ = AsyncMock(return_value=conn2); cm2.__aexit__ = AsyncMock(return_value=False)

    engine = MagicMock(); engine.begin = MagicMock(side_effect=[cm1, cm2])
    return engine, conn1


def _neo4j(event_dicts):
    result = MagicMock(); result.data = AsyncMock(return_value=event_dicts)
    session = MagicMock(); session.run = AsyncMock(return_value=result)
    cm = MagicMock(); cm.__aenter__ = AsyncMock(return_value=session); cm.__aexit__ = AsyncMock(return_value=False)
    driver = MagicMock(); driver.session = MagicMock(return_value=cm)
    return driver, session


def _info(engine, driver, user):
    return types.SimpleNamespace(context={"postgres": engine, "neo4j": driver, "user": user})


CREATED = datetime(2026, 6, 17, 8, 0, 0, tzinfo=timezone.utc)


def test_scan_history_maps_events_and_strips_prefix():
    engine, _ = _pg(
        object_row=("owner-1", "org-1", CREATED),
        profile_rows=[("owner-1", "Paul Wisén")],
    )
    driver, session = _neo4j([
        {"id": "s1", "at": "2026-06-17T08:30:00Z", "latitude": 65.5, "longitude": 22.1,
         "scanning_user": "user:owner-1"},
    ])
    info = _info(engine, driver, _UC("owner-1"))
    out = asyncio.run(shr.resolve_scan_history(None, info, objectId="obj-1"))

    assert len(out) == 1
    assert out[0]["scannedBy"] == "owner-1"
    assert out[0]["scannedByName"] == "Paul Wisén"
    assert out[0]["at"] == "2026-06-17T08:30:00Z"
    # The Cypher carries the guard clause + window params derived from created_at.
    _, kwargs = session.run.call_args
    assert kwargs["creator_user"] == "user:owner-1"
    assert kwargs["oid"] == "obj-1"
    assert "object_id = $oid" in session.run.call_args[0][0]
    assert "SCANNED" in session.run.call_args[0][0] and "IDENTIFIES" in session.run.call_args[0][0]


def test_scan_history_unauthorized_returns_empty():
    # object owned by someone else, user not in its org
    engine, _ = _pg(object_row=("owner-1", "org-1", CREATED), profile_rows=[])
    driver, _ = _neo4j([])
    info = _info(engine, driver, _UC("intruder-9"))
    out = asyncio.run(shr.resolve_scan_history(None, info, objectId="obj-1"))
    assert out == []


def test_scan_history_no_user_returns_empty():
    info = types.SimpleNamespace(context={"postgres": MagicMock(), "neo4j": MagicMock(), "user": None})
    out = asyncio.run(shr.resolve_scan_history(None, info, objectId="obj-1"))
    assert out == []


def test_scan_history_window_params_from_created_at():
    engine, _ = _pg(object_row=("owner-1", "org-1", CREATED), profile_rows=[])
    driver, session = _neo4j([])
    info = _info(engine, driver, _UC("owner-1"))
    asyncio.run(shr.resolve_scan_history(None, info, objectId="obj-1"))
    _, kwargs = session.run.call_args
    # window_start = created_at - 10 min; created_iso = created_at
    assert kwargs["created_iso"].startswith("2026-06-17T08:00:00")
    assert kwargs["window_start"].startswith("2026-06-17T07:50:00")
  • Step 2: Run test to verify it fails

Run: poetry run pytest tests/test_scan_history.py -v Expected: FAIL — app.scan_history_resolvers does not exist.

  • Step 3: Create app/scan_history_resolvers.py
# Plings-API/app/scan_history_resolvers.py
"""scanHistory(objectId, limit): an object's scan history via a GUARDED tag-walk.

An event counts for an object when it was scanned while attached to it
(event.object_id == object id — reassignment-safe) OR it is the creation-trigger
scan (same creator, within CREATION_SCAN_WINDOW before created_at). Unrelated tag
scans (registration, loose-tag test, scans long before attachment) are excluded.
Ownership-checked; an unauthorized/unauthenticated viewer gets an empty list.
"""
from typing import Any, Dict, List, Optional
from datetime import timedelta
from ariadne import QueryType
from sqlalchemy import text
from .auth import UserContext
from .logging_config import logger

query = QueryType()

CREATION_SCAN_WINDOW = timedelta(minutes=10)

_CYPHER = """
    MATCH (obj:ObjectInstance {id: $oid})<-[:IDENTIFIES]-(:PlingsIdentifier)<-[:SCANNED]-(e:ScanEvent)
    WHERE e.object_id = $oid
       OR (e.scanning_user = $creator_user
           AND e.timestamp >= datetime($window_start)
           AND e.timestamp <= datetime($created_iso))
    RETURN DISTINCT e.id AS id, toString(e.timestamp) AS at,
           e.latitude AS latitude, e.longitude AS longitude,
           e.scanning_user AS scanning_user
    ORDER BY at DESC
    LIMIT $limit
"""


def _raw_uid(scanning_user: Optional[str]) -> Optional[str]:
    if scanning_user and scanning_user.startswith("user:"):
        return scanning_user.split("user:", 1)[1]
    return None


@query.field("scanHistory")
async def resolve_scan_history(_: Any, info, objectId: str, limit: int = 20) -> List[Dict[str, Any]]:
    user: UserContext | None = info.context.get("user")
    if not user or not getattr(user, "id", None):
        return []

    pg_engine = info.context["postgres"]
    neo4j = info.context.get("neo4j")

    # 1) Permission + the guard inputs (created_by / created_at).
    try:
        async with pg_engine.begin() as conn:
            membership = await conn.execute(
                text("SELECT organization_id FROM organization_members WHERE user_id = :uid"),
                {"uid": str(user.id)},
            )
            user_org_ids = [str(r[0]) for r in membership]
            obj = await conn.execute(
                text("SELECT created_by, owner_organization_id, created_at "
                     "FROM object_instances WHERE id = :oid"),
                {"oid": objectId},
            )
            row = obj.fetchone()
        if not row:
            return []
        created_by, owner_org, created_at = row
        if str(created_by) != str(user.id) and str(owner_org) not in user_org_ids:
            return []  # not permitted → no who-leak
    except Exception as e:
        logger.error(f"scanHistory permission/lookup failed for {objectId}: {e}")
        return []

    if created_at is not None:
        window_start = (created_at - CREATION_SCAN_WINDOW).isoformat()
        created_iso = created_at.isoformat()
    else:  # no created_at → guard (2) matches nothing
        window_start = "9999-01-01T00:00:00+00:00"
        created_iso = "0001-01-01T00:00:00+00:00"
    creator_user = f"user:{created_by}"

    # 2) Guarded tag-walk.
    try:
        async with neo4j.session() as session:
            result = await session.run(
                _CYPHER, oid=objectId, creator_user=creator_user,
                window_start=window_start, created_iso=created_iso, limit=limit,
            )
            events = await result.data()
    except Exception as e:
        logger.error(f"scanHistory query failed for {objectId}: {e}")
        return []

    # 3) Resolve scannedByName from profiles for the distinct scanners.
    raw_ids = list({uid for ev in events if (uid := _raw_uid(ev.get("scanning_user")))})
    names: Dict[str, str] = {}
    if raw_ids:
        try:
            async with pg_engine.begin() as conn:
                pr = await conn.execute(
                    text("SELECT id, full_name FROM profiles WHERE id::text = ANY(:ids)"),
                    {"ids": raw_ids},
                )
                names = {str(r[0]): r[1] for r in pr}
        except Exception as e:
            logger.error(f"scanHistory name lookup failed for {objectId}: {e}")

    out: List[Dict[str, Any]] = []
    for ev in events:
        uid = _raw_uid(ev.get("scanning_user"))
        out.append({
            "at": ev.get("at"),
            "latitude": ev.get("latitude"),
            "longitude": ev.get("longitude"),
            "scannedBy": uid,
            "scannedByName": names.get(uid) if uid else None,
        })
    return out
  • Step 4: Run test to verify it passes + schema assembles

Run: poetry run pytest tests/test_scan_history.py -v && poetry run python -c "import app.graphql" Expected: 4 passed; import OK.

  • Step 5: Run the full suite (no regression)

Run: poetry run pytest -q Expected: green.

  • Step 6: Commit
    git add app/scan_history_resolvers.py app/graphql.py tests/test_scan_history.py
    git commit -m "feat(api): scanHistory(objectId, limit) — guarded tag-walk over :ScanEvent"
    

Task 3: Live Cypher check + docs + ROADMAP

The unit tests assert params/permission/mapping; the guard semantics live in Cypher, so verify them against the real dev DB and document.

  • Step 1: Verify the guard against real data (Neo4j MCP)

Using the neo4j-aura MCP read_neo4j_cypher tool, run _CYPHER (from the resolver) against a real object id that has scan events — once with a real created_at/creator_user, and confirm: post-attachment scans (matching object_id) appear; a scan by the creator just before creation appears; a scan by a different user or far outside the window does NOT. (Use a known object from object_instances.) Record the object id + outcome in the PR description. This is the real verification of guard (2).

  • Step 2: Docs (Plings-Web dev, in a Plings-Web worktree)

In a Plings-Web worktree off origin/dev, document scanHistory in docs/api/ (query shape, the guarded tag-walk rule, ownership gating, the 10-min CREATION_SCAN_WINDOW), and copy this spec/plan in if not already present. Add an **Updated**: line (date "+%a %d %b %Y %H:%M:%S %Z"). Commit on dev, open a PR. (Same pattern as the prior docs PR.)

  • Step 3: API ROADMAP

In Plings-API/ROADMAP.md (this worktree): add a Done line for scanHistory (guarded tag-walk; ownership-gated; no migration/backfill) with date + PR ref. Add Backlog items: “reassignment-aware history (scope by an IDENTIFIES creation timestamp)” and “tag-centric scan history view”. Commit:

git add ROADMAP.md
git commit -m "docs(roadmap): scanHistory done + reassignment/tag-centric backlog"

Task 4: API quality gate + ship

  • Step 1: poetry run pytest -q → green.
  • Step 2: Dispatch quality-control-enforcer over git diff origin/main..HEAD (new GraphQL query resolver — QC trigger). Address findings.
  • Step 3: PR + merge + (note local FF)
    git push -u origin task/scan-history
    gh pr create --base main --title "feat(api): scanHistory (guarded tag-walk)" --body "Ownership-gated scanHistory(objectId, limit) over :ScanEvent via object←IDENTIFIES←tag←SCANNED←event, keeping events where object_id matches OR the creation-trigger scan (same created_by, within 10 min of created_at). No migration/backfill. Tests green; guard verified live (object id in checklist). Merge BEFORE the iOS PR."
    gh pr merge --merge --delete-branch
    

    (API deploys from origin/main; the local Plings-API checkout is dirty — do NOT force-FF it.) Reap the worktree (git worktree remove, prune, delete branch).


PART B — Plings-iOS (merge after the API is live)

Task 5: PlingsKit — ScanHistoryOperation + ScanEventInfo

Files:

  • Modify: PlingsKit/Sources/PlingsKit/API/Operations.swift
  • Modify: PlingsKit/Sources/PlingsKit/API/Models.swift
  • Test: PlingsKit/Tests/PlingsKitTests/ScanHistoryDecodeTests.swift

  • Step 0: Create the iOS worktree (off clean origin)
    cd /Users/paul/Documents/GitHub/Plings/Plings-iOS
    git fetch origin
    git worktree add ../Plings-worktrees/Plings-iOS/scan-history -b task/scan-history origin/main
    cd ../Plings-worktrees/Plings-iOS/scan-history
    (cd PlingsKit && swift build) && xcodegen generate
    
  • Step 1: Write the failing decode test ```swift // PlingsKit/Tests/PlingsKitTests/ScanHistoryDecodeTests.swift import XCTest @testable import PlingsKit

final class ScanHistoryDecodeTests: XCTestCase { func testDecodesScanHistory() throws { let json = “”” {“scanHistory”:[ {“at”:”2026-06-17T08:30:00Z”,”latitude”:65.5,”longitude”:22.1,”scannedBy”:”u-1”,”scannedByName”:”Paul”}, {“at”:”2026-06-10T09:00:00Z”,”latitude”:null,”longitude”:null,”scannedBy”:”u-2”,”scannedByName”:null} ]} “”“.data(using: .utf8)! let data = try JSONDecoder().decode(ScanHistoryOperation.Data.self, from: json) XCTAssertEqual(data.scanHistory.count, 2) XCTAssertEqual(data.scanHistory[0].scannedByName, “Paul”) XCTAssertEqual(data.scanHistory[0].latitude, 65.5) XCTAssertNil(data.scanHistory[1].latitude) XCTAssertNil(data.scanHistory[1].scannedByName) } }


- [ ] **Step 2: Run** `cd PlingsKit && swift test --filter ScanHistoryDecodeTests` → FAIL (types don't exist).

- [ ] **Step 3: Add `ScanEventInfo` in `Models.swift`**
```swift
public struct ScanEventInfo: Decodable, Sendable {
    public let at: String?
    public let latitude: Double?
    public let longitude: Double?
    public let scannedBy: String?
    public let scannedByName: String?
}
  • Step 4: Add ScanHistoryOperation in Operations.swift (mirror the existing GraphQLOperation structs)
    public struct ScanHistoryOperation: GraphQLOperation {
      public struct Variables: Encodable, Sendable { public let objectId: String; public let limit: Int }
      public struct Data: Decodable, Sendable { public let scanHistory: [ScanEventInfo] }
      public static let document = """
      query ScanHistory($objectId: ID!, $limit: Int) {
        scanHistory(objectId: $objectId, limit: $limit) {
          at latitude longitude scannedBy scannedByName
        }
      }
      """
      public let variables: Variables
      public init(objectId: String, limit: Int = 20) {
          variables = Variables(objectId: objectId, limit: limit)
      }
    }
    
  • Step 5: Run cd PlingsKit && swift test --filter ScanHistoryDecodeTests → PASS; then swift test → all green.

  • Step 6: Commit
    git add PlingsKit/Sources/PlingsKit/API/Operations.swift PlingsKit/Sources/PlingsKit/API/Models.swift PlingsKit/Tests/PlingsKitTests/ScanHistoryDecodeTests.swift
    git commit -m "feat(kit): ScanHistoryOperation + ScanEventInfo"
    

Task 6: History screen, person stub, Apple Maps helper

Files: Create App/Screens/ScanHistoryScreen.swift

  • Step 1: Create the file (shared row + full-history list + person stub + maps helper) ```swift // App/Screens/ScanHistoryScreen.swift import SwiftUI import PlingsKit import CoreLocation

/// Opens Apple Maps at a coordinate (interim until an in-app map — see ROADMAP). func openInAppleMaps(latitude: Double, longitude: Double, label: String = “Plings”) { let q = label.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? “Plings” if let url = URL(string: “https://maps.apple.com/?ll=(latitude),(longitude)&q=(q)”) { UIApplication.shared.open(url) } }

/// One scan event row: time + place (reverse-geocoded) + who. Place taps → Apple Maps. struct ScanEventRow: View { let event: ScanEventInfo let currentUserId: String? @State private var place: String?

private var whoLabel: String? {
    if let by = event.scannedBy, let me = currentUserId, by.lowercased() == me.lowercased() { return "dig" }
    return event.scannedByName
}

var body: some View {
    VStack(alignment: .leading, spacing: 2) {
        Text(relativeOrAbsolute(event.at)).font(.subheadline)
        HStack(spacing: 6) {
            if let place {
                Button { if let la = event.latitude, let lo = event.longitude { openInAppleMaps(latitude: la, longitude: lo) } }
                label: { Label(place, systemImage: "mappin.and.ellipse") }
                    .buttonStyle(.plain).foregroundStyle(.secondary)
            }
            if let who = whoLabel {
                Text("· av \(who)").foregroundStyle(.tertiary)
            }
        }.font(.caption)
    }
    .task(id: event.latitude) {
        guard let la = event.latitude, let lo = event.longitude else { place = nil; return }
        let marks = try? await CLGeocoder().reverseGeocodeLocation(CLLocation(latitude: la, longitude: lo))
        if let p = marks?.first {
            let s = [p.thoroughfare, p.locality].compactMap { $0 }.joined(separator: ", ")
            place = s.isEmpty ? nil : s
        }
    }
} }

/// “Visa alla” — full scan history. struct ScanHistoryScreen: View { @EnvironmentObject var model: AppModel let objectId: String @State private var events: [ScanEventInfo] = [] @State private var loading = true

var body: some View {
    List {
        if loading { ProgressView() }
        else if events.isEmpty { Text("Inga skanningar ännu").foregroundStyle(.secondary) }
        else { ForEach(events.indices, id: \.self) { i in ScanEventRow(event: events[i], currentUserId: model.currentUserId) } }
    }
    .navigationTitle("Senast sedd")
    .task {
        defer { loading = false }
        events = (try? await model.api.execute(ScanHistoryOperation(objectId: objectId, limit: 50)))?.scanHistory ?? []
    }
} }

/// Stub until person/profile management exists (see ROADMAP). struct PersonStubScreen: View { let name: String? var body: some View { VStack(spacing: 8) { Image(systemName: “person.crop.circle”).font(.largeTitle).foregroundStyle(.secondary) Text(name ?? “Användare”).font(.headline) Text(“Profil – kommer snart”).font(.subheadline).foregroundStyle(.secondary) }.navigationTitle(“Profil”) } }

/// Swedish relative time for recent, absolute for older. Tolerant of fractional seconds. func relativeOrAbsolute(_ iso: String?) -> String { guard let iso else { return “—” } let a = ISO8601DateFormatter() let b = ISO8601DateFormatter(); b.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let date = a.date(from: iso) ?? b.date(from: iso) else { return iso } if Date().timeIntervalSince(date) < 24 * 3600 { let f = RelativeDateTimeFormatter(); f.locale = Locale(identifier: “sv_SE”) return f.localizedString(for: date, relativeTo: Date()) } let df = DateFormatter(); df.locale = Locale(identifier: “sv_SE”); df.dateFormat = “d MMM HH:mm” return df.string(from: date) }


- [ ] **Step 2: Build**

Run: `xcrun xcodebuild build -project Plings.xcodeproj -scheme Plings -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO` (run `xcodegen generate` first — new files added).
Expected: BUILD SUCCEEDED.

- [ ] **Step 3: Commit**
```bash
git add App/Screens/ScanHistoryScreen.swift
git commit -m "feat(ios): scan-history screen, event row, person stub, Apple Maps helper"

Task 7: LastSeenView (compact line + inline expansion) + wire into ObjectScreen

Files:

  • Create: App/Views/LastSeenView.swift
  • Modify: App/Screens/ObjectScreen.swift

  • Step 1: Create LastSeenView.swift

A subtle inline line with three tap zones; tapping time toggles an inline expansion that loads the last 3 events + a “Visa alla” link.

// App/Views/LastSeenView.swift
import SwiftUI
import PlingsKit
import CoreLocation

/// Compact, subtle "Senast sedd" line. time → toggles inline history (last 3) + "Visa alla";
/// place → Apple Maps; who → person stub. Reads the line from the passed-in lastSeenInfo;
/// loads history lazily on first expand.
struct LastSeenView: View {
    @EnvironmentObject var model: AppModel
    let objectId: String
    let info: LastSeenInfo

    @State private var expanded = false
    @State private var place: String?
    @State private var history: [ScanEventInfo] = []
    @State private var loadedHistory = false

    private var whoLabel: String? {
        if let by = info.scannedBy, let me = model.currentUserId, by.lowercased() == me.lowercased() { return "dig" }
        return info.scannedByName
    }
    private var shortPlace: String? { place?.components(separatedBy: ",").first }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(spacing: 8) {
                Button { expanded.toggle() } label: {
                    Label(relativeOrAbsolute(info.at), systemImage: "clock").labelStyle(.titleAndIcon)
                }.buttonStyle(.plain)

                if let sp = shortPlace, let la = info.latitude, let lo = info.longitude {
                    Text("·").foregroundStyle(.tertiary)
                    Button { openInAppleMaps(latitude: la, longitude: lo) } label: {
                        Label(sp, systemImage: "mappin.and.ellipse").labelStyle(.titleAndIcon)
                    }.buttonStyle(.plain)
                }
                if let who = whoLabel {
                    Text("·").foregroundStyle(.tertiary)
                    NavigationLink { PersonStubScreen(name: info.scannedByName) } label: {
                        Label(who, systemImage: "person").labelStyle(.titleAndIcon)
                    }.buttonStyle(.plain)
                }
                Spacer(minLength: 0)
            }
            .font(.footnote).foregroundStyle(.secondary)

            if expanded {
                VStack(alignment: .leading, spacing: 8) {
                    if !loadedHistory { ProgressView().controlSize(.small) }
                    else if history.isEmpty { Text("Inga skanningar ännu").font(.caption).foregroundStyle(.secondary) }
                    else {
                        ForEach(history.prefix(3).indices, id: \.self) { i in
                            ScanEventRow(event: history[i], currentUserId: model.currentUserId)
                        }
                        NavigationLink { ScanHistoryScreen(objectId: objectId) } label: {
                            Text("Visa alla").font(.caption)
                        }
                    }
                }
                .padding(.leading, 4)
            }
        }
        .task(id: info.latitude) {   // line place
            guard let la = info.latitude, let lo = info.longitude else { place = nil; return }
            let marks = try? await CLGeocoder().reverseGeocodeLocation(CLLocation(latitude: la, longitude: lo))
            if let p = marks?.first {
                let s = [p.thoroughfare, p.locality].compactMap { $0 }.joined(separator: ", ")
                place = s.isEmpty ? nil : s
            }
        }
        .task(id: expanded) {        // load history on first expand
            guard expanded, !loadedHistory else { return }
            history = (try? await model.api.execute(ScanHistoryOperation(objectId: objectId, limit: 3)))?.scanHistory ?? []
            loadedHistory = true
        }
    }
}
  • Step 2: Replace the old Section in ObjectScreen.swift

Delete the Section("Senast sedd") { ... } block (the if let ls = details?.lastSeenInfo, let at = ls.at { Section(...) } added previously, ~lines 126–137) and the now-unused helpers relativeTime(_:) and scannedByLabel(_:) and the line-place .task(id: details?.lastSeenInfo?.latitude) + @State private var lastSeenPlace. Replace the section with a single compact row:

            if let ls = details?.lastSeenInfo, ls.at != nil {
                Section {
                    LastSeenView(objectId: store.serverIdIfReady(objectId) ?? objectId, info: ls)
                        .environmentObject(model)
                }
            }

(If relativeOrAbsolute collides with the old relativeTime, keep only relativeOrAbsolute from ScanHistoryScreen.swift. Remove the import CoreLocation from ObjectScreen.swift only if nothing else there uses it — check first.)

  • Step 3: Build

Run: xcrun xcodebuild build -project Plings.xcodeproj -scheme Plings -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO Expected: BUILD SUCCEEDED. Fix any reference to the removed helpers/state.

  • Step 4: Commit
    git add App/Views/LastSeenView.swift App/Screens/ObjectScreen.swift
    git commit -m "feat(ios): compact LastSeenView with inline history; replace Senast sedd Section"
    

Task 8: iOS quality gate, docs, ship

  • Step 1: cd PlingsKit && swift test (green) + the xcodebuild build above (BUILD SUCCEEDED).
  • Step 2: iOS ROADMAPPlings-iOS/ROADMAP.md: Done line for compact “Senast sedd” + tappable history (PR ref + date). Backlog: “in-app interactive map (replace the Apple Maps hand-off)” and “person/profile management (who-tap → real profile)”. Commit (scoped).
  • Step 3: Dispatch quality-control-enforcer over the iOS git diff origin/main..HEAD. Address findings.
  • Step 4: PR + merge (after the API PR is live) + FF + xcodegen
    git push -u origin task/scan-history
    gh pr create --base main --title "feat(ios): compact Senast sedd + tappable scan history" --body "Subtle inline line (time→inline last-3 + Visa alla full screen; place→Apple Maps; who→person stub). Needs Plings-API scanHistory live first. PlingsKit green; app builds; QC passed."
    gh pr merge --merge --delete-branch
    git -C /Users/paul/Documents/GitHub/Plings/Plings-iOS fetch origin
    git -C /Users/paul/Documents/GitHub/Plings/Plings-iOS merge --ff-only origin/main   # clean checkout only
    cd /Users/paul/Documents/GitHub/Plings/Plings-iOS && xcodegen generate   # new files added
    
  • Step 5: Reap the worktree (git worktree remove --force, prune, delete local+remote branch).
  • Step 6: Device test handoff. iOS is “ready for device test”, not done until Paul verifies on-device: line is compact; tap time → expands last 3 + “Visa alla”; tap place → Apple Maps; tap who → stub; an object scanned multiple times shows multiple history rows; a registration/test scan does NOT appear.

Definition of done

  • Plings-API: poetry run pytest -q green; guard verified live via Neo4j MCP (object id in PR); QC passed; merged to main.
  • Plings-iOS: swift test green; app builds; QC passed; merged after the API; local checkout FF’d + xcodegen generate run.
  • Docs updated on dev (API scanHistory); both ROADMAP.md files updated (incl. map + person backlog).
  • Paul confirms on-device.
  • FINAL: quality-control-enforcer validated the API and iOS diffs (new GraphQL query resolver = QC trigger).