Approach: aggregate daily totals from transactions, generate a continuous date series for the last two years to fill missing dates (as zero sales), then compute moving averages with window functions and WoW/YoY percent changes comparing the same weekday last week / same date last year to respect seasonality.SQL (Postgres):sql
WITH params AS (
SELECT
(current_date - interval '2 years')::date AS start_date,
current_date::date AS end_date
),
daily AS (
-- aggregate raw transactions per day
SELECT
t_date::date AS date,
SUM(amount) AS daily_total
FROM transactions
JOIN params ON true
WHERE t_date::date BETWEEN params.start_date AND params.end_date
GROUP BY t_date::date
),
calendar AS (
-- continuous date series for last 2 years
SELECT generate_series(start_date, end_date, interval '1 day')::date AS date
FROM params
),
series AS (
-- left join to ensure every date exists; missing days become zero
SELECT
c.date,
COALESCE(d.daily_total, 0) AS daily_total
FROM calendar c
LEFT JOIN daily d USING (date)
),
ma AS (
-- compute 7-day and 30-day moving averages (including current day)
SELECT
date,
daily_total,
ROUND(AVG(daily_total) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)::numeric, 2) AS 7_day_MA,
ROUND(AVG(daily_total) OVER (ORDER BY date ROWS BETWEEN 29 PRECEDING AND CURRENT ROW)::numeric, 2) AS 30_day_MA
FROM series
),
changes AS (
SELECT
m.*,
-- week-over-week pct change: compare to same weekday last week (date - 7 days)
LAG(daily_total, 7) OVER (ORDER BY date) AS daily_last_week,
NULLIF(LAG(daily_total, 7) OVER (ORDER BY date), 0) AS prev_week_nonzero,
-- year-over-year: compare to same date last year
LAG(daily_total, 365) OVER (ORDER BY date) AS daily_last_year
FROM ma m
)
SELECT
date,
daily_total,
7_day_MA,
30_day_MA,
-- WoW pct change using 7-day lag (same weekday last week). Handle division-by-zero and NULLs.
CASE
WHEN daily_last_week IS NULL THEN NULL
WHEN daily_last_week = 0 THEN NULL
ELSE ROUND( (daily_total - daily_last_week)::numeric / daily_last_week * 100, 2)
END AS week_over_week_pct_change,
-- YoY pct change using 365-day lag (same calendar date last year). NULL for missing prior-year date.
CASE
WHEN daily_last_year IS NULL THEN NULL
WHEN daily_last_year = 0 THEN NULL
ELSE ROUND( (daily_total - daily_last_year)::numeric / daily_last_year * 100, 2)
END AS year_over_year_pct_change
FROM changes
ORDER BY date;
Explanation & reasoning:- Missing dates: generate_series ensures every date appears; COALESCE(...,0) treats no-transaction days as zero sales — appropriate when absence implies zero activity. If missing means unknown, use NULL instead and adjust moving averages with FILTER to ignore NULLs.- WoW seasonality: comparing to date - 7 days (or using LAG(...,7)) aligns the same weekday, avoiding weekday-seasonality distortion (e.g., weekends vs. weekdays).- YoY seasonality: comparing to the same calendar date last year (date - interval '1 year' or LAG by 365) handles annual seasonality. For promotions that shift weekdays year-to-year, you can also compare to same ISO week (52-week lag) or build a 7×52 seasonal model.- Nulls/zeros: avoid dividing by zero; return NULL when prior period is missing or zero. For business reports you might instead show "infinite" or a label.- Smoothing: 7/30-day MAs reduce noise; consider centered moving average for better seasonality removal if needed.Edge cases: leap years (365 vs 366), sparse historical data (not enough days for full MA), daylight saving effects (use date only), and transactions with different timezones (normalize to UTC/date).