Smartwatch Companion

4league ships a companion app for Wear OS and watchOS. Referees, team managers, and organizers can start matches, log goals, cards, fouls, and substitutions directly from the wrist — and fans can glance at a ranking-position widget to see where their followed teams sit across every competition they play in.

ℹ️
Companion-only, not standalone

The watch app is a companion to the 4league phone app — all authentication, API calls, and data writes flow through the phone. If the phone is out of Bluetooth range or the 4league app is force-quit, the watch will show a connecting/waiting state until it reconnects.

Overview

The smartwatch companion has two main capabilities:

  • Live match control — available to users with match-editing rights (organizers, referees, team managers). Start a match, add goals / cards / fouls / substitutions, and end the match — all from the watch.
  • Ranking-position widget — available to every user. Glanceable list of every competition your followed teams play in, showing current rank, points, and a daily ▲/▼ arrow vs. the previous day's snapshot.

The watch does not hold its own session. On launch it waits for the phone to push a SESSION_SYNC message containing the authenticated user's sessionId, userId, language, and secretCode. Until that message arrives the companion renders a waiting screen.

Platforms

Platform Tech stack Target Package / Bundle
Wear OS Kotlin, Jetpack Compose for Wear, Material3 for Wear, Wearable Data Layer API Wear OS 3+ (API 30+) com.fourleague.watch
watchOS Swift, SwiftUI, WatchConnectivity watchOS 9+ com.nativeuplab.fourleague.watchkitapp

Both apps use the same message contract (see Phone ↔ Watch Messages) and the same backend endpoint for rankings.

Pairing & Session Sync

Installing the 4league watch app from the Play Store (Wear OS) or App Store (watchOS) is enough — there is no separate login on the watch. The phone app detects a paired watch with the companion installed and pushes a session envelope via the Wearable Data Layer / WatchConnectivity.

1

Install the watch app

From the phone's companion store, or directly on the watch if the user browses for "4league" on a Wear OS device.

2

Open the 4league phone app

Log in on the phone as usual. When the phone detects a reachable watch, it sends a SESSION_SYNC message containing the current sessionId, userId, language, and a secretCode the watch echoes on every request.

3

Open the watch app

The waiting screen resolves as soon as the session envelope has been received. The main menu then offers Matches and Rankings.

Live Match Screens

When the signed-in user has match-editing rights (organizer, referee, or team manager) the watch exposes the full live-match flow:

Watch navigation
├── WaitingScreen — pre-session
├── MatchListScreen — upcoming/in-progress matches
│   └── LiveMatchScreen — running clock, score, team crests
│       ├── GoalEntryScreen — tap a side to add a goal
│       ├── ActionMenuScreen — long-press to open
│       ├── CardEntryScreen — yellow / red card
│       ├── FoulScreen
│       └── SubstitutionScreen
└── RankingsScreen — ranking-position widget (all users)
    └── RankingDetailScreen — Top 5 of the tapped competition

The match list is backed by GET /competition/watchMatches, which returns matches where the caller has editing rights and that either start within the next 10 minutes or are currently in progress — so a referee's watch never shows matches three days out when the user is standing by the pitch right now.

Ranking-Position Widget

The rankings widget is a glanceable tile for every competition that a followed team plays in — visible to every plan (Free, PRO, Enterprise). It's designed so a fan who follows three teams across five competitions can see all five positions in one scroll without opening the phone.

What the user sees

Each tile on the Rankings screen shows:

  • The organization name (if the competition belongs to one) as a small header.
  • The team name — large, bold.
  • The current rank (e.g. "#4") and points in that competition.
  • A delta arrow on the right side:
    • ▲ green — team moved up since the previous day's snapshot.
    • ▼ red — team moved down.
    • — grey — rank unchanged since yesterday.
    • · dim — no prior snapshot yet (first-run or newly published competition). Rendered as a no-arrow state on purpose; zero and null are different states.
ℹ️
Delta sign convention

The backend emits delta = priorRank − currentRank. Positive means the team climbed positions (e.g. 5 → 3 gives delta = +2 → green ▲). This matches the sign convention the rankingPositionChange push notification uses, so the arrow the user saw in the notification matches the arrow on the watch tile.

Stale-data hint

If the last successful fetch is more than 5 minutes old while the Rankings screen is open, a small yellow "Updated >5m ago" line appears above the list. The screen re-evaluates staleness every 30 seconds while visible — the ticker only runs after the first response, so it doesn't burn battery before the user has any data.

Empty state

If the user follows no teams, or every followed team's competition is private/unpublished, the screen shows "No rankings available" plus a manual Refresh button. Private competitions (visibility = 0) are filtered server-side even for competitions the caller owns, so a watch tile can never leak a private-league position to someone who shouldn't see it.

Rankings Screens

RankingsScreen — the tile list

Vertical ScalingLazyColumn of RankingTileCards, one per (team, competition) pair. The screen requests fresh data on navigation and on manual refresh; the list renders whatever the repository last observed and updates reactively when a new RANKINGS_RESPONSE arrives.

RankingDetailScreen — Top 5 of one competition

Tapping a tile opens a detail view that renders the top 5 rows of that competition's standings — the same ranking the user sees on the phone's standings screen. The followed team is highlighted with a green left-edge rule and bold text.

If the followed team is not in the top 5 (say they sit 9th), the screen shows the top 5, a ··· separator, and then one highlighted row for the followed team's current position. A helper match-by-(rank, teamName) fallback handles the case where the followed team id differs between a managed team and its competition-team mirror.

Scenario: Alex checks Saturday's movers

Alex follows two teams — his son's U14 club and his local Sunday-league team. Between matches he raises his wrist, sees the U14 side has moved up one place (▲ +1) and the Sunday team held steady (—). He taps the U14 tile and confirms the club is now 2nd with the leader just 1 point ahead.

Phone ↔ Watch Messages

All phone-watch traffic is JSON on the /fourleague message path. Messages are one-shot fire-and-forget; the watch never holds a socket. Each message carries a type field that the receiver switches on.

Message catalog

Direction Type Purpose
Phone → Watch SESSION_SYNC Auth handshake. Carries sessionId, userId, language, and a secretCode the watch replays on every request.
Watch → Phone REQUEST_MATCHES Ask the phone for the list of editable matches (in-progress or starting within 10 min).
Phone → Watch MATCHES_RESPONSE Response to REQUEST_MATCHES. Contains an array of Match objects.
Watch → Phone ACTION_REQUEST Start match, add goal, card, foul, substitution, or end match. Carries action, matchId, payload.
Phone → Watch ACTION_RESPONSE Result of the action. Echoes action, a success flag, and optionally the updated match.
Phone → Watch MATCH_UPDATE Push of the live match state (score, elapsed minutes, inProgress start time) while a match is running.
Watch → Phone REQUEST_RANKINGS Ask the phone to refetch the ranking tiles. No payload fields beyond type.
Phone → Watch RANKINGS_RESPONSE Array of RankingTile objects, one per followed (team, competition) pair.

Rankings round-trip

The rankings exchange is intentionally minimal:

Watch                                           Phone
user opens Rankings → requestRankings()
  ├────── { "type": "REQUEST_RANKINGS" }  →
  │                                               │
phone calls GET /competition/watch-rankings
  │                                               │
  ├────── ← { "type": "RANKINGS_RESPONSE", "tiles": […] }
RankingRepository.tiles StateFlow emits → UI rerender

Backend API

GET / POST /competition/watch-rankings

Aggregated watch-tile endpoint. Returns one RankingTile per (followed team, competition) pair. Both GET and POST are accepted — the watch companion uses the GET form for idempotent requests; dev tooling and tests prefer the POST body form to avoid URL-length limits on users who follow many teams.

Request

Location Name Type Description
Query teamIds string (repeatable) The followed team ids. Pass once per id, e.g. ?teamIds=abc&teamIds=def.
JSON body teamIds string[] Alternative to the query form. Duplicates are de-duped server-side.

The endpoint accepts managed team ids (the top-level team the user follows) or competition-team ids (the per-competition mirror). Both resolve to the same tile.

Response

{
  "tiles": [
    {
      "rankingId": "65f0c3…",          // = competitionId. Stable per (team, competition) tile.
      "orgId":     "65ef91…" | null,   // parent organization id.
      "orgName":   "Sunday League" | null,
      "orgLogo":   "https://…" | null,
      "teamId":    "65f0b7…",          // caller's followed team id, echoed back.
      "teamName":  "FC Example" | null,
      "teamCrest": "https://…" | null, // team.logo
      "rank":      4,                    // 1-based (1 = leader).
      "points":    27,
      "delta":     2 | null,             // priorRank - currentRank. +ve = moved up. null = no prior snapshot.
      "top5": [                          // top 5 rows of the same standings (oldest-rank-first).
{ "rank": 1, "teamId": "…", "teamName": "…", "points": 34 },
{ "rank": 2, "teamId": "…", "teamName": "…", "points": 30 },
{ "rank": 3, "teamId": "…", "teamName": "…", "points": 28 },
{ "rank": 4, "teamId": "…", "teamName": "…", "points": 27 },
{ "rank": 5, "teamId": "…", "teamName": "…", "points": 25 }
      ]
    }
  ]
}

RankingTile field reference

Field Type Notes
rankingId string Equal to the competition id. Stable per tile — the watch uses it as the React-key equivalent when diffing the list.
orgId / orgName / orgLogo string | null Parent organization (competition.parent). Null for orphan competitions not owned by an org.
teamId string The followed team id the caller passed in, echoed back so the watch doesn't need to re-join against its followed list.
teamName / teamCrest string | null From the team document. teamCrest is team.logo.
rank number 1-based position in the full competition standings (1 = leader). Derived from the same stats pipeline GET /competition/getStandings uses, so the tile's rank always matches the number the user sees on the standings screen.
points number Current competition points.
delta number | null priorSnapshotRank − currentRank. Positive means the team moved up. Null (not 0!) when no prior snapshot exists — typical for first-run or freshly published competitions. The watch renders null as a no-arrow state.
top5 array Top 5 rows of the same standings, oldest-rank-first. Each row has { rank, teamId, teamName, points }. Empty when the competition has fewer than 5 ranked teams.

Visibility & publish gating

A tile is only emitted when both conditions hold on the competition:

  • visibility !== 0 — the internal migration collapses private, demo, hidden, and inactive competitions into visibility = 0; this filter gates the whole public surface.
  • onboardingDone === true — the competition is published (onboarding has finished). Half-configured drafts are skipped because their standings would be empty or misleading.
⚠️
Private-league tiles never leak

Private competitions (visibility === 0) are filtered server-side regardless of the caller's ownership or role. A watch tile surfacing a private-league position to a user who isn't inside that league would be a visibility leak, so the endpoint strips them before the response ever leaves the backend.

ℹ️
Not gated behind PRO

The widget is available on every plan — Free, PRO, and Enterprise. No subscription gate is applied to /competition/watch-rankings.

Daily Snapshot Cron

The delta field on each tile is computed against a daily snapshot of every published competition's standings. A dedicated cron helpers/competitionSnapshotJob.js runs once per 24h and writes one document per competition per day to the rankSnapshots collection.

Property Value
Schedule 15 3 * * * UTC — daily at 03:15 UTC.
Timezone UTC (canonical date key).
Cluster behaviour Registered once from app.js under the same !cluster.isWorker guard as the organization-level snapshotJob.js, so exactly one process runs the job even under a multi-worker Node.js cluster.
Offset 15 min after snapshotJob.js (which runs at 03:00 UTC) so the two daily crons don't contend for Mongo or overlap Sentry breadcrumb trails.
Opt-out DISABLE_COMPETITION_SNAPSHOT_CRON=1 environment variable skips registration (for local dev against a dev DB).
Output collection rankSnapshots with unique compound index (competitionId, date).

Why daily and not match-driven

A match-driven snapshot would re-fire every time a match finishes, and on a busy Saturday the widget's arrow would flip sign multiple times as each result landed. One canonical pass per 24 hours gives every tile a stable "since yesterday" delta that compares against one reference point rather than an arbitrary mid-day checkpoint. Users far from UTC may perceive their "yesterday" shifted by one local day — acceptable, and matches the existing RankingSnapshots behavior.

Snapshot document shape

// collection: rankSnapshots
{
  _id:            ObjectId,
  competitionId:  "65f0c3...",           // stringified competition id
  date:           "YYYY-MM-DD",          // UTC midnight canonical date key (no time component)
  entries:        {                      // whole-table map keyed on competition-team _id
    "65f0b7...": { rank: 1, points: 34 },
    "65f0b8...": { rank: 2, points: 30 },
    ...
  },
  teamCount:      20,                    // convenience for admin queries
  snapshotRunId:  "competition-cron:2026-04-21T03:15:00.000Z"   // groups a single cron invocation
}

A whole-table map per competition (rather than one row per team) keeps the cron's write rate low — a 12-team league is one upsert, not twelve — and matches the shape the read path needs anyway, because the endpoint's top5 block reads the same snapshot.

Where the delta comes from

For each followed (team, competition) tile the endpoint runs RankSnapshots.findMostRecentPrior(competitionId, todayKey) — i.e. the most recent snapshot strictly before today's UTC date. If that snapshot contains an entry for the team, delta = priorEntry.rank − currentRank. If it doesn't — first-run, team joined today, or the competition was published in the last 24 hours — delta is null, and the watch renders a no-arrow state.

Retention

Snapshots are retained indefinitely for MVP. A 20-team competition is roughly 1 KB on disk, so a year of daily rows across a typical tenant is measured in megabytes. A retention job (TTL index on date, or cold archival) can be added later if collection growth becomes a concern.

Complementing the glanceable widget is an on-demand push notification (rankingPositionChange) that fires when a followed team's position moves relative to the organization-level ranking system. Tapping the notification deep-links into the phone app's RankingTable screen for the relevant organization.

Because the notification's positionChange uses the same positive-means-moved-up convention as the watch tile's delta, the ▲/▼ the user saw in the push matches the ▲/▼ they see when they raise their wrist a moment later.

💡
Organizer tip

Notification thresholds (how many positions a team must move before a push is fired) are configurable per organization from the organization profile — see that page's admin settings for the audit-logged threshold control.