Skip to content

[P2] Liftosaur heterogeneous history undercounts skipped records on Swift #94

Description

@bhelm

Summary

Swift and Kotlin import the same valid Liftosaur workout from a heterogeneous history array, but report different rejected-record counts. Swift's pattern-filtering loop silently discards non-object elements; Kotlin counts them as skipped records.

This is a product-contract drift, not a parity-harness problem. Route the correction upstream and keep LiftingImporter.parse/2 quarantined from promotion until the product behavior is aligned.

Exact representative input and outputs

Use this history order:

{
  "history": [
    {
      "startTime": 1748772000000,
      "endTime": 1748775600000,
      "dayName": "Day 1",
      "entries": [
        { "sets": [ { "weight": 80, "completedReps": 5 } ] }
      ]
    },
    7,
    {
      "dayName": "Missing timestamp",
      "entries": [
        { "sets": [ { "weight": 60, "completedReps": 8 } ] }
      ]
    },
    "not a record"
  ]
}

Both platforms produce exactly one session, in oldest-first order:

  • start: Unix 1748772000 (2025-06-01T10:00:00Z)
  • end: Unix 1748775600 (2025-06-01T11:00:00Z)
  • volume load: 400.0 kg
  • sets: 1
  • exercises: 1
  • total reps: 5
  • top set: 80.0 kg
  • title: Day 1
  • Swift earliest/latest: the session start
  • Kotlin firstDay/lastDay: 2025-06-01

The rejected count diverges:

  • Swift: skipped = 1
  • Kotlin: skipped = 3

The missing-timestamp object is counted by both. The numeric and string array elements are counted by Kotlin but silently filtered by Swift.

Cause

Swift iterates with:

for case let record as [String: Any] in history {
    if let s = liftosaurSession(record) { sessions.append(s) } else { skipped += 1 }
}

for case excludes non-dictionaries before the body, so those malformed history entries never increment skipped.

Kotlin explicitly calls optJSONObject(i) and increments skipped when it returns null, then also increments when an object cannot form a session.

Contract

Both implementations document the same tolerant contract: a malformed Liftosaur set or record is "skipped and counted, never fatal." The result models also describe skipped as the number of dropped Liftosaur records. Kotlin follows that contract for every history element; Swift does not.

Live product reachability

This is not test-only behavior.

  • Swift DataSourcesView calls LiftingImporter.parse(data:), persists every returned session as a WorkoutRow, and surfaces/logs result.skipped in the import result.
  • Android DataSourcesScreen calls LiftingImporter.importExport, which dispatches through parse, persists returned sessions via WhoopRepository, reloads Workouts, and includes result.skipped in ImportSummary.

The valid workout and its ordering/persistence are identical. The user-visible import accounting and diagnostic log differ.

Provenance

The divergent loops and shared "skipped and counted" contract were introduced with the original lifting importer in c4c320e8 (v2.11.0). Later Swift commit d4757ffb changed template-set handling and number grouping, not the history loop. Later Kotlin refactoring changed syntax but preserved explicit scalar counting.

The behavior remains byte-for-byte present on parity baseline 032983d2bed9bf50dfcfc4d06ef707f3818cdc26 and current upstream main b0d8a29fbc89836fc4257b9511af63629a2daa18.

Severity

P2. The discrepancy is visible in import summaries/logs and violates the documented rejection-accounting contract, but it does not lose a valid workout, reorder valid sessions, corrupt stored fields, or affect Effort/scoring.

Duplicate search

Searched open and closed issues and pull requests in both bhelm/noop and ryanbr/noop for Liftosaur, parseLiftosaur, LiftingImporter, heterogeneous history, scalar history elements, malformed records, and skipped counts. No duplicate exists.

Acceptance criteria

Upstream-only product routing; do not patch or promote the parity harness as a substitute.

  • Add mirrored public regression tests using the exact four-element history above.
  • Assert the complete valid session fields and oldest-first order on both platforms.
  • Assert canonical skipped = 3 on both platforms.
  • On unchanged code, Swift must fail only on 1 != 3; Kotlin must pass as the control.
  • Apply the smallest Swift-only production correction unless an authoritative contract changes.
  • Preserve valid-session parsing, order, timestamps/date span, persistence, notes, scoring, APIs, and package graphs.
  • Run focused, affected, and full Swift/Kotlin suites; revert any temporary Linux compatibility gates byte-exactly.
  • Keep LiftingImporter.parse/2 quarantined from parity promotion until the upstream fix is reviewed and merged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions