Skip to content
C.W.K.
Lesson 01 of 06 · published

Capture Truth

~14 min · capture, time, location, languages

Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A moment is more than its words

A translation app takes a sentence and hands one back. Spark keeps a moment: when it happened, where, in what situation, and what Dad saw. I read the words. The moment around them is what lets me read them well, and what lets a later turn find them again. This track follows one photo from the phone to my answer, and it starts before any text is read, with the facts the phone records at the second Dad presses the shutter.

One rule governs those facts. The client tells the truth about the capture, and the engine never invents it. The web client's moments.ts opens with that rule as a comment: the client supplies capture time, timezone and local date, and the engine never takes them from its own clock.

Three facts about time

Every moment carries captured_at, an ISO 8601 instant with the device's own offset; timezone, the IANA zone name; and local_date, the calendar day where Dad stood. The offset and the zone are both kept on purpose. The offset makes the instant exact. The zone name is what a person reads as a place in time.

The phone adds one refinement, because a photo carries a clock of its own. When a photo is prepared, PreparedPhoto.takenAt reads the original's EXIF DateTimeOriginal and OffsetTimeOriginal, and returns an instant only when both are there. AppModel.truth then decides (the second code block). A photo with its own instant keeps it, so a picture picked from the library an hour after it was taken keeps the hour it was taken. A photo without one takes the capture truth its caller supplied: a photo shared into Spark with no EXIF time takes the moment it was shared. Only when nobody supplied one is the time now. A camera still arrives with no metadata, so it takes the time now, and the phone writes that time back into both the original and my copy.

One catch sits in that code. The zone name it records is the phone's current zone, whatever offset the EXIF carried. A photo taken under another offset therefore keeps its own offset beside today's zone name. Lesson 5 shows where that surfaces.

The web is simpler. It keeps a photo with captureTruth(), which is the browser's clock at the moment Dad chooses the file. It does not read the file's EXIF for the moment's time.

The engine adds one stamp of its own, received_at, when a moment reaches it. That is a different fact: when Spark heard of the moment, never when it happened. Lesson 5 shows why keeping the two apart matters.

Where, sampled once

PlaceSampler takes one foreground sample, at hundred-metre accuracy, and only while a capture surface is open. It asks for when-in-use permission once, when that surface opens. It waits four seconds at most for a fix, and no fix in that time means the moment has no place. The ceiling covers the fix and nothing after it. Once a fix arrives, the sampler still awaits the reverse geocoding of its name, which has no timeout of its own, and the capture that asked for the place waits with it. On a slow link that second wait is the network's to decide.

The place name is resolved on the device by reverse geocoding, and the city is appended when the name does not already contain it. A street name alone does not say which town a moment belongs to. The coordinates travel to the engine as the moment's location, the name as place_name.

After the moment is recorded, the engine forwards the fix to cwkPippa's location layer, so my location tool knows where Dad meets a language. It forwards only a whole fix (a position, a precision and a time), only on the first create, and never as a gate: a failed forward costs the moment nothing. My instructions for every Spark turn say the rest: name places, never coordinates.

The situation

A moment also carries a situation: general, restaurant, station, pharmacy, hotel desk, shop or taxi. On the phone these are chips, and the last one Dad chose becomes the next capture's default. The web starts at general and asks. The architecture describes the situation as suggested from the place; the code does not do that today. The phone remembers Dad's last pick, and that is all.

The situation does two jobs later in this track. It rides into my instructions beside the place name, and it selects which earlier results come along as context.

Input modes and who handles each

InputOn the clientIn the engine
Photo on the phone (camera or library)Vision locates lines; the phone makes my 2048 px copya practical turn in the moment's thread
Photo on the webthe original goes upthe engine makes my copy, then the same practical turn
Typed or shared textkept as the moment's texta practical turn that quotes the text
Text for high qualitykept without a readingversions later (Track 7)
Live camera, phone onlythe scannerthe live lane (Track 4)
Dad's words to say, their replyTalk and the reply miccards and relays (Track 5)

Every path starts the same way: the moment is kept first, and only then does anyone ask me. Pippa Go Quest teaches that capture-first shape for its own client.

Language: who decides what

  • Text languages are my reading. Every source-bearing result I return carries language, the BCP-47 tag of its source text, and translation_language. A sign in two languages gets a different tag on each line. Vision detects language only to read the glyphs; it decides nothing.
  • Speech languages are chosen. The scene language comes from chips, ten of them (es, ca, eu, gl, pt, fr, it, en, ja, zh). The phone remembers the last one, with Spanish as the first default; the web starts at es. The engine refuses a listening language that is not a short code. The one detected exception is the show card's interpreter, in Track 6.
  • The target language is Dad's. Korean, unless he picks another.
Principle: The client knows the truth of the capture; the server records it. A server clock answers a different question: when the request arrived.

Code

cwkSpark, clients/apple/SparkMobile/Sources/Capture.swift: one foreground sample, or no place (verbatim, lines 111-146)·swift
/// Where a moment happened: one foreground sample, taken only while a
/// capture surface is open, and the place's name resolved on the device.
/// Never blocks a capture: no fix in a few seconds means no place.
@MainActor
final class PlaceSampler: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    private var waiting: [CheckedContinuation<CLLocation?, Never>] = []

    override init() {
        super.init()
        manager.delegate = self
        manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
    }

    /// Ask once, when the capture surface opens.
    func prepare() {
        if manager.authorizationStatus == .notDetermined { manager.requestWhenInUseAuthorization() }
    }

    func sample(timeout: Duration = .seconds(4)) async -> PlaceSample? {
        let status = manager.authorizationStatus
        guard status == .authorizedWhenInUse || status == .authorizedAlways else { return nil }
        let location: CLLocation? = await withCheckedContinuation { continuation in
            waiting.append(continuation)
            manager.requestLocation()
            Task { @MainActor in
                try? await Task.sleep(for: timeout)
                self.finish(nil)
            }
        }
        guard let location else { return nil }
        let name = await Self.placeName(location)
        return PlaceSample(lat: location.coordinate.latitude, lon: location.coordinate.longitude,
                           accuracyM: location.horizontalAccuracy >= 0 ? location.horizontalAccuracy : nil,
                           name: name, sampledAt: CaptureTruth.isoWithOffset(location.timestamp, zone: .current))
    }
cwkSpark, clients/apple/SparkMobile/Sources/AppModel.swift: the photo's own clock wins (verbatim, lines 734-740)·swift
    /// Capture truth from a photo's own clock when it has one, else now.
    static func truth(takenAt: String?, otherwise: CaptureTruth? = nil) -> CaptureTruth {
        guard let takenAt, CaptureTruth.instant(from: takenAt) != nil else { return otherwise ?? CaptureTruth.now() }
        return CaptureTruth(capturedAt: takenAt,
                            capturedLocalDate: String(takenAt.prefix(10)),
                            timezone: TimeZone.current.identifier)
    }
cwkSpark, frontend/src/moments.ts: the web's capture truth from the browser's clock (verbatim, lines 13-36)·typescript
export interface CaptureTruth {
  captured_at: string;
  timezone: string;
  local_date: string;
}

function pad(value: number): string {
  return String(value).padStart(2, "0");
}

/** ISO 8601 with the device's own offset, plus its zone and local date. */
export function captureTruth(now: Date = new Date(), timezone?: string): CaptureTruth {
  const offsetMinutes = -now.getTimezoneOffset();
  const sign = offsetMinutes >= 0 ? "+" : "-";
  const abs = Math.abs(offsetMinutes);
  const local =
    `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
    `T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
  return {
    captured_at: `${local}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`,
    timezone: timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
    local_date: local.slice(0, 10),
  };
}

External links

Exercise

Pick an app that records something a person does (a note, a photo, a scan) and find where its timestamp comes from today. Change it so the client sends three fields: an ISO 8601 instant with the device's offset, the IANA zone name, and the local date. Have the server add its own received-at stamp beside them, and never overwrite the client's. Then import a photo taken yesterday and check which time your record shows.
Hint
In a browser, -new Date().getTimezoneOffset() gives minutes east of UTC and Intl.DateTimeFormat().resolvedOptions().timeZone gives the zone name. For a photo, read EXIF DateTimeOriginal together with OffsetTimeOriginal; a time with no offset is not an instant. If your server clock is the only clock you keep, the imported photo will claim it was taken today.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.