From f705ff8d5066370b793c332b3c244cebf361f9a9 Mon Sep 17 00:00:00 2001 From: "Alexandro T. Netto" Date: Mon, 28 Sep 2026 14:34:39 -0300 Subject: [PATCH] fix(dashboard): respect each SPP program's report deadline --- .changeset/spp-report-due-days.md | 5 ++ .../components/ServiceProvidersTable.tsx | 6 +- .../hooks/useServiceProvidersData.ts | 2 +- .../features/service-providers/types.ts | 3 + .../utils/computeQuarterStatus.test.ts | 60 ++++++++++++++++ .../utils/computeQuarterStatus.ts | 26 +++++-- .../utils/fetchServiceProvidersData.test.ts | 72 +++++++++++++++++++ .../utils/fetchServiceProvidersData.ts | 61 ++++++++++------ 8 files changed, 204 insertions(+), 31 deletions(-) create mode 100644 .changeset/spp-report-due-days.md create mode 100644 apps/dashboard/features/service-providers/utils/computeQuarterStatus.test.ts create mode 100644 apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.test.ts diff --git a/.changeset/spp-report-due-days.md b/.changeset/spp-report-due-days.md new file mode 100644 index 0000000000..36f8e42e06 --- /dev/null +++ b/.changeset/spp-report-due-days.md @@ -0,0 +1,5 @@ +--- +"@anticapture/dashboard": patch +--- + +Service providers page: respect each program's report deadline (`reportDueDays` in `ensdao/spp`), so SPP3 reports are due 30 days after each quarter ends, per its Program Terms diff --git a/apps/dashboard/features/service-providers/components/ServiceProvidersTable.tsx b/apps/dashboard/features/service-providers/components/ServiceProvidersTable.tsx index f0b1a5839c..ffb7b1e7ff 100644 --- a/apps/dashboard/features/service-providers/components/ServiceProvidersTable.tsx +++ b/apps/dashboard/features/service-providers/components/ServiceProvidersTable.tsx @@ -103,7 +103,11 @@ export const ServiceProvidersTable = ({ const isYear2 = year2ColIndex !== undefined; const isCurrentQuarter = col.year === currentYear && col.quarter === currentQuarter; - const dueDateLabel = getDueDateLabel(col.quarter); + const dueDateLabel = getDueDateLabel( + col.year, + col.quarter, + program.reportDueDays, + ); return { id: key, diff --git a/apps/dashboard/features/service-providers/hooks/useServiceProvidersData.ts b/apps/dashboard/features/service-providers/hooks/useServiceProvidersData.ts index 9ee8a52a63..4cbb79bc68 100644 --- a/apps/dashboard/features/service-providers/hooks/useServiceProvidersData.ts +++ b/apps/dashboard/features/service-providers/hooks/useServiceProvidersData.ts @@ -31,7 +31,7 @@ const buildProviders = ( githubSlug: entry.slug, streamDuration: programEntry.streamDuration, years: Object.fromEntries( - Object.entries(result.data) + Object.entries(result.data[programKey] ?? {}) .filter(([, slugData]) => slugData[entry.slug]) .map(([year, slugData]) => [year, slugData[entry.slug]]), ), diff --git a/apps/dashboard/features/service-providers/types.ts b/apps/dashboard/features/service-providers/types.ts index 15a1a208c8..819ff60f1c 100644 --- a/apps/dashboard/features/service-providers/types.ts +++ b/apps/dashboard/features/service-providers/types.ts @@ -43,6 +43,8 @@ export type ProgramConfig = { year2Quarters?: string[]; budget: number; startDate: string; + /** Days after each quarter ends that its report is due, per the program's terms. */ + reportDueDays?: number; discussionUrl: string; budgetProposal: ProgramProposal; selectionProposal: ProgramProposal; @@ -78,6 +80,7 @@ export type ProgramDefinition = { name: string; year1Quarters: ParsedQuarter[]; year2Quarters: ParsedQuarter[]; + reportDueDays: number; discussionUrl: string; budgetProposal: ProgramProposal; selectionProposal: ProgramProposal; diff --git a/apps/dashboard/features/service-providers/utils/computeQuarterStatus.test.ts b/apps/dashboard/features/service-providers/utils/computeQuarterStatus.test.ts new file mode 100644 index 0000000000..aa9ef07e79 --- /dev/null +++ b/apps/dashboard/features/service-providers/utils/computeQuarterStatus.test.ts @@ -0,0 +1,60 @@ +import { + computeQuarterStatus, + getDueDate, + getDueDateLabel, +} from "@/features/service-providers/utils/computeQuarterStatus"; + +describe("getDueDate", () => { + test("defaults to the last second of the quarter", () => { + expect(getDueDate(2026, "Q3").toISOString()).toBe( + "2026-09-30T23:59:59.000Z", + ); + }); + + test("adds the program's report window after the quarter ends", () => { + expect(getDueDate(2026, "Q3", 30).toISOString()).toBe( + "2026-10-30T23:59:59.000Z", + ); + }); + + test("rolls a Q4 report window into the next year", () => { + expect(getDueDate(2026, "Q4", 30).toISOString()).toBe( + "2027-01-30T23:59:59.000Z", + ); + }); +}); + +describe("getDueDateLabel", () => { + test("labels the quarter end when there is no report window", () => { + expect(getDueDateLabel(2026, "Q3")).toBe("Due by Sep 30"); + }); + + test("labels the actual deadline when there is a report window", () => { + expect(getDueDateLabel(2026, "Q3", 30)).toBe("Due by Oct 30"); + expect(getDueDateLabel(2026, "Q4", 30)).toBe("Due by Jan 30"); + }); +}); + +describe("computeQuarterStatus", () => { + const oct5 = new Date("2026-10-05T12:00:00Z"); + + test("is overdue the day after the quarter ends without a report window", () => { + expect(computeQuarterStatus(2026, "Q3", oct5)).toBe("overdue"); + }); + + test("is still due soon inside the program's report window", () => { + expect(computeQuarterStatus(2026, "Q3", oct5, 30)).toBe("due_soon"); + }); + + test("is overdue once the report window has passed", () => { + expect( + computeQuarterStatus(2026, "Q3", new Date("2026-10-31T00:00:00Z"), 30), + ).toBe("overdue"); + }); + + test("is upcoming more than 30 days before the deadline", () => { + expect( + computeQuarterStatus(2026, "Q3", new Date("2026-09-28T00:00:00Z"), 30), + ).toBe("upcoming"); + }); +}); diff --git a/apps/dashboard/features/service-providers/utils/computeQuarterStatus.ts b/apps/dashboard/features/service-providers/utils/computeQuarterStatus.ts index 198a5dc993..b2e6b39ba2 100644 --- a/apps/dashboard/features/service-providers/utils/computeQuarterStatus.ts +++ b/apps/dashboard/features/service-providers/utils/computeQuarterStatus.ts @@ -10,25 +10,37 @@ const QUARTER_END_DATES: Record = { Q4: { month: 12, day: 31 }, }; -export const getDueDate = (year: number, quarter: QuarterKey): Date => { +// reportDueDays: days after the quarter ends that the program gives providers +// to publish the report (e.g. SPP3 Program Terms ยง6.3: 30 days). +export const getDueDate = ( + year: number, + quarter: QuarterKey, + reportDueDays = 0, +): Date => { const { month, day } = QUARTER_END_DATES[quarter]; - return new Date(Date.UTC(year, month - 1, day, 23, 59, 59)); + return new Date(Date.UTC(year, month - 1, day + reportDueDays, 23, 59, 59)); }; -export const getDueDateLabel = (quarter: QuarterKey): string => { - const { month, day } = QUARTER_END_DATES[quarter]; - const monthName = new Date(2000, month - 1).toLocaleString("en-US", { +export const getDueDateLabel = ( + year: number, + quarter: QuarterKey, + reportDueDays = 0, +): string => { + const dueDate = getDueDate(year, quarter, reportDueDays); + const monthName = dueDate.toLocaleString("en-US", { month: "short", + timeZone: "UTC", }); - return `Due by ${monthName} ${day}`; + return `Due by ${monthName} ${dueDate.getUTCDate()}`; }; export const computeQuarterStatus = ( year: number, quarter: QuarterKey, now: Date, + reportDueDays = 0, ): ReportStatus => { - const deadline = getDueDate(year, quarter); + const deadline = getDueDate(year, quarter, reportDueDays); if (now > deadline) return "overdue"; diff --git a/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.test.ts b/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.test.ts new file mode 100644 index 0000000000..35a5688930 --- /dev/null +++ b/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.test.ts @@ -0,0 +1,72 @@ +import type { ProviderEntry } from "@/features/service-providers/types"; +import { + buildProgramData, + parseProgramConfig, +} from "@/features/service-providers/utils/fetchServiceProvidersData"; + +const proposal = { + id: "EP 1.1", + title: "Proposal", + date: "2026-01-01", + forumUrl: "https://discuss.ens.domains/t/1", + snapshotUrl: "https://snapshot.box/#/s:ens.eth/proposal/0x1", +}; + +const programConfig = { + name: "Program", + year1Quarters: ["2026/Q3", "2026/Q4"], + budget: 1_000_000, + startDate: "2026-08-01", + discussionUrl: "https://discuss.ens.domains/t/1", + budgetProposal: proposal, + selectionProposal: proposal, +}; + +const provider: ProviderEntry = { + name: "Provider", + slug: "provider", + programs: { SPP3: { budget: 100_000, streamDuration: 1 } }, + reports: { "2026/Q4": "https://discuss.ens.domains/t/2" }, +}; + +const oct5 = new Date("2026-10-05T12:00:00Z"); + +describe("parseProgramConfig", () => { + test("defaults the report window to 0 days", () => { + expect(parseProgramConfig(programConfig).reportDueDays).toBe(0); + }); + + test("keeps the program's report window", () => { + expect( + parseProgramConfig({ ...programConfig, reportDueDays: 30 }).reportDueDays, + ).toBe(30); + }); +}); + +describe("buildProgramData", () => { + test("uses each program's own report window for the same quarter", () => { + const quarterEnd = parseProgramConfig(programConfig); + const thirtyDays = parseProgramConfig({ + ...programConfig, + reportDueDays: 30, + }); + + expect( + buildProgramData(quarterEnd, [provider], oct5)[2026].provider.Q3, + ).toEqual({ status: "overdue" }); + expect( + buildProgramData(thirtyDays, [provider], oct5)[2026].provider.Q3, + ).toEqual({ status: "due_soon" }); + }); + + test("marks quarters with a report link as published", () => { + const program = parseProgramConfig({ ...programConfig, reportDueDays: 30 }); + + expect( + buildProgramData(program, [provider], oct5)[2026].provider.Q4, + ).toEqual({ + status: "published", + reportUrl: "https://discuss.ens.domains/t/2", + }); + }); +}); diff --git a/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.ts b/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.ts index 174fdd3159..92dc543ab5 100644 --- a/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.ts +++ b/apps/dashboard/features/service-providers/utils/fetchServiceProvidersData.ts @@ -7,6 +7,7 @@ import type { ProgramConfig, ProgramDefinition, ProgramsConfig, + ProviderEntry, ProvidersConfig, QuarterKey, QuarterReport, @@ -22,7 +23,8 @@ export type ServiceProvidersData = Record>; export type ServiceProvidersResult = { config: ProvidersConfig; programs: Record; - data: ServiceProvidersData; + // Keyed by program: the same quarter can have different deadlines per program. + data: Record; avatarUrls: Record; }; @@ -36,6 +38,7 @@ export const parseProgramConfig = ( config: ProgramConfig, ): ProgramDefinition => ({ name: config.name, + reportDueDays: config.reportDueDays ?? 0, discussionUrl: config.discussionUrl, budgetProposal: config.budgetProposal, selectionProposal: config.selectionProposal, @@ -47,13 +50,9 @@ export const parseProgramConfig = ( .filter((q): q is ParsedQuarter => q !== null), }); -const collectYears = ( - programs: Record, -): number[] => [ +const collectYears = (program: ProgramDefinition): number[] => [ ...new Set( - Object.values(programs).flatMap((p) => - [...p.year1Quarters, ...p.year2Quarters].map((q) => q.year), - ), + [...program.year1Quarters, ...program.year2Quarters].map((q) => q.year), ), ]; @@ -61,6 +60,7 @@ const buildQuarterReport = ( year: number, quarter: QuarterKey, reports: Record, + reportDueDays: number, now: Date, ): QuarterReport => { const reportUrl = reports[`${year}/${quarter}`]; @@ -69,9 +69,37 @@ const buildQuarterReport = ( return { status: "published", reportUrl }; } - return { status: computeQuarterStatus(year, quarter, now) }; + return { status: computeQuarterStatus(year, quarter, now, reportDueDays) }; }; +export const buildProgramData = ( + program: ProgramDefinition, + providers: ProviderEntry[], + now: Date, +): ServiceProvidersData => + Object.fromEntries( + collectYears(program).map((year) => [ + year, + Object.fromEntries( + providers.map((provider) => [ + provider.slug, + Object.fromEntries( + QUARTERS.map((quarter) => [ + quarter, + buildQuarterReport( + year, + quarter, + provider.reports, + program.reportDueDays, + now, + ), + ]), + ) as YearData, // safe: maps over all 4 quarters + ]), + ), + ]), + ); + const fetchJson = async (path: string): Promise => { const response = await fetch(`${GITHUB_RAW_BASE}/${path}`); if (!response.ok) { @@ -122,23 +150,12 @@ export const fetchServiceProvidersData = ]), ); - const years = collectYears(programs); const now = new Date(); const data = Object.fromEntries( - years.map((year) => [ - year, - Object.fromEntries( - config.providers.map((provider) => [ - provider.slug, - Object.fromEntries( - QUARTERS.map((quarter) => [ - quarter, - buildQuarterReport(year, quarter, provider.reports, now), - ]), - ) as YearData, // safe: maps over all 4 quarters - ]), - ), + Object.entries(programs).map(([key, program]) => [ + key, + buildProgramData(program, config.providers, now), ]), );