Skip to content

Lab 01 · NHL play-by-play

Calgary Flames shot analysis

Every Flames shot attempt since 2021-22, including this season. Where they shoot from, and where goals come from.

Last full season2025-26, 77 points
34-39-9
Top goal scorer22 goals in 2025-26
Morgan Frost
Games reconciled to the official scoreevery game, every season
413

Computed by the pipeline on Oct 7, 2026. See how

Ask the Flames data

Claude writes the SQL, your browser runs it. Guardrails

Loading Ask the data…

01

Dashboard

Source: NHL public API, schedules and play-by-play. Regular season only.

Loading Flames data…

02

Why it matters

Anyone can generate a dashboard now. The value is knowing what to build, and what it’s for.

In a sales or marketing team

The same techniques, applied to revenue data.

  • Reconciling detail to the official total

    Attribution that ties back to booked revenue, so finance and marketing report one number.

  • A locked-down API proxy

    Ad, CRM and ticketing data pulled safely, with rate limits and only the endpoints you need.

  • Raw events modelled in SQL

    Messy app and ad-platform events turned into one consistent view of each customer.

Decisions and trade-offs

What I chose, why, and what it cost.

  1. Flatten JSON in Python, keep business rules in dbt

    Why
    Rules like power play and shot direction stay readable and tested in one place.
    Trade-off
    Two languages in the pipeline, with a clear line between them.
  2. Proxy exactly two NHL URL shapes

    Why
    The NHL API blocks browsers, and an open proxy would be abused.
    Trade-off
    A new endpoint needs a code change. That’s the point.
  3. Cache finished games forever

    Why
    A finished game rarely changes, so each one is fetched once.
    Trade-off
    A late correction by the NHL wouldn’t be picked up without clearing the cache.

03

Pipeline

Every number above comes from this pipeline: dbt models in raw, cleaned and business-ready layers, with tests and a contract. An Airflow DAG runs it weekly. You can run it in your browser too.

Last production run
2026-10-07
Trigger
Local run
Duration
0.6 s
Rows extracted
67,078
Tests
9 pass · 0 warn

Lineage

Click a node for its SQL, schema and row counts.

Checks the NHL for games newer than the snapshot. Changes stay in this tab.
  1. 1 Source

  2. 2 Bronze

  3. 3 Silver

  4. 4 Gold

  5. 5 Checks

  6. 6 Browser

stg_gamesdbt/models/flames/stg_games.sql

413 rows · built in 58 ms · last production run

Finished regular-season games, re-expressed from the Flames' side of the ice.

-- Finished regular-season games, re-expressed from the Flames' side of the ice.
select
    game_id,
    substr(season, 1, 4) || '-' || substr(season, 7, 2)                    as season,
    cast(game_date as date)                                                 as game_date,
    home_id = 20                                                    as home,
    case when home_id = 20 then away_abbrev else home_abbrev end    as opponent,
    case when home_id = 20 then home_score else away_score end      as goals_for,
    case when home_id = 20 then away_score else home_score end      as goals_against,
    coalesce(last_period_type, 'REG')                                       as decided_in
from {{ ref('raw_games') }}
where game_type = 2
  and game_state in ('OFF', 'FINAL')
qualify row_number() over (partition by game_id) = 1
ColumnTypeNull %
game_idBIGINT0
seasonVARCHAR0
game_dateDATE0
homeBOOLEAN0
opponentVARCHAR0
goals_forINTEGER0
goals_againstINTEGER0
decided_inVARCHAR0

Data quality tests

From the last production run. Warnings are real issues in the source, flagged rather than hidden.

  • ✓ passresult is W, L or OTLaccepted_values · flames_games0

    No ties, no unknowns. severity: error

    select count(*) as failures from (
    with all_values as (
    
        select
            result as value_field,
            count(*) as n_records
    
        from main.flames_games
        group by result
    
    )
    
    select *
    from all_values
    where value_field not in (
        'W','L','OTL'
    )
    ) dbt_test
  • ✓ passCoordinate flip points shots at the attacking netsingular · flames_shots0

    At least 95% of unblocked attempts should land in the attacking half. If the API changes its side convention, this catches it. severity: warn

    select count(*) as failures from (
    select avg((x > 0)::integer) as share_in_attacking_half
    from main.flames_shots
    where event <> 'blocked-shot'
    having avg((x > 0)::integer) < 0.95
    ) dbt_test
  • ✓ passPoints match the resultexpression_is_true · flames_games0

    Standings logic is applied consistently. severity: error

    select count(*) as failures from (
    select * from main.flames_games where not (points = case result when 'W' then 2 when 'OTL' then 1 else 0 end)
    ) dbt_test
  • ✓ passCoordinates are on the iceexpression_is_true · flames_shots0

    A rink is 200 by 85 feet. severity: error

    select count(*) as failures from (
    select * from main.flames_shots where not (x between -100 and 100 and y between -43 and 43)
    ) dbt_test
  • ✓ passShot-level goals reconcile to the final scoresingular · flames_shots0

    Counts goal events per game and compares them to the official score, allowing for the extra goal a shootout winner is credited. severity: error

    select count(*) as failures from (
    -- Returns games whose shot-level goals don't match the official score.
    with shot_goals as (
        select game_id,
               count(*) filter (where is_goal and team = 'CGY') as gf,
               count(*) filter (where is_goal and team = 'OPP') as ga
        from main.flames_shots
        group by 1
    )
    
    select g.game_id
    from main.flames_games g
    left join shot_goals s using (game_id)
    where g.goals_for - (g.decided_in = 'SO' and g.result = 'W')::integer <> coalesce(s.gf, 0)
       or g.goals_against - (g.decided_in = 'SO' and g.result <> 'W')::integer <> coalesce(s.ga, 0)
    ) dbt_test
  • ✓ passShooter name is presentnot_null · flames_shots0

    A few events arrive without a matching roster entry. severity: warn

    select count(*) as failures from (
    select shooter
    from main.flames_shots
    where shooter is null
    ) dbt_test
  • ✓ passEvery shot belongs to a known gamerelationships · flames_shots0

    Referential integrity between the two served tables. severity: error

    select count(*) as failures from (
    with child as (
        select game_id as from_field
        from main.flames_shots
        where game_id is not null
    ),
    
    parent as (
        select game_id as to_field
        from main.flames_games
    )
    
    select
        from_field
    
    from child
    left join parent
        on child.from_field = parent.to_field
    
    where parent.to_field is null
    ) dbt_test
  • ✓ passAt least 82 gamesrow_count_at_least · flames_games0

    Guards against a partial extract replacing full seasons. severity: error

    select count(*) as failures from (
    select count(*) as row_count from main.flames_games having count(*) < 82
    ) dbt_test
  • ✓ passgame_id is uniqueunique · flames_games0

    One row per game. severity: error

    select count(*) as failures from (
    select
        game_id as unique_field,
        count(*) as n_records
    
    from main.flames_games
    where game_id is not null
    group by game_id
    having count(*) > 1
    ) dbt_test

Run history

The last 6 production runs, newest first. Kept in manifest.json.

RunTriggerRows inDurationTests
Oct 7, 2026, 8:25 p.m.Local67,0780.6 s✓ 9 pass
Oct 7, 2026, 7:48 p.m.Local67,0780.7 s✓ 9 pass
Oct 7, 2026, 7:46 p.m.Local67,0780.7 s✓ 9 pass
Oct 7, 2026, 6:59 p.m.Local67,0780.6 s✓ 9 pass
Oct 7, 2026, 6:24 p.m.Local67,0783.8 s✓ 9 pass
Oct 7, 2026, 6:22 p.m.Local67,0784.0 s✓ 9 pass

Data contract

What consumers of this data can rely on. The tests above enforce every line of it.

Owner
Cody Chandler
Refresh
Weekly, Mondays at 10:00 UTC, via Airflow on GitHub Actions
Freshness SLA
8 days
Keys
flames_games: game_idflames_shots: game_id
Consumers
Lab dashboards, Ask the data (Claude-generated SQL)
Source
NHL public API · schedules and play-by-playData © NHL. Used here for non-commercial illustration.
  • Regular-season games only. Preseason and playoffs are excluded.
  • Shot coordinates are in feet, with the shooter always attacking x = 89.
  • Shot-level goals reconcile to the official final score for every game.
  • A failing error-level test blocks the refresh. The last good snapshot keeps serving.

Served files

Gold models written as Parquet and committed, so a deploy never depends on a third-party API.

Telemetry

This tab only. Nothing here leaves your browser.

Core Web Vitals

LCP
…
waiting
INP
…
waiting
CLS
…
waiting
FCP
…
waiting
TTFB
…
waiting

Measured in this browser with the web-vitals library. INP appears after you interact.

Query engine

Engine
Not started
Start-up
–
Data downloaded
–
Tables loaded
–
Queries run
0
Errors
0
p50 latency
–
p95 latency
–