Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spp-report-due-days.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]]),
),
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/features/service-providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,6 +80,7 @@ export type ProgramDefinition = {
name: string;
year1Quarters: ParsedQuarter[];
year2Quarters: ParsedQuarter[];
reportDueDays: number;
discussionUrl: string;
budgetProposal: ProgramProposal;
selectionProposal: ProgramProposal;
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,37 @@ const QUARTER_END_DATES: Record<QuarterKey, { month: number; day: number }> = {
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";

Expand Down
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ProgramConfig,
ProgramDefinition,
ProgramsConfig,
ProviderEntry,
ProvidersConfig,
QuarterKey,
QuarterReport,
Expand All @@ -22,7 +23,8 @@ export type ServiceProvidersData = Record<number, Record<string, YearData>>;
export type ServiceProvidersResult = {
config: ProvidersConfig;
programs: Record<string, ProgramDefinition>;
data: ServiceProvidersData;
// Keyed by program: the same quarter can have different deadlines per program.
data: Record<string, ServiceProvidersData>;
avatarUrls: Record<string, string>;
};

Expand All @@ -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,
Expand All @@ -47,20 +50,17 @@ export const parseProgramConfig = (
.filter((q): q is ParsedQuarter => q !== null),
});

const collectYears = (
programs: Record<string, ProgramDefinition>,
): 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),
),
];

const buildQuarterReport = (
year: number,
quarter: QuarterKey,
reports: Record<string, string>,
reportDueDays: number,
now: Date,
): QuarterReport => {
const reportUrl = reports[`${year}/${quarter}`];
Expand All @@ -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 <T>(path: string): Promise<T> => {
const response = await fetch(`${GITHUB_RAW_BASE}/${path}`);
if (!response.ok) {
Expand Down Expand Up @@ -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),
]),
);

Expand Down
Loading