Compact “Senast sedd” + scan history — Implementation Plan
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>aftergit fetch(NEVER the local checkout — bothPlings-APIandPlings-iOSlocal checkouts carry concurrent-session WIP). Confirm a clean checkout (ls tests/for API,Operations.swiftpresent for iOS) before editing. - No DB migration and no DB write in this plan —
scanHistoryis read-only;createObjectis untouched. - Merge order: Plings-API first (query is additive/non-breaking), then Plings-iOS, then Plings-Web docs on
dev. origin/mainon Plings-API has a committedvenv/(pre-existing, noted in its ROADMAP backlog) that can breakgit worktreecheckout. Ifworktree addfails part-way onvenv, 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.py—scanHistoryquery (guarded tag-walk + permission + name join). - Modify:
app/graphql.py— addtype ScanEventInfo, thescanHistoryQuery field, and registerscan_history_queryinmake_executable_schema. - Create test:
tests/test_scan_history.py.
Plings-iOS (worktree off origin/main)
- Modify:
PlingsKit/Sources/PlingsKit/API/Operations.swift—ScanHistoryOperation. - Modify:
PlingsKit/Sources/PlingsKit/API/Models.swift—ScanEventInfo. - Create test:
PlingsKit/Tests/PlingsKitTests/ScanHistoryDecodeTests.swift. - Create:
App/Screens/ScanHistoryScreen.swift— “Visa alla” list + sharedScanEventRow+PersonStubScreen+openInAppleMapshelper. - Create:
App/Views/LastSeenView.swift— the compact inline line + inline expansion (owns history loading + geocoding). - Modify:
App/Screens/ObjectScreen.swift— replace theSection("Senast sedd")block + its helpers withLastSeenView.
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 OKandtests/populated. (Ifworktree adderrors onvenv, see Preconditions.)
Task 1: SDL + resolver registration
Files: Modify app/graphql.py
- Step 1: Add the
ScanEventInfotype andscanHistoryQuery 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-enforcerovergit 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 localPlings-APIcheckout 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
ScanHistoryOperationinOperations.swift(mirror the existingGraphQLOperationstructs)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; thenswift 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) + thexcodebuild buildabove (BUILD SUCCEEDED). - Step 2: iOS ROADMAP —
Plings-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-enforcerover the iOSgit 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 -qgreen; guard verified live via Neo4j MCP (object id in PR); QC passed; merged tomain. - Plings-iOS:
swift testgreen; app builds; QC passed; merged after the API; local checkout FF’d +xcodegen generaterun. - Docs updated on
dev(APIscanHistory); bothROADMAP.mdfiles 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).