Mini-challenge M03 — top 3 closer mỗi tháng + completion rate theo tuần
Combine M03 concepts: window DENSE_RANK + date_trunc + FILTER aggregate + UNION ALL. 2 query + 1 combined output cho TaskFlow analytics dashboard.
PM vừa gửi yêu cầu: TaskFlow analytics dashboard cần 2 metric mới trước cuộc họp board sáng mai.
Metric 1 — Top 3 user closer mỗi tháng: rank, số task hoàn thành, thời gian trung bình hoàn thành (ngày). Dùng để theo dõi productivity và phân bổ workload hợp lý.
Metric 2 — Completion rate theo ISO week: tổng task tạo ra trong tuần, số task đã done, tỉ lệ phần trăm. Dùng để phát hiện tuần nào backlog tắc nghẽn.
Hai query này chạm toàn bộ kiến thức Module 3 của khoá này: window function với DENSE_RANK, date_trunc, FILTER aggregate, và JOIN. Bài này build từng bước, bắt lỗi phổ biến, rồi combine hai query vào một endpoint duy nhất.
1. Spec chi tiết
| Field | Query 1 — top 3 monthly | Query 2 — weekly completion |
|---|---|---|
| Group | năm + tháng | ISO week |
| Output | user_id, name, done_count, rank, avg_complete_days | week, total, done, completion_pct |
| Sort | month DESC, rank ASC | week DESC |
| Filter | 6 tháng gần nhất | 12 tuần gần nhất |
| Kết hợp | UNION ALL với label column | (combined output cho 1 endpoint) |
2. Step 1 — Build Query 1 (top 3 closer mỗi tháng)
2.1 Stage 1 — aggregate per user per month
-- Stage 1: aggregate per user per month
SELECT
date_trunc('month', updated_at) AS month,
assignee_id,
COUNT(*) AS done_count,
AVG(EXTRACT(DAY FROM (updated_at - created_at))) AS avg_days
FROM tasks
WHERE status = 'done'
AND updated_at >= now() - INTERVAL '6 months'
AND assignee_id IS NOT NULL
GROUP BY 1, 2;
date_trunc('month', updated_at) cắt timestamp về đầu tháng — 2026-05-14 09:30 trở thành 2026-05-01 00:00. Toàn bộ task trong tháng 5 gom về cùng một bucket. EXTRACT(DAY FROM interval) lấy phần ngày của khoảng thời gian hoàn thành.
2.2 Stage 2 — thêm DENSE_RANK
-- Stage 2: wrap CTE, add rank
WITH monthly AS (
SELECT
date_trunc('month', updated_at) AS month,
assignee_id,
COUNT(*) AS done_count,
AVG(EXTRACT(DAY FROM (updated_at - created_at))) AS avg_days
FROM tasks
WHERE status = 'done'
AND updated_at >= now() - INTERVAL '6 months'
AND assignee_id IS NOT NULL
GROUP BY 1, 2
)
SELECT
month,
assignee_id,
done_count,
ROUND(avg_days::numeric, 1) AS avg_complete_days,
DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC) AS rank
FROM monthly;
DENSE_RANK thay vì RANK: khi hai user cùng số task done, cả hai nhận rank 2 và user tiếp theo nhận rank 3 (không có gap). RANK tạo gap — rank 4 xuất hiện sau hai người rank 2. Với leaderboard "top 3", DENSE_RANK cho kết quả trực quan hơn.
2.3 Stage 3 — filter rank nhỏ hơn hoặc bằng 3 + JOIN users
-- Stage 3: filter top 3 + JOIN users
WITH monthly AS (
SELECT
date_trunc('month', updated_at) AS month,
assignee_id,
COUNT(*) AS done_count,
AVG(EXTRACT(DAY FROM (updated_at - created_at))) AS avg_days
FROM tasks
WHERE status = 'done'
AND updated_at >= now() - INTERVAL '6 months'
AND assignee_id IS NOT NULL
GROUP BY 1, 2
),
ranked AS (
SELECT
month,
assignee_id,
done_count,
ROUND(avg_days::numeric, 1) AS avg_complete_days,
DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC) AS rank
FROM monthly
)
SELECT
r.month,
r.rank,
u.name,
r.done_count,
r.avg_complete_days
FROM ranked r
INNER JOIN users u ON r.assignee_id = u.id
WHERE r.rank <= 3
ORDER BY r.month DESC, r.rank;
Window function nằm trong SELECT (bước 5 của logical order). WHERE chạy ở bước 2 — trước khi window được tính. Viết WHERE rank <= 3 ngay sau DENSE_RANK() OVER (...) AS rank trong cùng một query sẽ báo lỗi column "rank" does not exist. Wrap CTE (như Stage 3 ở trên) hoặc subquery là bắt buộc — xem Module 3 bài 6 của khoá này.
3. Step 2 — Build Query 2 (weekly completion rate)
-- Per ISO week: total task vs done vs ratio
SELECT
date_trunc('week', created_at) AS week,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'done') AS done,
ROUND(
COUNT(*) FILTER (WHERE status = 'done') * 100.0
/ NULLIF(COUNT(*), 0),
2
) AS completion_pct
FROM tasks
WHERE created_at >= now() - INTERVAL '12 weeks'
GROUP BY 1
ORDER BY week DESC;
Hai điểm quan trọng trong công thức completion_pct:
Integer division: COUNT(*) FILTER (...) / COUNT(*) chia số nguyên cho số nguyên — kết quả cũng là số nguyên, mất phần thập phân. 45 / 100 = 0 thay vì 0.45. Fix bằng cách nhân 100.0 (ép float) trước khi chia.
Divide-by-zero: NULLIF(COUNT(*), 0) trả về NULL khi COUNT(*) = 0, tránh lỗi division by zero. PostgreSQL trả về NULL thay vì crash khi chia cho NULL. Trường hợp nhóm rỗng hiếm xảy ra trong GROUP BY nhưng cách xử lý defensive này nên trở thành thói quen.
4. Step 3 — Combine 2 query qua UNION ALL
Schema của hai query khác nhau (Query 1 có 5 cột, Query 2 có 4 cột) — không thể UNION ALL trực tiếp. Cần cast về common schema.
Option A — 2 endpoint riêng (recommended cho production): schema rõ ràng, frontend nhận đúng kiểu dữ liệu, dễ cache và đánh index riêng.
Option B — 1 endpoint với label column (single combined output):
-- Cast 2 query ve common schema (metric_type, period, rank, subject, metric_value, extra)
SELECT
'monthly_top' AS metric_type,
to_char(month, 'YYYY-MM') AS period,
rank::int AS rank,
name AS subject,
done_count AS metric_value,
avg_complete_days::text AS extra
FROM (
-- ...Stage 3 ranked top 3 query...
WITH monthly AS (
SELECT
date_trunc('month', updated_at) AS month,
assignee_id,
COUNT(*) AS done_count,
AVG(EXTRACT(DAY FROM (updated_at - created_at))) AS avg_days
FROM tasks
WHERE status = 'done'
AND updated_at >= now() - INTERVAL '6 months'
AND assignee_id IS NOT NULL
GROUP BY 1, 2
),
ranked AS (
SELECT
month, assignee_id, done_count,
ROUND(avg_days::numeric, 1) AS avg_complete_days,
DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC) AS rank
FROM monthly
)
SELECT r.month, r.rank, u.name, r.done_count, r.avg_complete_days
FROM ranked r
INNER JOIN users u ON r.assignee_id = u.id
WHERE r.rank <= 3
) q1
UNION ALL
SELECT
'weekly_completion' AS metric_type,
to_char(week, 'YYYY-WW') AS period,
NULL::int AS rank,
NULL AS subject,
total AS metric_value,
completion_pct::text AS extra
FROM (
SELECT
date_trunc('week', created_at) AS week,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'done') AS done,
ROUND(
COUNT(*) FILTER (WHERE status = 'done') * 100.0
/ NULLIF(COUNT(*), 0),
2
) AS completion_pct
FROM tasks
WHERE created_at >= now() - INTERVAL '12 weeks'
GROUP BY 1
) q2
ORDER BY metric_type, period DESC, rank;
Schema chung (metric_type, period, rank, subject, metric_value, extra) cho phép frontend parse bằng metric_type. Trường rank và subject là NULL với weekly rows — frontend cần xử lý nullable.
5. Step 4 — Benchmark với EXPLAIN ANALYZE
-- Seed 100k task de benchmark
INSERT INTO tasks (project_id, assignee_id, title, status, created_at, updated_at)
SELECT
(random() * 5 + 1)::int,
(random() * 10 + 1)::int,
'Task ' || generate_series,
CASE WHEN random() < 0.4 THEN 'done' ELSE 'todo' END,
now() - (random() * 180) * INTERVAL '1 day',
now() - (random() * 30) * INTERVAL '1 day'
FROM generate_series(1, 100000);
-- Analyze Query 1
\timing on
EXPLAIN (ANALYZE, BUFFERS)
WITH monthly AS (
SELECT
date_trunc('month', updated_at) AS month,
assignee_id,
COUNT(*) AS done_count,
AVG(EXTRACT(DAY FROM (updated_at - created_at))) AS avg_days
FROM tasks
WHERE status = 'done'
AND updated_at >= now() - INTERVAL '6 months'
AND assignee_id IS NOT NULL
GROUP BY 1, 2
),
ranked AS (
SELECT month, assignee_id, done_count,
ROUND(avg_days::numeric, 1) AS avg_complete_days,
DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC) AS rank
FROM monthly
)
SELECT r.month, r.rank, u.name, r.done_count, r.avg_complete_days
FROM ranked r
INNER JOIN users u ON r.assignee_id = u.id
WHERE r.rank <= 3
ORDER BY r.month DESC, r.rank;
-- Plan: HashAggregate -> WindowAgg -> Sort -> Hash Join -> Filter
-- Estimated: ~150ms tren 100k task, sequential scan
Plan thường thấy: Seq Scan on tasks với filter status = 'done' → HashAggregate (GROUP BY) → WindowAgg (DENSE_RANK) → Hash Join với users → filter rank. Module 5 của khoá này đi sâu index strategy — composite index (status, updated_at) giảm cost scan đáng kể.
6. Pitfall tổng hợp
-- BUG 1: WHERE rank cung level voi window function
SELECT
assignee_id,
DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC) AS rank
FROM monthly
WHERE rank <= 3;
-- ERROR: column "rank" does not exist
-- Fix: wrap CTE hoac subquery nhu Stage 3 o tren
-- BUG 2: integer division mat decimal
COUNT(*) FILTER (WHERE status = 'done') / COUNT(*)
-- int / int = int: 45 / 100 = 0, khong phai 0.45
-- Fix A: nhan 100.0 truoc khi chia
COUNT(*) FILTER (WHERE status = 'done') * 100.0 / NULLIF(COUNT(*), 0)
-- Fix B: cast explicit
COUNT(*) FILTER (WHERE status = 'done')::numeric * 100 / NULLIF(COUNT(*), 0)
-- BUG 3: date_trunc khong nhat quan timezone
date_trunc('week', created_at)
-- Truncate theo session timezone -- khac nhau giua servers
-- Fix: explicit AT TIME ZONE truoc khi truncate
date_trunc('week', created_at AT TIME ZONE 'UTC')
-- BUG 4: avg_days NULL khi khong co row done
AVG(EXTRACT(DAY FROM (updated_at - created_at)))
-- Neu khong co row nao match WHERE status = 'done', AVG tra ve NULL
-- Fix defensive:
COALESCE(AVG(EXTRACT(DAY FROM (updated_at - created_at))), 0)
date_trunc('week', created_at) cắt theo timezone của session PostgreSQL hiện tại. Trên hệ thống multi-region với server ở timezone khác nhau, cùng một timestamp sẽ rơi vào các tuần khác nhau. Fix bắt buộc trong production: convert về UTC trước — date_trunc('week', created_at AT TIME ZONE 'UTC'). Hoặc lưu created_at dưới dạng timestamptz và đảm bảo PostgreSQL server dùng timezone = 'UTC'.
7. Deliverable
Tạo 3 file SQL:
query_1_monthly_top.sql — full Query 1 (Stage 3): top 3 closer theo tháng, 6 tháng gần nhất, JOIN users.
query_2_weekly_completion.sql — full Query 2: weekly completion rate, 12 tuần gần nhất, FILTER aggregate + NULLIF.
combined.sql — UNION ALL combined output với label column metric_type.
Optional: 1 file TypeScript implement node-postgres cho 2 endpoint riêng (Option A) — GET /api/analytics/monthly-top và GET /api/analytics/weekly-completion. Option A cho schema rõ ràng, dễ cache, dễ test riêng từng query.
Kèm 1 file notes.md ngắn ghi lại: bug nào đã phát hiện và fix, window pattern nào sử dụng (DENSE_RANK thay vì RANK), lý do chọn Option A hay B cho endpoint.
8. Module tiếp theo
| Topic | Bài học |
|---|---|
Index strategy cho query này — composite (status, updated_at) | Module 5 của khoá này |
| EXPLAIN ANALYZE nâng cao — đọc plan node, cost estimate | Module 7 của khoá này |
| Materialized view precompute monthly snapshot | Module 11 của khoá này |
| TaskFlow Analytics Capstone — full dashboard query | Module 12 của khoá này |
9. Tóm tắt
DENSE_RANKvàWHERE rank <= 3cần CTE wrap —WHEREchạy trướcSELECTnên không thấy window column (Module 3 bài 6 của khoá này).date_trunc+FILTERaggregate là pattern cốt lõi của analytics query.- Integer division trong ratio mất phần thập phân — nhân
100.0hoặc cast::numerictrước khi chia;NULLIF(COUNT(*), 0)ngăn divide-by-zero. UNION ALLcombine hai query schema khác nhau cần cast về common schema với label column.- Production nên dùng 2 endpoint riêng (Option A) thay vì 1 combined endpoint — schema rõ ràng hơn, dễ cache và test hơn.
date_trunckhông nhất quán timezone trên multi-region — fix vớiAT TIME ZONE 'UTC'trước khi truncate.
10. Tự kiểm tra
Q1Vì sao WHERE rank <= 3 ở cùng level với DENSE_RANK báo lỗi? Cách fix nào cleaner hơn — CTE hay subquery — và khi nào chọn cách nào?▸
Nguyên nhân: SQL logical processing order là FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. WHERE chạy ở bước 2, trước SELECT (bước 5) — tại thời điểm đó column rank chưa được tính, chưa tồn tại trong scope. PostgreSQL báo column "rank" does not exist.
CTE vs subquery: Về execution plan trên PostgreSQL, CTE và subquery thường tương đương — optimizer inline CTE trong hầu hết trường hợp. CTE dễ đọc hơn khi query phức tạp, có nhiều stage, hoặc cần tái dùng kết quả nhiều lần trong cùng query. Subquery gọn hơn cho trường hợp đơn giản, 1 lần dùng.
Với bài toán "top 3 closer" có 2 stage (aggregate + rank), CTE rõ ràng hơn: mỗi CTE là một bước logic độc lập, dễ debug từng stage riêng bằng cách SELECT * FROM monthly hay SELECT * FROM ranked.
Q2Tại sao COUNT(*) FILTER (WHERE status = 'done') / COUNT(*) trả về 0 thay vì tỉ lệ thực? Nêu 2 cách fix với rationale.▸
COUNT(*) FILTER (WHERE status = 'done') / COUNT(*) trả về 0 thay vì tỉ lệ thực? Nêu 2 cách fix với rationale.Nguyên nhân: Cả hai toán hạng đều là kiểu bigint (integer). PostgreSQL thực hiện integer division: 45 / 100 = 0, mất phần thập phân. Đây là hành vi chuẩn của mọi ngôn ngữ lập trình, không phải bug riêng của SQL.
Fix A — nhân 100.0:
COUNT(*) FILTER (WHERE status = 'done') * 100.0 / NULLIF(COUNT(*), 0)Nhân với float literal 100.0 ép PostgreSQL nâng kiểu toán hạng lên numeric trước khi chia. Trực quan, ít ký tự nhất.
Fix B — cast explicit:
COUNT(*) FILTER (WHERE status = 'done')::numeric * 100 / NULLIF(COUNT(*), 0)Cast ::numeric rõ ràng hơn về intent — "tôi muốn numeric division". Dùng khi code review yêu cầu explicit over implicit.
NULLIF(COUNT(*), 0) cần thiết trong cả hai cách — trả về NULL khi mẫu bằng 0, tránh division by zero error. PostgreSQL trả về NULL khi chia cho NULL, query không crash.
Q3date_trunc('week', created_at) không nhất quán timezone trên multi-region production — vấn đề cụ thể là gì và cách fix?▸
Vấn đề: date_trunc cắt timestamp theo timezone của session PostgreSQL. Timestamp 2026-05-04 01:00:00+07 (Việt Nam) tương đương 2026-05-03 18:00:00 UTC. Nếu server A dùng UTC+7 và server B dùng UTC, cùng timestamp đó sẽ rơi vào tuần khác nhau — server A cắt vào tuần bắt đầu 2026-05-04, server B cắt vào tuần bắt đầu 2026-04-27.
Fix 1 — AT TIME ZONE trước khi truncate:
date_trunc('week', created_at AT TIME ZONE 'UTC')Convert về UTC trước, rồi mới cắt. Kết quả nhất quán bất kể session timezone. Đây là cách phổ biến nhất.
Fix 2 — PostgreSQL config: Đặt timezone = 'UTC' trong postgresql.conf hoặc SET timezone = 'UTC' đầu session. Toàn bộ date_trunc trong session đó tự dùng UTC.
Fix 3 — timestamptz: Lưu created_at dưới dạng TIMESTAMP WITH TIME ZONE. PostgreSQL tự normalize về UTC khi lưu — date_trunc vẫn cần explicit timezone nhưng data layer nhất quán hơn.
Q4Combined endpoint UNION ALL vs 2 endpoint riêng — production tradeoff về performance, code complexity, và frontend?▸
UNION ALL (1 endpoint):
- Ưu: 1 round-trip, ít API surface, frontend gọi 1 lần.
- Nhược: Schema heterogeneous —
rankvàsubjectlàNULLvới weekly rows, frontend phải parse theometric_type. Khó cache riêng từng metric (monthly top cache 1 giờ, weekly cache 15 phút — không thể phân tách). TypeScript type phức tạp hơn. Khó test từng query độc lập.
2 endpoint riêng (Option A):
- Ưu: Schema rõ ràng, TypeScript type đơn giản cho mỗi endpoint. Cache riêng với TTL khác nhau. Test đơn giản — mock từng query. Scale độc lập nếu 1 query chậm hơn.
- Nhược: 2 round-trip từ frontend (giải quyết bằng
Promise.all). Nhiều API route hơn.
Kết luận: Production nên dùng Option A. Overhead 2 round-trip gần như không đáng kể với Promise.all. Lợi ích cache riêng và schema rõ ràng quan trọng hơn ở hệ thống production.
Bài tiếp theo: Module 4 — Schema design
Bài này có giúp bạn hiểu bản chất không?