Task S3T01: Create stats.country_daily_stats Table¶
Master-Spec Alignment¶
The normative contract for this sprint is 00-SPEC-DRAFT-v1.obsolete.md, Sections 5.3, 5.4, 8.4, 9.2, and 11.
stats.country_daily_stats.unique_usersandunique_gksare exact online-maintained values, not approximate placeholders.INSERT,UPDATE, andDELETEhandling forstats.gk_countries_visited,stats.user_countries, andstats.gk_country_historymust maintain exact state. When earliest/latest rows are invalidated, affected rows must be recomputed from remaining qualifying moves.- Snapshot functions seed and verify canonical state; they do not compensate for knowingly inexact live maintenance.
- Any lower text that still describes
unique_usersorunique_gksas approximate is obsolete and superseded by this alignment block.
Source¶
- Generated from sprint document step
3.1in03-sprint-3-country-geography.md.
Step 3.1: Create stats.country_daily_stats Table¶
What this step does: Creates the stats.country_daily_stats table that stores daily per-country aggregate statistics. This table is the primary source for country choropleth maps, country leaderboards, and country-level time series charts. The composite primary key (stats_date, country_code) supports efficient date-range queries partitioned by country.
Migration file name: 20260310300000_create_country_daily_stats.php
Full SQL DDL¶
CREATE TABLE stats.country_daily_stats (
stats_date DATE NOT NULL,
country_code CHAR(2) NOT NULL,
moves_count BIGINT NOT NULL DEFAULT 0,
drops BIGINT NOT NULL DEFAULT 0,
grabs BIGINT NOT NULL DEFAULT 0,
comments BIGINT NOT NULL DEFAULT 0,
sees BIGINT NOT NULL DEFAULT 0,
archives BIGINT NOT NULL DEFAULT 0,
dips BIGINT NOT NULL DEFAULT 0,
unique_users BIGINT NOT NULL DEFAULT 0,
unique_gks BIGINT NOT NULL DEFAULT 0,
km_contributed NUMERIC(14,3) NOT NULL DEFAULT 0,
points_contributed NUMERIC(16,4) NOT NULL DEFAULT 0,
loves_count BIGINT NOT NULL DEFAULT 0,
pictures_uploaded_total BIGINT NOT NULL DEFAULT 0,
pictures_uploaded_avatar BIGINT NOT NULL DEFAULT 0,
pictures_uploaded_move BIGINT NOT NULL DEFAULT 0,
pictures_uploaded_user BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (stats_date, country_code)
);
COMMENT ON TABLE stats.country_daily_stats IS 'Daily per-country aggregate statistics for moves, distance, users, GKs, and content';
COMMENT ON COLUMN stats.country_daily_stats.unique_users IS 'Exact distinct user count maintained online for the date/country bucket';
COMMENT ON COLUMN stats.country_daily_stats.unique_gks IS 'Exact distinct GK count maintained online for the date/country bucket';
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateCountryDailyStats extends AbstractMigration
{
public function up(): void
{
$this->execute(<<<'SQL'
CREATE TABLE stats.country_daily_stats (
stats_date DATE NOT NULL,
country_code CHAR(2) NOT NULL,
moves_count BIGINT NOT NULL DEFAULT 0,
drops BIGINT NOT NULL DEFAULT 0,
grabs BIGINT NOT NULL DEFAULT 0,
comments BIGINT NOT NULL DEFAULT 0,
sees BIGINT NOT NULL DEFAULT 0,
archives BIGINT NOT NULL DEFAULT 0,
dips BIGINT NOT NULL DEFAULT 0,
unique_users BIGINT NOT NULL DEFAULT 0,
unique_gks BIGINT NOT NULL DEFAULT 0,
km_contributed NUMERIC(14,3) NOT NULL DEFAULT 0,
points_contributed NUMERIC(16,4) NOT NULL DEFAULT 0,
loves_count BIGINT NOT NULL DEFAULT 0,
pictures_uploaded_total BIGINT NOT NULL DEFAULT 0,
pictures_uploaded_avatar BIGINT NOT NULL DEFAULT 0,
pictures_uploaded_move BIGINT NOT NULL DEFAULT 0,
pictures_uploaded_user BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (stats_date, country_code)
);
COMMENT ON TABLE stats.country_daily_stats IS 'Daily per-country aggregate statistics for moves, distance, users, GKs, and content';
COMMENT ON COLUMN stats.country_daily_stats.unique_users IS 'Exact distinct user count maintained online for the date/country bucket';
COMMENT ON COLUMN stats.country_daily_stats.unique_gks IS 'Exact distinct GK count maintained online for the date/country bucket';
SQL
);
}
public function down(): void
{
$this->execute('DROP TABLE IF EXISTS stats.country_daily_stats;');
}
}
SQL Usage Examples¶
-- Top 10 countries by total moves (all time)
SELECT country_code, SUM(moves_count) AS total_moves
FROM stats.country_daily_stats
GROUP BY country_code
ORDER BY total_moves DESC
LIMIT 10;
-- Daily move trend for Poland in 2025
SELECT stats_date, moves_count, km_contributed
FROM stats.country_daily_stats
WHERE country_code = 'PL'
AND stats_date BETWEEN '2025-01-01' AND '2025-12-31'
ORDER BY stats_date;
-- Country leaderboard by km for current month
SELECT country_code, SUM(km_contributed) AS total_km, SUM(moves_count) AS total_moves
FROM stats.country_daily_stats
WHERE stats_date >= date_trunc('month', CURRENT_DATE)
GROUP BY country_code
ORDER BY total_km DESC
LIMIT 20;
-- Move-type breakdown for Germany last 30 days
SELECT SUM(drops) AS drops, SUM(grabs) AS grabs, SUM(sees) AS sees,
SUM(dips) AS dips, SUM(comments) AS comments, SUM(archives) AS archives
FROM stats.country_daily_stats
WHERE country_code = 'DE'
AND stats_date >= CURRENT_DATE - INTERVAL '30 days';
Graph/Visualization Specification¶
Unlocked visualizations:
- Chart type: Choropleth world map — heatmap of
moves_countorkm_contributedby country - Data source:
SELECT country_code, SUM(moves_count) FROM stats.country_daily_stats GROUP BY country_code -
Color scale: Sequential green (D3
interpolateGreens) withscaleSequentialLog -
Chart type: Stacked area chart — daily moves by type per country
- X-axis:
stats_date - Y-axis:
drops,grabs,sees,dips,comments,archives - Filter: Country code selector
ASCII Sample (Top Countries by Moves):
PL |████████████████████████████████████████| 3.2M
DE |████████████████████ | 1.5M
CZ |██████████████ | 1.1M
FR |████████ | 0.6M
SK |██████ | 0.5M
TimescaleDB Assessment¶
RECOMMENDED (conditional). Rationale:
country_daily_statsis an append-heavy time-series table keyed by(stats_date, country_code). With ~250 active countries and 15+ years of data, this table will grow to ~1.3M rows.- TimescaleDB hypertable on
stats_datewould enable automatic chunk-based partitioning, transparent time-range pruning, and built-in retention policies. - However, at ~1.3M rows, standard PostgreSQL with appropriate indexes handles this volume efficiently. Hypertable conversion is beneficial only if the table grows significantly larger (>10M rows) or if automated retention/compression policies are needed.
- Recommendation: Use standard PostgreSQL table now. Revisit hypertable conversion if TimescaleDB is installed and row count exceeds 5M.
pgTAP Unit Tests¶
| Test ID | Test Name | Assertion |
|---|---|---|
| T-3.1.001 | country_daily_stats table exists | has_table('stats', 'country_daily_stats') |
| T-3.1.002 | PK is (stats_date, country_code) | col_is_pk('stats', 'country_daily_stats', ARRAY['stats_date', 'country_code']) |
| T-3.1.003 | stats_date column type is date | col_type_is('stats', 'country_daily_stats', 'stats_date', 'date') |
| T-3.1.004 | country_code column type is char(2) | col_type_is('stats', 'country_daily_stats', 'country_code', 'character(2)') |
| T-3.1.005 | moves_count default is 0 | col_default_is('stats', 'country_daily_stats', 'moves_count', '0') |
| T-3.1.006 | km_contributed type is numeric(14,3) | col_type_is('stats', 'country_daily_stats', 'km_contributed', 'numeric(14,3)') |
| T-3.1.007 | points_contributed type is numeric(16,4) | col_type_is('stats', 'country_daily_stats', 'points_contributed', 'numeric(16,4)') |
| T-3.1.008 | All 18 columns exist | SELECT COUNT(*) = 18 FROM information_schema.columns WHERE table_schema = 'stats' AND table_name = 'country_daily_stats' |
| T-3.1.009 | Insert and read-back succeeds | Insert ('2025-06-15', 'PL', 10, ...) and verify SELECT moves_count = 10 |
| T-3.1.010 | Duplicate PK raises error | Insert same (stats_date, country_code) twice — throws_ok |
Implementation Checklist¶
- 1. Create migration file
20260310300000_create_country_daily_stats.php - 2. Run
phinx migrate - 3. Verify table exists with 18 columns and correct types
- 4. Verify composite PK on
(stats_date, country_code) - 5. Test insert and read-back
- 6. Run pgTAP tests T-3.1.001 through T-3.1.010