Compare commits
4 Commits
v202603010
...
v202603011
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f2ffd432b | ||
|
|
c9dec066f7 | ||
|
|
fea4f85da3 | ||
|
|
2590020014 |
@@ -32,10 +32,6 @@ LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:pAqSkoJzsw==,iv:5J1Js7JPH/j1oTmEBdNXjwd
|
|||||||
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:e89yGzousImmdO7WVqmRWLJNejDFH5eTaw7G74CyZSw=,iv:bR1jgqSzJlxPA8LMMg2Mc1Lnp01iZgaqa9dgAoV0RpY=,tag:m92xzCP0qaP2onK7ChwA1Q==,type:str]
|
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:e89yGzousImmdO7WVqmRWLJNejDFH5eTaw7G74CyZSw=,iv:bR1jgqSzJlxPA8LMMg2Mc1Lnp01iZgaqa9dgAoV0RpY=,tag:m92xzCP0qaP2onK7ChwA1Q==,type:str]
|
||||||
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:yzXeb8c/Y0d+EluY7g6buo4BnFvBDEVblOi7doNgOp3siLvfMmPkjdRLqZzA14ET6CW5vef9i51yijPYwuhnbw==,iv:IYQRZ8SsquUQpsHH3X/iovz2wFskR4iHyvr0arY7Ag4=,tag:9G5lpHloacjQbEhSk9T2pw==,type:str]
|
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:yzXeb8c/Y0d+EluY7g6buo4BnFvBDEVblOi7doNgOp3siLvfMmPkjdRLqZzA14ET6CW5vef9i51yijPYwuhnbw==,iv:IYQRZ8SsquUQpsHH3X/iovz2wFskR4iHyvr0arY7Ag4=,tag:9G5lpHloacjQbEhSk9T2pw==,type:str]
|
||||||
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:qqDLfsPeiWOfwtgpZeItypnYNmIOD07fV0IPlZfphhUFeY0Z/BRpkVXA7nfqQ2M6PmcYKVIlBiBY,iv:hsEBxxv1+fvUY4v3nhBP8puKlu216eAGZDUNBAjibas=,tag:MvnsJ8W3oSrv4ZrWW/p+dg==,type:str]
|
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:qqDLfsPeiWOfwtgpZeItypnYNmIOD07fV0IPlZfphhUFeY0Z/BRpkVXA7nfqQ2M6PmcYKVIlBiBY,iv:hsEBxxv1+fvUY4v3nhBP8puKlu216eAGZDUNBAjibas=,tag:MvnsJ8W3oSrv4ZrWW/p+dg==,type:str]
|
||||||
#ENC[AES256_GCM,data:YGV2exKdGOUkblNZZos=,iv:NuabFM/gNHIzYmDMRZ2tglFYdMPVFuHFGd+AAWvvu6Q=,tag:gZRoNNEmjL9v3nC8j9YkHw==,type:comment]
|
|
||||||
DUCKDB_PATH=ENC[AES256_GCM,data:GgOEQ5B1KeQrVavhoMU/JGXcVu3H,iv:XY8JiaosxaUDv5PwizrZFWuNKMSOeuE3cfVyp51r++8=,tag:RnoDE5+7WQolFLejfRZ//w==,type:str]
|
|
||||||
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:U2X9KmlgnWXM9uCfhHCJ03HMGCLm,iv:KHHdBTq+ct4AG7Jt4zLog/5jbDC7LvHA6KzWNTDS/Yw=,tag:m5uIG/bS4vaBooSYoYa6SA==,type:str]
|
|
||||||
LANDING_DIR=ENC[AES256_GCM,data:NkEmV8LOwEiN9Sal,iv:mQHBVT6lNoEEEVbl7a5bNN5qoF/LvTyWXQvvkv/z/B0=,tag:IgA5A1nfF91fOBdYxEN71g==,type:str]
|
|
||||||
#ENC[AES256_GCM,data:jvZYm7ceM4jtNRg=,iv:nuv65SDTZiaVukVZ40seBZevpqP8uiKCgJyQcIrY524=,tag:cq6gB3vmJzJWIXCLHaIc9g==,type:comment]
|
#ENC[AES256_GCM,data:jvZYm7ceM4jtNRg=,iv:nuv65SDTZiaVukVZ40seBZevpqP8uiKCgJyQcIrY524=,tag:cq6gB3vmJzJWIXCLHaIc9g==,type:comment]
|
||||||
REPO_DIR=ENC[AES256_GCM,data:ae8i6PpGFaiYFA/gGIhczg==,iv:nmsIRMPJYocIO6Z2Gz4OIzAOvSpdgDYmUaIr2hInFo0=,tag:EmAYG5NujnHg8lPaO/uAnQ==,type:str]
|
REPO_DIR=ENC[AES256_GCM,data:ae8i6PpGFaiYFA/gGIhczg==,iv:nmsIRMPJYocIO6Z2Gz4OIzAOvSpdgDYmUaIr2hInFo0=,tag:EmAYG5NujnHg8lPaO/uAnQ==,type:str]
|
||||||
WORKFLOWS_PATH=ENC[AES256_GCM,data:sGU4l68Pbb1thsPyG104mWXWD+zJGTIcR/TqVbPmew==,iv:+xhGkX+ep4kFEAU65ELdDrfjrl/WyuaOi35JI3OB/zM=,tag:brauZhFq8twPXmvhZKjhDQ==,type:str]
|
WORKFLOWS_PATH=ENC[AES256_GCM,data:sGU4l68Pbb1thsPyG104mWXWD+zJGTIcR/TqVbPmew==,iv:+xhGkX+ep4kFEAU65ELdDrfjrl/WyuaOi35JI3OB/zM=,tag:brauZhFq8twPXmvhZKjhDQ==,type:str]
|
||||||
@@ -62,7 +58,7 @@ sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb2
|
|||||||
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
||||||
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
|
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
||||||
sops_lastmodified=2026-02-28T17:03:44Z
|
sops_lastmodified=2026-03-01T00:26:54Z
|
||||||
sops_mac=ENC[AES256_GCM,data:IQ9jpRxVUssaMK+qFcM3nPdzXHkiqp6E+DhEey1TfqUu5GCBNsWeVy9m9A6p9RWhu2NtJV7aKdUeqneuMtD1q5Tnm6L96zuyot2ESnx2N2ssD9ilrDauQxoBJcrJVnGV61CgaCz9458w8BuVUZydn3MoHeRaU7bOBBzQlTI6vZk=,iv:qHqdt3av/KZRQHr/OS/9KdAJUgKlKEDgan7qI3Zzkck=,tag:fOvdO9iRTTF1Siobu2mLqg==,type:str]
|
sops_mac=ENC[AES256_GCM,data:DdcABGVm9KbAcFrF0iuZlAaugsouNs7Hon2mZISaHs15/2H/Pd9FniXW3KeQ0+/NdZFQkz/h3i3bVFampcpFS1AxuOE5+1/IgWn8sKtaqPc7E9y8g6lxMnwTkUX2z+n/Q2nR8KAcO9IyE0GNjIluMWkxPWQuLzlRYDOjRN4/1e0=,iv:rm+6lXhYu6VUmrdCIrU0BRN2/ooa21Fw1ESWxr7vATg=,tag:GZmLLZf/LQaNeNNAAEg5bA==,type:str]
|
||||||
sops_unencrypted_suffix=_unencrypted
|
sops_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
sops_version=3.12.1
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
- 15 new tests in `web/tests/test_affiliate.py` (41 total)
|
- 15 new tests in `web/tests/test_affiliate.py` (41 total)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Data Platform admin view showing stale/zero row counts** — Docker web containers were mounting `/opt/padelnomics/data` (stale copy) instead of `/data/padelnomics` (live supervisor output). Fixed volume mount in all 6 containers (blue/green × app/worker/scheduler) and added `LANDING_DIR=/app/data/pipeline/landing` so extraction stats and landing zone file stats are visible to the web app.
|
||||||
|
- **`workflows.toml` never found in dev** — `_REPO_ROOT` in `pipeline_routes.py` used `parents[5]` (one level too far up) instead of `parents[4]`. Workflow schedules now display correctly on the pipeline overview tab in dev.
|
||||||
- **Article preview frontmatter bug** — `_rebuild_article()` in `admin/routes.py` now strips YAML frontmatter before passing markdown to `mistune.html()`, preventing raw `title:`, `slug:` etc. from appearing as visible text in article previews.
|
- **Article preview frontmatter bug** — `_rebuild_article()` in `admin/routes.py` now strips YAML frontmatter before passing markdown to `mistune.html()`, preventing raw `title:`, `slug:` etc. from appearing as visible text in article previews.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -60,9 +60,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -82,9 +83,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
|
|
||||||
@@ -98,9 +100,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
|
|
||||||
@@ -115,9 +118,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -137,9 +141,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
|
|
||||||
@@ -153,9 +158,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
-- 4. Country-level income (global fallback from stg_income / ilc_di03)
|
-- 4. Country-level income (global fallback from stg_income / ilc_di03)
|
||||||
--
|
--
|
||||||
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
|
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
|
||||||
-- A bounding-box pre-filter (~0.5°, ≈55km) reduces the cross-join before the
|
-- Spatial joins use BETWEEN predicates (not ABS()) to enable DuckDB's IEJoin
|
||||||
-- exact sphere distance is computed.
|
-- (interval join) optimization: O((N+M) log M) vs O(N×M) nested-loop.
|
||||||
|
-- Country pre-filters restrict the left side to ~20K rows for padel/tennis CTEs
|
||||||
|
-- (~8 countries each), down from ~140K global locations.
|
||||||
|
|
||||||
MODEL (
|
MODEL (
|
||||||
name foundation.dim_locations,
|
name foundation.dim_locations,
|
||||||
@@ -147,6 +149,8 @@ padel_courts AS (
|
|||||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||||
),
|
),
|
||||||
-- Nearest padel court distance per location (bbox pre-filter → exact sphere distance)
|
-- Nearest padel court distance per location (bbox pre-filter → exact sphere distance)
|
||||||
|
-- BETWEEN enables DuckDB IEJoin (O((N+M) log M)) vs ABS() nested-loop (O(N×M)).
|
||||||
|
-- Country pre-filter reduces left side from ~140K to ~20K rows (padel is ~8 countries).
|
||||||
nearest_padel AS (
|
nearest_padel AS (
|
||||||
SELECT
|
SELECT
|
||||||
l.geoname_id,
|
l.geoname_id,
|
||||||
@@ -158,9 +162,12 @@ nearest_padel AS (
|
|||||||
) AS nearest_padel_court_km
|
) AS nearest_padel_court_km
|
||||||
FROM locations l
|
FROM locations l
|
||||||
JOIN padel_courts p
|
JOIN padel_courts p
|
||||||
-- ~55km bounding box pre-filter to limit cross-join before sphere calc
|
-- ~55km bounding box pre-filter; BETWEEN triggers IEJoin optimization
|
||||||
ON ABS(l.lat - p.lat) < 0.5
|
ON l.lat BETWEEN p.lat - 0.5 AND p.lat + 0.5
|
||||||
AND ABS(l.lon - p.lon) < 0.5
|
AND l.lon BETWEEN p.lon - 0.5 AND p.lon + 0.5
|
||||||
|
WHERE l.country_code IN (
|
||||||
|
SELECT DISTINCT country_code FROM padel_courts WHERE country_code IS NOT NULL
|
||||||
|
)
|
||||||
GROUP BY l.geoname_id
|
GROUP BY l.geoname_id
|
||||||
),
|
),
|
||||||
-- Padel venues within 5km of each location (counts as "local padel supply")
|
-- Padel venues within 5km of each location (counts as "local padel supply")
|
||||||
@@ -170,24 +177,35 @@ padel_local AS (
|
|||||||
COUNT(*) AS padel_venue_count
|
COUNT(*) AS padel_venue_count
|
||||||
FROM locations l
|
FROM locations l
|
||||||
JOIN padel_courts p
|
JOIN padel_courts p
|
||||||
ON ABS(l.lat - p.lat) < 0.05 -- ~5km bbox pre-filter
|
-- ~5km bbox pre-filter; BETWEEN triggers IEJoin optimization
|
||||||
AND ABS(l.lon - p.lon) < 0.05
|
ON l.lat BETWEEN p.lat - 0.05 AND p.lat + 0.05
|
||||||
WHERE ST_Distance_Sphere(
|
AND l.lon BETWEEN p.lon - 0.05 AND p.lon + 0.05
|
||||||
|
WHERE l.country_code IN (
|
||||||
|
SELECT DISTINCT country_code FROM padel_courts WHERE country_code IS NOT NULL
|
||||||
|
)
|
||||||
|
AND ST_Distance_Sphere(
|
||||||
ST_Point(l.lon, l.lat),
|
ST_Point(l.lon, l.lat),
|
||||||
ST_Point(p.lon, p.lat)
|
ST_Point(p.lon, p.lat)
|
||||||
) / 1000.0 <= 5.0
|
) / 1000.0 <= 5.0
|
||||||
GROUP BY l.geoname_id
|
GROUP BY l.geoname_id
|
||||||
),
|
),
|
||||||
-- Tennis courts within 25km of each location (sports culture proxy)
|
-- Tennis courts within 25km of each location (sports culture proxy)
|
||||||
|
-- Country pre-filter reduces left side from ~140K to ~20K rows (tennis courts are European only).
|
||||||
tennis_nearby AS (
|
tennis_nearby AS (
|
||||||
SELECT
|
SELECT
|
||||||
l.geoname_id,
|
l.geoname_id,
|
||||||
COUNT(*) AS tennis_courts_within_25km
|
COUNT(*) AS tennis_courts_within_25km
|
||||||
FROM locations l
|
FROM locations l
|
||||||
JOIN staging.stg_tennis_courts t
|
JOIN staging.stg_tennis_courts t
|
||||||
ON ABS(l.lat - t.lat) < 0.23 -- ~25km bbox pre-filter
|
-- ~25km bbox pre-filter; BETWEEN triggers IEJoin optimization
|
||||||
AND ABS(l.lon - t.lon) < 0.23
|
ON l.lat BETWEEN t.lat - 0.23 AND t.lat + 0.23
|
||||||
WHERE ST_Distance_Sphere(
|
AND l.lon BETWEEN t.lon - 0.23 AND t.lon + 0.23
|
||||||
|
WHERE l.country_code IN (
|
||||||
|
SELECT DISTINCT country_code
|
||||||
|
FROM staging.stg_tennis_courts
|
||||||
|
WHERE country_code IS NOT NULL
|
||||||
|
)
|
||||||
|
AND ST_Distance_Sphere(
|
||||||
ST_Point(l.lon, l.lat),
|
ST_Point(l.lon, l.lat),
|
||||||
ST_Point(t.lon, t.lat)
|
ST_Point(t.lon, t.lat)
|
||||||
) / 1000.0 <= 25.0
|
) / 1000.0 <= 25.0
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ _LANDING_DIR = os.environ.get("LANDING_DIR", "data/landing")
|
|||||||
_SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
|
_SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
|
||||||
|
|
||||||
# Repo root: web/src/padelnomics/admin/ → up 4 levels
|
# Repo root: web/src/padelnomics/admin/ → up 4 levels
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[5]
|
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||||
_WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml"
|
_WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml"
|
||||||
|
|
||||||
# A "running" row older than this is considered stale/crashed.
|
# A "running" row older than this is considered stale/crashed.
|
||||||
|
|||||||
@@ -3037,6 +3037,7 @@ async def outreach():
|
|||||||
current_search=search,
|
current_search=search,
|
||||||
current_follow_up=follow_up,
|
current_follow_up=follow_up,
|
||||||
page=page,
|
page=page,
|
||||||
|
outreach_email=EMAIL_ADDRESSES["outreach"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,10 @@
|
|||||||
.admin-subnav {
|
.admin-subnav {
|
||||||
display: flex; align-items: stretch; padding: 0 2rem;
|
display: flex; align-items: stretch; padding: 0 2rem;
|
||||||
background: #fff; border-bottom: 1px solid #E2E8F0;
|
background: #fff; border-bottom: 1px solid #E2E8F0;
|
||||||
flex-shrink: 0; overflow-x: auto; gap: 0;
|
flex-shrink: 0; overflow-x: auto; overflow-y: hidden; gap: 0;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
.admin-subnav::-webkit-scrollbar { display: none; }
|
||||||
.admin-subnav a {
|
.admin-subnav a {
|
||||||
display: flex; align-items: center; gap: 5px;
|
display: flex; align-items: center; gap: 5px;
|
||||||
padding: 0 1px; margin: 0 13px 0 0; height: 42px;
|
padding: 0 1px; margin: 0 13px 0 0; height: 42px;
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
|
|
||||||
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.funnel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.funnel-grid { grid-template-columns: repeat(5, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<header class="flex justify-between items-center mb-8">
|
<header class="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -47,7 +60,7 @@
|
|||||||
|
|
||||||
<!-- Lead Funnel -->
|
<!-- Lead Funnel -->
|
||||||
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Lead Funnel</p>
|
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Lead Funnel</p>
|
||||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
|
<div class="funnel-grid mb-8">
|
||||||
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
|
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
|
||||||
<p class="text-xs text-slate">Planner Users</p>
|
<p class="text-xs text-slate">Planner Users</p>
|
||||||
<p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p>
|
<p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p>
|
||||||
@@ -72,7 +85,7 @@
|
|||||||
|
|
||||||
<!-- Supplier Stats -->
|
<!-- Supplier Stats -->
|
||||||
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Supplier Funnel</p>
|
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Supplier Funnel</p>
|
||||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
|
<div class="funnel-grid mb-8">
|
||||||
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
|
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
|
||||||
<p class="text-xs text-slate">Claimed Suppliers</p>
|
<p class="text-xs text-slate">Claimed Suppliers</p>
|
||||||
<p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p>
|
<p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p>
|
||||||
|
|||||||
@@ -2,13 +2,30 @@
|
|||||||
{% set admin_page = "outreach" %}
|
{% set admin_page = "outreach" %}
|
||||||
{% block title %}Outreach Pipeline - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Outreach Pipeline - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.pipeline-status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.pipeline-status-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.pipeline-status-grid { grid-template-columns: repeat(6, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<header class="flex justify-between items-center mb-6">
|
<header class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl">Outreach</h1>
|
<h1 class="text-2xl">Outreach</h1>
|
||||||
<p class="text-sm text-slate mt-1">
|
<p class="text-sm text-slate mt-1">
|
||||||
{{ pipeline.total }} supplier{{ 's' if pipeline.total != 1 else '' }} in pipeline
|
{{ pipeline.total }} supplier{{ 's' if pipeline.total != 1 else '' }} in pipeline
|
||||||
· Sending domain: <span class="mono text-xs">hello.padelnomics.io</span>
|
· Sending from: <span class="mono text-xs">{{ outreach_email }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -18,7 +35,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Pipeline cards -->
|
<!-- Pipeline cards -->
|
||||||
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem;margin-bottom:1.5rem">
|
<div class="pipeline-status-grid">
|
||||||
{% set status_colors = {
|
{% set status_colors = {
|
||||||
'prospect': '#E2E8F0',
|
'prospect': '#E2E8F0',
|
||||||
'contacted': '#DBEAFE',
|
'contacted': '#DBEAFE',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% if emails %}
|
{% if emails %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card text-center" style="padding:2rem">
|
<div class="card text-center" style="padding:2rem">
|
||||||
<p class="text-slate">No emails match the current filters.</p>
|
<p class="text-slate">No emails match the current filters.</p>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
{% if leads %}
|
{% if leads %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if total > per_page %}
|
{% if total > per_page %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% if suppliers %}
|
{% if suppliers %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card text-center" style="padding:2rem">
|
<div class="card text-center" style="padding:2rem">
|
||||||
<p class="text-slate">No suppliers match the current filters.</p>
|
<p class="text-slate">No suppliers match the current filters.</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% if suppliers %}
|
{% if suppliers %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card text-center" style="padding:2rem">
|
<div class="card text-center" style="padding:2rem">
|
||||||
<p class="text-slate">No suppliers match the current filters.</p>
|
<p class="text-slate">No suppliers match the current filters.</p>
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
|
|
||||||
{% block admin_head %}
|
{% block admin_head %}
|
||||||
<style>
|
<style>
|
||||||
|
.pipeline-stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.pipeline-stat-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
.pipeline-tabs {
|
.pipeline-tabs {
|
||||||
display: flex; gap: 0; border-bottom: 2px solid #E2E8F0; margin-bottom: 1.5rem;
|
display: flex; gap: 0; border-bottom: 2px solid #E2E8F0; margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -46,7 +55,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Health stat cards -->
|
<!-- Health stat cards -->
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.75rem" class="mb-6">
|
<div class="pipeline-stat-grid mb-6">
|
||||||
<div class="card text-center" style="padding:0.875rem">
|
<div class="card text-center" style="padding:0.875rem">
|
||||||
<p class="text-xs text-slate">Total Runs</p>
|
<p class="text-xs text-slate">Total Runs</p>
|
||||||
<p class="text-2xl font-bold text-navy metric">{{ summary.total | default(0) }}</p>
|
<p class="text-2xl font-bold text-navy metric">{{ summary.total | default(0) }}</p>
|
||||||
|
|||||||
@@ -218,9 +218,7 @@
|
|||||||
.nav-bar[data-navopen="true"] .nav-mobile {
|
.nav-bar[data-navopen="true"] .nav-mobile {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
.nav-mobile a,
|
.nav-mobile a:not(.nav-auth-btn) {
|
||||||
.nav-mobile button.nav-auth-btn,
|
|
||||||
.nav-mobile a.nav-auth-btn {
|
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.625rem 0;
|
padding: 0.625rem 0;
|
||||||
border-bottom: 1px solid #F1F5F9;
|
border-bottom: 1px solid #F1F5F9;
|
||||||
@@ -230,15 +228,18 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
.nav-mobile a:last-child { border-bottom: none; }
|
.nav-mobile a:not(.nav-auth-btn):last-child { border-bottom: none; }
|
||||||
.nav-mobile a:hover { color: #1D4ED8; }
|
.nav-mobile a:not(.nav-auth-btn):hover { color: #1D4ED8; }
|
||||||
|
/* nav-auth-btn in mobile menu: override block style, keep button colours */
|
||||||
.nav-mobile a.nav-auth-btn,
|
.nav-mobile a.nav-auth-btn,
|
||||||
.nav-mobile button.nav-auth-btn {
|
.nav-mobile button.nav-auth-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
padding: 6px 16px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
width: auto;
|
width: auto;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
.nav-mobile .nav-mobile-section {
|
.nav-mobile .nav-mobile-section {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user