From 6523e90a031b524fb9896de7d9752eef71ffaa06 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 20 Aug 2026 22:28:07 +0330 Subject: [PATCH] fix: expose complete dataset period catalog --- docs/ANALYTICS-SPEC.md | 2 +- docs/API-SPEC.md | 2 +- docs/openapi.yaml | 9 +- internal/analytics/periods.go | 143 +++++++++++++++++++---------- internal/analytics/periods_test.go | 50 +++++++--- internal/analytics/service.go | 2 +- tasks/plan.md | 2 +- tasks/todo.md | 2 +- 8 files changed, 140 insertions(+), 72 deletions(-) diff --git a/docs/ANALYTICS-SPEC.md b/docs/ANALYTICS-SPEC.md index b9ddbc7..9551972 100644 --- a/docs/ANALYTICS-SPEC.md +++ b/docs/ANALYTICS-SPEC.md @@ -167,7 +167,7 @@ Every section uses a versioned typed contract and has independent `queued`, `cal - Normalized PostgreSQL facts are authoritative; Redis is a 30-day disposable analytics cache. - Four sections calculate lazily and in bounded parallel, with partial authenticated SSE delivery. -- Reporting covers day, Iranian week, Jalali month, and Persian season only. +- Reporting covers every complete day, Iranian week, Jalali month, and Persian season inside the imported dataset's local-date coverage; arbitrary client-computed ranges remain disallowed. - Peer comparison uses same-category merchant-weighted medians and direction-aware percentiles with the approved suppression threshold. - Payment lifecycle, retry, timing, and real-fee definitions follow `METRIC-CATALOGUE.md` and ADR 0013. - Asynq `v0.26.0` is pinned and acknowledged as pre-1.0; jobs have bounded concurrency, timeouts, rate limits, and exponential-backoff retries. diff --git a/docs/API-SPEC.md b/docs/API-SPEC.md index 2b0f691..bf68810 100644 --- a/docs/API-SPEC.md +++ b/docs/API-SPEC.md @@ -79,7 +79,7 @@ Clients select one allowlisted `periodId` returned by `GET /analytics/periods`; - `timezone: Asia/Tehran` and `jalaliLabelFa`, ready for display; - `isComplete` and `isDefault` flags. -The default is the latest complete Jalali month containing source data. Weekly periods start Saturday at `00:00` and end at the following Saturday `00:00` in Tehran. The optional `periodType` query is allowlisted and filters the returned descriptors. The server rejects stale or fabricated period ids and invalid period types instead of accepting client-computed dates. +The catalog contains every complete day, Saturday–Friday week, Jalali month, and Persian season inside the imported dataset's local-date coverage. Descriptors are grouped by type and ordered newest-first within each group. The default is the latest complete Jalali month containing source data. Weekly periods start Saturday at `00:00` and end at the following Saturday `00:00` in Tehran. The optional `periodType` query is allowlisted and filters the returned descriptors. The server rejects stale or fabricated period ids and invalid period types instead of accepting client-computed dates. ## Analytics-run contract diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f0bea7c..f9693c2 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -136,9 +136,11 @@ paths: operationId: listAnalyticsPeriods summary: List approved reporting periods description: | - Returns allowlisted periods with server-calculated UTC and Tehran-local - half-open boundaries. Clients start runs with a returned `periodId` and - never submit arbitrary timestamps. + Returns every complete allowlisted day, Iranian week, Jalali month, and + Persian season inside the imported dataset's local-date coverage, with + server-calculated UTC and Tehran-local half-open boundaries. Periods are + grouped by type and ordered newest-first inside each group. Clients start + runs with a returned `periodId` and never submit arbitrary timestamps. parameters: - name: periodType in: query @@ -1090,6 +1092,7 @@ components: $ref: '#/components/schemas/PeriodId' periods: type: array + description: Dataset-bounded complete periods, grouped by type and newest-first within each group. maxItems: 1000 items: $ref: '#/components/schemas/ReportingPeriod' diff --git a/internal/analytics/periods.go b/internal/analytics/periods.go index c0ef2f1..e193d9f 100644 --- a/internal/analytics/periods.go +++ b/internal/analytics/periods.go @@ -119,76 +119,121 @@ func nextJalaliMonth(jy, jm int) (int, int) { return jy, jm } -func latestCompletePeriods(datasetStart, datasetMax time.Time, location *time.Location) []Period { +func completePeriods(datasetStart, datasetMax time.Time, location *time.Location) []Period { + if datasetMax.Before(datasetStart) { + return nil + } + localStart := datasetStart.In(location) localMax := datasetMax.In(location) datasetStartDay := time.Date(localStart.Year(), localStart.Month(), localStart.Day(), 0, 0, 0, 0, location) - dayStart := time.Date(localMax.Year(), localMax.Month(), localMax.Day(), 0, 0, 0, 0, location) - datasetEnd := dayStart.AddDate(0, 0, 1) + datasetEnd := time.Date(localMax.Year(), localMax.Month(), localMax.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1) + periods := make([]Period, 0) + + for dayStart := datasetEnd.AddDate(0, 0, -1); !dayStart.Before(datasetStartDay); dayStart = dayStart.AddDate(0, 0, -1) { + jy, jm, jd := gregorianToJalali(dayStart.Year(), int(dayStart.Month()), dayStart.Day()) + periods = append(periods, newPeriod( + "d-"+dayStart.Format(time.DateOnly), + "DAY", + dayStart, + dayStart.AddDate(0, 0, 1), + fmt.Sprintf("روز %s %s %s", persianNumber(jd), jalaliMonthNames[jm-1], persianNumber(jy)), + false, + )) + } weekEnd := datasetEnd for weekEnd.Weekday() != time.Saturday { weekEnd = weekEnd.AddDate(0, 0, -1) } - weekStart := weekEnd.AddDate(0, 0, -7) + for weekStart := weekEnd.AddDate(0, 0, -7); !weekStart.Before(datasetStartDay); weekStart = weekStart.AddDate(0, 0, -7) { + weekEnd = weekStart.AddDate(0, 0, 7) + weekStartJY, weekStartJM, weekStartJD := gregorianToJalali(weekStart.Year(), int(weekStart.Month()), weekStart.Day()) + weekLastDay := weekEnd.AddDate(0, 0, -1) + weekEndJY, weekEndJM, weekEndJD := gregorianToJalali(weekLastDay.Year(), int(weekLastDay.Month()), weekLastDay.Day()) + periods = append(periods, newPeriod( + "w-"+weekStart.Format(time.DateOnly), + "WEEK", + weekStart, + weekEnd, + fmt.Sprintf( + "هفته %s %s %s تا %s %s %s", + persianNumber(weekStartJD), jalaliMonthNames[weekStartJM-1], persianNumber(weekStartJY), + persianNumber(weekEndJD), jalaliMonthNames[weekEndJM-1], persianNumber(weekEndJY), + ), + false, + )) + } - jy, jm, _ := gregorianToJalali(localMax.Year(), int(localMax.Month()), localMax.Day()) - monthStart := jalaliDate(jy, jm, 1, location) - nextYear, nextMonth := nextJalaliMonth(jy, jm) + latestDay := datasetEnd.AddDate(0, 0, -1) + latestJY, latestJM, _ := gregorianToJalali(latestDay.Year(), int(latestDay.Month()), latestDay.Day()) + monthYear, month := latestJY, latestJM + monthStart := jalaliDate(monthYear, month, 1, location) + nextYear, nextMonth := nextJalaliMonth(monthYear, month) monthEnd := jalaliDate(nextYear, nextMonth, 1, location) if monthEnd.After(datasetEnd) { - jy, jm = previousJalaliMonth(jy, jm) - monthStart = jalaliDate(jy, jm, 1, location) - nextYear, nextMonth = nextJalaliMonth(jy, jm) + monthYear, month = previousJalaliMonth(monthYear, month) + } + for isDefault := true; ; isDefault = false { + monthStart = jalaliDate(monthYear, month, 1, location) + nextYear, nextMonth = nextJalaliMonth(monthYear, month) monthEnd = jalaliDate(nextYear, nextMonth, 1, location) + if monthStart.Before(datasetStartDay) { + break + } + periods = append(periods, newPeriod( + fmt.Sprintf("jm-%04d-%02d", monthYear, month), + "JALALI_MONTH", + monthStart, + monthEnd, + fmt.Sprintf("%s %s", jalaliMonthNames[month-1], persianNumber(monthYear)), + isDefault, + )) + monthYear, month = previousJalaliMonth(monthYear, month) } - seasonStartMonth := ((jm-1)/3)*3 + 1 - seasonYear := jy - seasonStart := jalaliDate(seasonYear, seasonStartMonth, 1, location) - seasonEndYear, seasonEndMonth := seasonYear, seasonStartMonth+3 - if seasonEndMonth > 12 { - seasonEndYear++ - seasonEndMonth -= 12 - } - seasonEnd := jalaliDate(seasonEndYear, seasonEndMonth, 1, location) + seasonYear := latestJY + seasonStartMonth := ((latestJM - 1) / 3 * 3) + 1 + seasonStart, seasonEnd := seasonBoundaries(seasonYear, seasonStartMonth, location) if seasonEnd.After(datasetEnd) { - seasonStartMonth -= 3 - if seasonStartMonth < 1 { - seasonYear-- - seasonStartMonth += 12 + seasonYear, seasonStartMonth = previousPersianSeason(seasonYear, seasonStartMonth) + } + for { + seasonStart, seasonEnd = seasonBoundaries(seasonYear, seasonStartMonth, location) + if seasonStart.Before(datasetStartDay) { + break } - seasonStart = jalaliDate(seasonYear, seasonStartMonth, 1, location) - seasonEndYear, seasonEndMonth = seasonYear, seasonStartMonth+3 - if seasonEndMonth > 12 { - seasonEndYear++ - seasonEndMonth -= 12 - } - seasonEnd = jalaliDate(seasonEndYear, seasonEndMonth, 1, location) + periods = append(periods, newPeriod( + fmt.Sprintf("ps-%04d-%02d", seasonYear, seasonStartMonth), + "PERSIAN_SEASON", + seasonStart, + seasonEnd, + fmt.Sprintf("%s %s", persianSeasonNames[(seasonStartMonth-1)/3], persianNumber(seasonYear)), + false, + )) + seasonYear, seasonStartMonth = previousPersianSeason(seasonYear, seasonStartMonth) } - dayJY, dayJM, dayJD := gregorianToJalali(dayStart.Year(), int(dayStart.Month()), dayStart.Day()) - weekEndJY, weekEndJM, weekEndJD := gregorianToJalali(weekEnd.AddDate(0, 0, -1).Year(), int(weekEnd.AddDate(0, 0, -1).Month()), weekEnd.AddDate(0, 0, -1).Day()) - weekStartJY, weekStartJM, weekStartJD := gregorianToJalali(weekStart.Year(), int(weekStart.Month()), weekStart.Day()) - - periods := []Period{ - newPeriod("d-"+dayStart.Format(time.DateOnly), "DAY", dayStart, datasetEnd, - fmt.Sprintf("روز %s %s %s", persianNumber(dayJD), jalaliMonthNames[dayJM-1], persianNumber(dayJY)), false), - newPeriod("w-"+weekStart.Format(time.DateOnly), "WEEK", weekStart, weekEnd, - fmt.Sprintf("هفته %s %s تا %s %s", persianNumber(weekStartJD), jalaliMonthNames[weekStartJM-1], persianNumber(weekEndJD), jalaliMonthNames[weekEndJM-1]), false), - newPeriod(fmt.Sprintf("jm-%04d-%02d", jy, jm), "JALALI_MONTH", monthStart, monthEnd, - fmt.Sprintf("%s %s", jalaliMonthNames[jm-1], persianNumber(jy)), true), - newPeriod(fmt.Sprintf("ps-%04d-%02d", seasonYear, seasonStartMonth), "PERSIAN_SEASON", seasonStart, seasonEnd, - fmt.Sprintf("%s %s", persianSeasonNames[(seasonStartMonth-1)/3], persianNumber(seasonYear)), false), - } - for index := range periods { - periods[index].IsComplete = !periods[index].LocalStartAt.Before(datasetStartDay) - } - _ = weekEndJY - _ = weekStartJY return periods } +func seasonBoundaries(year, startMonth int, location *time.Location) (time.Time, time.Time) { + endYear, endMonth := year, startMonth+3 + if endMonth > 12 { + endYear++ + endMonth -= 12 + } + return jalaliDate(year, startMonth, 1, location), jalaliDate(endYear, endMonth, 1, location) +} + +func previousPersianSeason(year, startMonth int) (int, int) { + startMonth -= 3 + if startMonth < 1 { + return year - 1, startMonth + 12 + } + return year, startMonth +} + func newPeriod(id, kind string, localStart, localEnd time.Time, label string, isDefault bool) Period { return Period{ PeriodID: id, PeriodType: kind, StartAt: localStart.UTC(), EndAt: localEnd.UTC(), diff --git a/internal/analytics/periods_test.go b/internal/analytics/periods_test.go index 5a0504e..d27f78e 100644 --- a/internal/analytics/periods_test.go +++ b/internal/analytics/periods_test.go @@ -31,7 +31,7 @@ func TestJalaliGregorianRoundTrip(t *testing.T) { } } -func TestLatestCompletePeriods(t *testing.T) { +func TestCompletePeriods(t *testing.T) { t.Parallel() location, err := time.LoadLocation("Asia/Tehran") if err != nil { @@ -40,37 +40,57 @@ func TestLatestCompletePeriods(t *testing.T) { datasetStart := time.Date(2026, time.January, 1, 0, 0, 0, 0, location) datasetMax := time.Date(2026, time.June, 30, 23, 59, 0, 0, location) - periods := latestCompletePeriods(datasetStart, datasetMax, location) - if len(periods) != 4 { - t.Fatalf("latestCompletePeriods() returned %d periods, want 4", len(periods)) + periods := completePeriods(datasetStart, datasetMax, location) + if len(periods) != 212 { + t.Fatalf("completePeriods() returned %d periods, want 212", len(periods)) } want := []struct { + index int id, kind, start, end string isDefault bool }{ - {id: "d-2026-06-30", kind: "DAY", start: "2026-06-30", end: "2026-07-01"}, - {id: "w-2026-06-20", kind: "WEEK", start: "2026-06-20", end: "2026-06-27"}, - {id: "jm-1405-03", kind: "JALALI_MONTH", start: "2026-05-22", end: "2026-06-22", isDefault: true}, - {id: "ps-1405-01", kind: "PERSIAN_SEASON", start: "2026-03-21", end: "2026-06-22"}, + {index: 0, id: "d-2026-06-30", kind: "DAY", start: "2026-06-30", end: "2026-07-01"}, + {index: 180, id: "d-2026-01-01", kind: "DAY", start: "2026-01-01", end: "2026-01-02"}, + {index: 181, id: "w-2026-06-20", kind: "WEEK", start: "2026-06-20", end: "2026-06-27"}, + {index: 205, id: "w-2026-01-03", kind: "WEEK", start: "2026-01-03", end: "2026-01-10"}, + {index: 206, id: "jm-1405-03", kind: "JALALI_MONTH", start: "2026-05-22", end: "2026-06-22", isDefault: true}, + {index: 210, id: "jm-1404-11", kind: "JALALI_MONTH", start: "2026-01-21", end: "2026-02-20"}, + {index: 211, id: "ps-1405-01", kind: "PERSIAN_SEASON", start: "2026-03-21", end: "2026-06-22"}, } - for index, expected := range want { - period := periods[index] + for _, expected := range want { + period := periods[expected.index] if period.PeriodID != expected.id || period.PeriodType != expected.kind { - t.Errorf("period[%d] identity = %s/%s, want %s/%s", index, period.PeriodID, period.PeriodType, expected.id, expected.kind) + t.Errorf("period[%d] identity = %s/%s, want %s/%s", expected.index, period.PeriodID, period.PeriodType, expected.id, expected.kind) } if got := period.LocalStartAt.Format(time.DateOnly); got != expected.start { - t.Errorf("period[%d] start = %s, want %s", index, got, expected.start) + t.Errorf("period[%d] start = %s, want %s", expected.index, got, expected.start) } if got := period.LocalEndAt.Format(time.DateOnly); got != expected.end { - t.Errorf("period[%d] end = %s, want %s", index, got, expected.end) + t.Errorf("period[%d] end = %s, want %s", expected.index, got, expected.end) } if period.IsDefault != expected.isDefault { - t.Errorf("period[%d] IsDefault = %v, want %v", index, period.IsDefault, expected.isDefault) + t.Errorf("period[%d] IsDefault = %v, want %v", expected.index, period.IsDefault, expected.isDefault) } if period.StartAt.Location() != time.UTC || period.EndAt.Location() != time.UTC { - t.Errorf("period[%d] UTC boundaries do not use UTC", index) + t.Errorf("period[%d] UTC boundaries do not use UTC", expected.index) } } + + defaultCount := 0 + for _, period := range periods { + if !period.IsComplete { + t.Errorf("period %s is not complete", period.PeriodID) + } + if period.LocalStartAt.Before(datasetStart) || period.LocalEndAt.After(datasetMax.Add(time.Minute)) { + t.Errorf("period %s lies outside dataset coverage", period.PeriodID) + } + if period.IsDefault { + defaultCount++ + } + } + if defaultCount != 1 { + t.Fatalf("completePeriods() returned %d defaults, want 1", defaultCount) + } } diff --git a/internal/analytics/service.go b/internal/analytics/service.go index a3ccaae..1cdea6f 100644 --- a/internal/analytics/service.go +++ b/internal/analytics/service.go @@ -74,7 +74,7 @@ func (s *Service) Periods(ctx context.Context) ([]Period, error) { if err != nil { return nil, fmt.Errorf("loading tehran timezone: %w", err) } - return latestCompletePeriods(start, end, location), nil + return completePeriods(start, end, location), nil } func (s *Service) Start(ctx context.Context, merchantID int64, period Period, terminalID string) (Run, error) { diff --git a/tasks/plan.md b/tasks/plan.md index bd28070..3e38684 100644 --- a/tasks/plan.md +++ b/tasks/plan.md @@ -213,7 +213,7 @@ None. The user approved the merchant-dashboard redesign on 2026-08-20. The backend increment is additive and follows this dependency order: -1. Pure, tested Jalali boundary helpers and four latest complete period descriptors. +1. Pure, tested Jalali boundary helpers and a dataset-bounded catalog of all complete supported period descriptors. 2. Authenticated merchant-terminal listing and optional terminal scope on analytics runs. 3. Bounded overview payment-history points for the selected scope. 4. Transaction listing filters and one merchant-owned transaction detail resource. diff --git a/tasks/todo.md b/tasks/todo.md index cee18f9..98966a0 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -549,7 +549,7 @@ Every task must satisfy its acceptance criteria and the standing Definition of D ## Approved redesign API increment — 2026-08-20 -- [ ] Task 13: Return latest complete `DAY`, `WEEK`, `JALALI_MONTH`, and `PERSIAN_SEASON` periods with the Jalali month as default. +- [ ] Task 13: Return every complete dataset-backed `DAY`, `WEEK`, `JALALI_MONTH`, and `PERSIAN_SEASON` period with the latest complete Jalali month as default. - Acceptance: pure boundary tests cover Nowruz, month rollover, leap Esfand, Iranian Saturday–Friday weeks, and dataset boundaries. - Verify: focused analytics tests and `go test ./...`. - [ ] Task 14: Add authenticated merchant terminal discovery and terminal-scoped analytics runs.