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

01

Dashboard

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

Loading Flames data…

02

Ask the Flames data

Ask a question in plain English. Claude writes the SQL and your browser runs it. See the guardrails.

Loading Ask the data…

03

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.

04

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 1 production runs, newest first. Kept in manifest.json.

RunTriggerRows inDurationTests
Oct 7, 2026, 8:25 p.m.Local67,0780.6 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
–