> ## Documentation Index
> Fetch the complete documentation index at: https://285e39fd5e337e58f16290.sightscreen.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Code conventions

> Coding standards, architectural rules, and review guidelines enforced across the codebase.

These conventions are enforced by [CodeRabbit](https://coderabbit.ai) on every PR and by Claude Code locally via the `/verify` skill. They are the guardrails that keep the codebase consistent across the backend, iOS app, widgets, and infrastructure.

<Accordion title="Server-driven UI">
  The single most important architectural rule in the project.

  <Warning>
    **iOS is a dumb view layer.** ALL data processing, formatting, and business logic MUST live on the backend. The backend sends pre-formatted, ready-to-render values; iOS simply binds them to SwiftUI views.
  </Warning>

  ### Violations (flag these in any iOS code)

  * String formatting or number formatting of data (e.g., building `"87/6"` from separate runs/wickets values, computing run rates, formatting overs)
  * Conditional logic based on match state (e.g., checking innings number to decide what text to show, computing progress from overs)
  * Hardcoded display strings that should come from the backend (e.g., match titles, toss descriptions, status messages)
  * Data transformations or computations on backend-provided values
  * Parsing backend strings to extract sub-values (e.g., splitting overs `"14.2"` to compute ball number)

  ### Allowed client-side logic (pure view concerns)

  * Color/theming decisions (mapping a sentiment string to a `Color`)
  * Simple array filtering on a boolean flag (e.g., `isOnStrike`)
  * Emoji flag maps (Live Activities cannot load network images)
  * SwiftUI layout, animation, and styling code
  * Local UI state (sheet presentation, scroll position, toggle state)
</Accordion>

<Accordion title="Backend TypeScript">
  ### Source of truth

  * **Zod schemas** are the source of truth for types. Update schemas first, then code.
  * Use `z.infer<typeof Schema>` for TypeScript types — never duplicate type definitions.
  * `DisplayStateBuilder` builds `UnifiedDisplayState` — the single source of truth for all display values sent to iOS and APNs.
  * APNs content state must mirror iOS `LiveActivityModels.swift` exactly.

  ### Code comments

  <Warning>
    Do not add comments that merely restate what the code already says. A comment should explain **WHY**, not **WHAT**. If the code is clear enough to read without the comment, the comment is noise.
  </Warning>

  Bad examples: `// Get all devices for this user`, `// Return the result`, `// Check if null`

  ### DynamoDB anti-patterns

  <Warning>
    **Never use `ScanCommand`** in store files. Full table scans are a cost and latency smell that masks a missing GSI or wrong key schema.
  </Warning>

  Every scan should either:

  1. Be replaced with a `QueryCommand` on the existing key schema, OR
  2. Justify itself with a GSI TODO comment explaining the planned index and why it hasn't been added yet

  Existing scans with TODO comments are tracked tech debt — do not add new ones without justification.

  ### Copy text convention

  All user-facing text (notification titles, event descriptions, display labels, placeholders) MUST live in `*.messages.ts` files — not inline in logic files.

  <Note>
    Does NOT apply to: log messages, developer errors, schema keys, test fixtures.
  </Note>
</Accordion>

<Accordion title="Providers layer (event pipeline)">
  ### Architecture rules

  * **MatchEventBus** is the only entry point to the detection pipeline — nothing bypasses it
  * **Detectors** (Tier0EventDetector, Tier1EventDetector) classify events but do not decide whether to send them — that is the filters' job
  * **Filters** (CooldownFilter, StalenessFilter) suppress events but never generate them
  * **LACoordinator** is the only component that talks to ApnsDeliveryService — services never send pushes directly
  * **DisplayStateBuilder** builds UnifiedDisplayState — no other component constructs display payloads

  ### Event priority

  * Tier0 events (wickets, milestones, hat-tricks) always get `apns-priority: 10` (immediate delivery)
  * Tier1 events (score changes, over completions) always get `apns-priority: 5` (opportunistic delivery)
  * The pipeline short-circuits if no events are detected — no APNs call is made

  ### Cooldown conventions

  * Cooldown windows are stored per-event-type in the `active-matches` table `cooldowns` map
  * CooldownFilter reads and writes cooldown state on every tick
  * Cooldown expiry is event-type-specific — wickets have longer cooldowns than score ticks

  ### State diffing

  * The event detection pipeline diffs current data against `previousScore`, `previousBatting`, `previousBowling` stored in `active-matches`
  * If nothing changed, the pipeline exits early without building display state or calling APNs
  * This replaces the old `state-hashes` table approach
</Accordion>

<Accordion title="iOS Swift">
  ### Apple/SwiftUI best practices

  <Warning>
    **Never force unwrap (`!`)** — use `guard let`, `if let`, or nil coalescing.
  </Warning>

  * Prefer `.foregroundStyle()` over deprecated `.foregroundColor()`
  * Prefer `.task {}` over `.onAppear { Task {} }` for async work
  * Use `@ViewBuilder` for functions returning conditional views
  * Prefer `let` over `var` — mutability only when required
  * Flag `print()` statements — use `os.Logger` or remove before merge

  ### Access control

  * Use `private` for properties/methods not needed outside the type
  * **Exception:** a top-level type that is the sole primary declaration in its own file should be `internal` (default), never `private` — `private` at file scope makes it invisible to the rest of the target

  ### View rules

  * Views should be small and focused — extract sub-views for complex layouts
  * Avoid heavy computation in `body` — use computed properties or cache
  * No I/O operations in views (should be in Services)
  * Use `Color("colorName")` for asset catalog colors or Theme constants

  ### Model rules

  * Models are `Codable` structs that mirror backend JSON exactly
  * Conform to `Codable`, `Hashable`, and `Sendable`
  * Use `let` for all properties — models are immutable data containers
  * No business logic or computed display values in models
  * Both Sightscreen and SightscreenWidget targets share identical model files — flag any divergence

  ### Theme rules

  * Use `enum` with `static let` for namespaced constants
  * Flag hardcoded color/spacing values in other files that should use Theme constants

  ### Paging pattern

  Use `ScrollView(.horizontal)` + `.scrollTargetBehavior(.paging)` for paged views. Never use `TabView(.page)` — it has a known Apple bug with misaligned pages when content sizes differ. See [ADR-010](/system-map/adrs#adr-010).
</Accordion>

<Accordion title="iOS Services — DI pattern">
  <Warning>
    Every service MUST follow the Protocol + @Observable + @Environment pattern. The following are architectural violations:

    * A service defined as a base class for subclassing (use a protocol instead)
    * `ObservableObject` conformance or `@Published` properties (use `@Observable`)
    * `@EnvironmentObject` usage anywhere (use `@Environment(\.serviceKey)`)
    * `@StateObject` for services (use `@State` in App root, `@Environment` in views)
    * Views accepting a service via `init` parameter instead of reading from environment
  </Warning>

  ### Required shape for every service

  1. **Protocol:** `@MainActor protocol FooService: AnyObject, Observable` — the interface
  2. **Real implementation:** `@Observable @MainActor final class RealFooService: FooService` — production impl
  3. **Fake implementation:** `@Observable @MainActor final class FakeFooService: FooService` — preview/test stub
  4. **EnvironmentKey:** `defaultValue` set to `FakeFooService()` so previews work with zero boilerplate
  5. **EnvironmentValues extension:** exposing `var fooService: any FooService`

  ### File organization

  * Protocol file contains: protocol + `EnvironmentKey` + `EnvironmentValues` extension
  * Real impl lives in its own file (e.g., `CognitoAuthService.swift`)
  * Fake impl lives under `Services/Fake/` (e.g., `Fake/FakeAuthService.swift`)

  ### Consumption

  * Views consume via `@Environment(\.fooService) var fooService`
  * App root injects real impl via `.environment(\.fooService, realFooService)`
  * Previews get the fake default automatically — no explicit environment wiring needed
  * When a service holds a reference to another service, type it as `any FooService` (existential), never a concrete class

  ### Additional rules

  * Services handle networking, persistence, and system integrations
  * Services must NOT contain display formatting logic — that belongs on the backend
  * Use `async` functions, never blocking calls
  * Handle errors gracefully and propagate via `throws`
  * No SwiftUI imports in services (exception: `EnvironmentKey`/`EnvironmentValues` declarations and `ScenePhase` references)
</Accordion>

<Accordion title="Live Activity rules">
  ### ActivityAttributes vs ContentState

  <Warning>
    **`ActivityAttributes` holds STATIC data** (set once at request time, never changes). **`ContentState` holds DYNAMIC data** (updated via push). Putting mutable match data in `ActivityAttributes` instead of `ContentState` is a structural bug — it can never be updated after creation.
  </Warning>

  ### Push token lifecycle

  <Warning>
    **Pass `pushType: .token` to `Activity.request()`.** Omitting this locks the activity to local-only updates for its entire lifetime — this is irreversible.
  </Warning>

  * **Never read `.pushToken` synchronously** after creation — it will be `nil`. Must observe `activity.pushTokenUpdates` (AsyncSequence) with `for await` to get the initial token and handle system-initiated token rotations.
  * When the system rotates a push token, send the new token to the backend and invalidate the old one immediately.

  ### All 6 presentations are mandatory

  Every `ActivityConfiguration` MUST implement:

  1. `compactLeading` — must deep-link to the same destination as compact trailing
  2. `compactTrailing` — must deep-link to the same destination as compact leading
  3. `minimal` — must show live data (score, overs), never just a static logo
  4. `expanded` — full Dynamic Island expanded view
  5. **Lock Screen** closure
  6. **Watch/CarPlay** via `.supplementalActivityFamilies([.small])`

  <Warning>
    Without `.supplementalActivityFamilies([.small])`, Watch and CarPlay get an auto-generated low-quality fallback from compact leading + trailing.
  </Warning>

  ### Dynamic Island layout

  * Background is ALWAYS black and not customizable — use bold foreground colors for brand identity
  * Use `ContainerRelativeShape` for inner rounded rectangles (concentric corner radii)
  * Do not add padding between content and the TrueDepth camera in compact views
  * Use `.numericText` content transition for counting numbers (scores, overs)
  * Animation max duration is 2 seconds (system-enforced)

  ### Lock Screen layout

  * Use 14pt standard margins; no hardcoded fixed frames
  * Use `activityBackgroundTint(_:)` for custom background colors
  * Use `activitySystemActionForegroundColor(_:)` for the dismiss button — verify in both light and dark mode
  * Check `isLuminanceReduced` for Always-On displays — no animations play on Always-On displays

  ### StandBy mode

  * StandBy scales the Lock Screen presentation 2x — all image assets must be high-resolution enough
  * The system extends the background color to fill the entire screen in StandBy
  * Graphic elements drawn to edges will be clipped — use dividers or containing shapes at boundaries
  * Verify contrast under StandBy Night Mode (automatic red tint in low light)

  ### Watch and CarPlay

  * Use `@Environment(\.activityFamily)` and switch on `.small` to provide the Watch/CarPlay layout

  <Warning>
    **CarPlay DEACTIVATES all interactive elements** (Button/Toggle). Do NOT include interactive controls in `.small` activity family layouts.
  </Warning>

  * Apple Watch: use at most ONE interactive control — multiple controls are unusable at Smart Stack sizes
  * Tapping a Watch Live Activity navigates straight to the Watch app — no expanded view exists

  ### Lifecycle management

  * Live Activities can ONLY be requested when the app is in the foreground, after a discrete user action
  * Must handle all four activity states: `.active`, `.stale`, `.ended`, `.dismissed` via `activityStateUpdates`
  * When `.dismissed`, stop tracking and clean up — failing to observe dismissal leads to zombie state
  * Provide a meaningful final `ContentState` when calling `end()` — show the final match result
  * Set a custom `dismissalPolicy` when ending — 15-30 minutes after completion for cricket
  * Check `ActivityAuthorizationInfo.frequentPushesEnabled` once after activity start and send to the backend
  * System enforces a max 8-hour duration — implement restart logic for longer cricket matches
  * `NSSupportsLiveActivitiesFrequentUpdates` must be `YES` in Info.plist
  * Alert `title` and `body` display ONLY on a paired Apple Watch, not on iPhone

  ### Multi-activity behavior

  * When multiple Live Activities are active, the system shows the `minimal` presentation
  * Use `relevanceScore` to influence which activity the system prioritizes
</Accordion>

<Accordion title="APNs contract">
  ### Payload rules

  <Warning>
    **`content-state` payload must be under 4KB.** Exceeding this causes silent update failure on the device.
  </Warning>

  <Warning>
    **Backend MUST use camelCase keys** matching the Swift `ContentState` property names exactly. iOS decodes with the default `JSONDecoder` — snake\_case or any custom encoding causes silent decode failure with no error surfaced to the user.
  </Warning>

  ### Required headers and fields

  * `apns-push-type` must be `liveactivity`
  * `apns-topic` must be `<bundle-id>.push-type.liveactivity` (not the bare bundle ID)
  * Must use **token-based APNs connections** — certificate-based connections do not support the `liveactivity` push type
  * Every update payload MUST include `timestamp` (Unix epoch seconds), `event` (`"update"` or `"end"`), and `content-state`
  * If `timestamp` is older than the last processed update, the system silently discards it

  ### Priority

  * Default to **`apns-priority: 5`** (low — delivered opportunistically, no rate limit)
  * Use **`10`** (high — immediate delivery) only for genuinely time-sensitive moments like wickets or score changes
  * Over-using priority 10 exhausts the device budget and triggers throttling

  ### No double notifications

  <Warning>
    Do NOT send a regular push notification alongside a Live Activity alert update for the same event. The Live Activity alert already lights the screen and plays a sound — doubling up causes users to disable Live Activities entirely.
  </Warning>

  ### Ending via push

  * Set `event` to `"end"` and include `dismissal-date` (Unix epoch) to control how long the ended activity stays on the Lock Screen
  * Omitting `dismissal-date` lets the system decide (up to 4 hours) — for cricket, 15-30 minutes is appropriate

  ### Optional fields

  * `stale-date` (Unix epoch): if set, iOS marks the activity as stale after this time without a newer update. Only use if the widget checks `context.isStale`.
  * `relevance-score` (number): controls ordering when multiple activities are active. Set higher values for the match the user is most likely watching.
  * Push tokens are per-activity and rotate. When the iOS app sends a new token, invalidate the old one immediately in the backend token store.
</Accordion>

<Accordion title="Widget rules">
  ### File organization (one view per file)

  <Warning>
    Each SwiftUI view struct MUST live in its own file. Flag any file containing multiple top-level `struct ... : View` definitions — extract them.
  </Warning>

  * `SightscreenWidgetLiveActivity.swift` is the widget entry point ONLY — contains `Widget` conformance and `ActivityConfiguration` builder
  * All view structs it references live in separate files under `LiveActivity/`

  ### Directory layout

  ```
  SightscreenWidget/
    LiveActivity/
      LockScreen/          — Lock Screen view and sub-types
      DynamicIsland/       — one file per DI region view
      Shared/              — reusable helpers, shared view components
    Preview Content/       — sample data extensions for #Preview macros
  ```

  ### Styling

  * Use `WidgetColors` constants (e.g., `Color.ssAmber`, `Color.ssFooterGradient1`) instead of inline hex colors
  * Widget views receive ALL display data via `LiveActivityDisplayState` from APNs push updates — never compute or derive display values

  <Note>
    Live Activities cannot load network images — emoji flag maps are acceptable.
  </Note>

  ### Access control for extracted views

  * Top-level types in their own file MUST use `internal` access (Swift default — no keyword). Do NOT mark them `private`.
  * Properties and methods that are implementation details within a view should still be `private`.

  ### Preview conventions

  * Each view file SHOULD include its own `#Preview` macros at the bottom
  * Preview sample data lives in `Preview Content/LiveActivityPreviewData.swift`, NOT inline in view files
  * Sample data properties use `internal` access so all view files can reference them
</Accordion>

<Accordion title="Schema rules">
  * Schemas define the contract between backend and iOS
  * Any schema change requires a matching update in iOS models
  * Use **`.nullable()`** for optional API fields, **`.optional()`** for include-dependent fields
  * Add inline comments for non-obvious fields (units, ranges, format examples)

  ### Version safety net

  <Warning>
    If `MatchItemSchema`, `LeagueGroupSchema`, or `MatchesResponseSchema` fields change (added, removed, renamed, type changed), verify `MATCH_SCHEMA_VERSION` bump in `config.ts`.
  </Warning>

  <Note>
    If `isLiveStatus`, `isCompletedStatus`, `formatScoreDisplay`, `formatOversDisplay`, or `getLiveNote` return values change in `matchDisplay.ts`, also verify `MATCH_SCHEMA_VERSION` bump if the response shape is affected.
  </Note>
</Accordion>

<Accordion title="Copy and tone">
  The Sightscreen voice: **informed cricket fan, not commentator.**

  ### Tone

  * Write like a fan watching with mates, not a broadcaster performing
  * Understated and dry over loud and hype — "in a hurry" not "ON FIRE"
  * Let rare moments (hat-trick, century) earn their excitement; routine events stay calm
  * No adjective inflation: avoid "magnificent", "brilliant", "incredible", "explosive"
  * If the moment is big, the data says it — the copy doesn't need to

  ### Punctuation

  * Reserve `!` for genuinely rare events (hat-tricks, centuries, consecutive sixes)
  * Flag any line with two or more `!`
  * Use em dash `---` consistently as the separator between description and stats
  * No code-style formatting in user-facing strings (`` ` ``, `~`, etc.)

  ### Gender-neutral terminology

  * Use **"Player of the Match"** (abbreviated **POM**), never "Man of the Match" or "MOTM". This applies to all user-facing copy, notification text, and backend message templates.

  ### Brevity and clarity

  * Copy should be scannable at lock-screen glance speed
  * Avoid redundancy between title and description — don't repeat what's already obvious
  * No commentary-box cliches: "brings up", "reaches a milestone", "dispatches to the boundary"
  * Prefer real cricket vernacular over generic sports hype ("spell" not "on fire", "departs" not "is out")
</Accordion>

<Accordion title="Infrastructure scripts">
  ### Safety defaults

  <Warning>
    Scripts MUST be dry-run by default. Real changes require an explicit `--apply` flag. Accidental runs without `--apply` must be safe (no side effects).
  </Warning>

  <Warning>
    Every script MUST start with `set -euo pipefail`.
  </Warning>

  ### Required behavior

  * Must support `--sandbox` flag to target sandbox tables (prefix `cricket-sandbox-`) vs production (prefix `cricket-`)
  * Must check AWS credentials before any operations (`aws sts get-caller-identity`)
  * Must be idempotent — if a table already exists, skip creation gracefully
  * Use `aws dynamodb wait table-exists` after creating tables
  * Use `PAY_PER_REQUEST` billing mode (no capacity planning needed)
  * Print summary at the end with table names and required `.env` additions
  * File naming: `migrate-NNN-description.sh` (sequential numbering)

  ### GSI documentation

  When adding a GSI, document why it's needed in a comment. When using `ScanCommand` in the corresponding code, document why Scan is acceptable (e.g., small table size, infrequent access, cached result).
</Accordion>

<Accordion title="Architecture decisions">
  If a PR introduces a significant architectural change (new infrastructure pattern, protocol change, dependency strategy, data model redesign, etc.), it should be documented as an **Architecture Decision Record** in `docs/architecture/adr/`.

  If the change is urgent and an ADR is not feasible, at minimum create a GitHub issue documenting the decision and link it in the PR.

  See the [full ADR catalog](/system-map/adrs) for all recorded decisions.
</Accordion>
