diff --git a/sites/main-site/tests/newsletter.spec.ts b/sites/main-site/tests/newsletter.spec.ts index 76aa91c560..eb3d4111e7 100644 --- a/sites/main-site/tests/newsletter.spec.ts +++ b/sites/main-site/tests/newsletter.spec.ts @@ -22,7 +22,7 @@ test("getMonthName maps 1-indexed months to English names", () => { expect(getMonthName(12)).toBe("December"); }); -test("getNewsletterMonths dedupes, sorts newest-first, and drops future months", () => { +test("getNewsletterMonths seeds the reporting newsletter, dedupes, sorts newest-first, drops future months", () => { const future = new Date(); future.setFullYear(future.getFullYear() + 5); @@ -30,13 +30,19 @@ test("getNewsletterMonths dedupes, sorts newest-first, and drops future months", [blog("a", "2020-03-10"), blog("future", future.toISOString())], [evt("e", "2020-05-20")], [], // pipelines - [advisory("adv", "2020-05-02")], // same month as the event -> must dedupe + [advisory("adv", "2020-05-02")], ); - // 2020-05 appears once despite event + advisory; future month excluded. + // Backward-looking content is reported by the *next* month's newsletter: + // the March blog -> April, the May advisory -> June. + expect(months).toContainEqual({ year: 2020, month: 4 }); + expect(months).toContainEqual({ year: 2020, month: 6 }); + // An event seeds its own month (this month) and the previous month (next + // month's "upcoming events"): the May event -> May and April. expect(months).toContainEqual({ year: 2020, month: 5 }); - expect(months).toContainEqual({ year: 2020, month: 3 }); - expect(months.filter((m) => m.year === 2020 && m.month === 5)).toHaveLength(1); + // April is seeded by both the March blog and the May event -> still one entry. + expect(months.filter((m) => m.year === 2020 && m.month === 4)).toHaveLength(1); + // Future months excluded. expect(months.some((m) => m.year === future.getFullYear())).toBe(false); // Sorted strictly newest-first. diff --git a/sites/main-site/utils/newsletter.ts b/sites/main-site/utils/newsletter.ts index f5fd98b80f..c2d5852556 100644 --- a/sites/main-site/utils/newsletter.ts +++ b/sites/main-site/utils/newsletter.ts @@ -95,7 +95,11 @@ function adjacentMonths(year: number, month: number, count: number, step: 1 | -1 // ======================================== /** - * Get all months that have any newsletter content, sorted descending. + * Candidate newsletter months, sorted newest-first and limited to past/current months. + * + * Seeded from the months that *report* each item, not the months it's dated in, so the + * newsletter that renders an item is always in the set. Callers filter this further with + * newsletterMonthHasContent to drop candidates whose sections would all be empty. */ export function getNewsletterMonths( blogPosts: { data: { pubDate: Date } }[], @@ -104,31 +108,38 @@ export function getNewsletterMonths( advisories: { data: { publishedDate: Date } }[] = [], ): NewsletterMonth[] { const monthSet = new Set(); + const add = (year: number, month: number) => monthSet.add(`${year}-${month}`); + + // Seeds the newsletter month(s) an item's month is reported in, relative to its own month: + // [1] – content (posts, advisories, releases, new pipelines): month X → newsletter X+1. + // [0, -1] – events: month X → newsletters X (this month) and X-1 (upcoming events). + const seedFrom = (date: Date, offsets: number[]) => { + const anchor = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 12)); + for (const offset of offsets) { + const d = addMonths(anchor, offset); + add(d.getUTCFullYear(), d.getUTCMonth() + 1); + } + }; for (const post of blogPosts) { - const d = new Date(post.data.pubDate); - monthSet.add(`${d.getUTCFullYear()}-${d.getUTCMonth() + 1}`); + seedFrom(new Date(post.data.pubDate), [1]); } for (const event of events) { - const d = new Date(event.data.start); - monthSet.add(`${d.getUTCFullYear()}-${d.getUTCMonth() + 1}`); + seedFrom(new Date(event.data.start), [0, -1]); } for (const advisory of advisories) { - const d = new Date(advisory.data.publishedDate); - monthSet.add(`${d.getUTCFullYear()}-${d.getUTCMonth() + 1}`); + seedFrom(new Date(advisory.data.publishedDate), [1]); } for (const pipeline of pipelines) { for (const release of pipeline.releases) { if (release.tag_name === "dev") continue; - const d = new Date(release.published_at); - monthSet.add(`${d.getUTCFullYear()}-${d.getUTCMonth() + 1}`); + seedFrom(new Date(release.published_at), [1]); } - // Also include month when pipeline was created - const created = new Date(pipeline.created_at); - monthSet.add(`${created.getUTCFullYear()}-${created.getUTCMonth() + 1}`); + // Also seed from the month the pipeline was created (new-pipeline section). + seedFrom(new Date(pipeline.created_at), [1]); } const months: NewsletterMonth[] = [...monthSet].map((key) => { @@ -152,14 +163,10 @@ export function getNewsletterMonths( /** * Whether the newsletter dated the 1st of (year, month) would render any content. * - * The candidate-month set above is seeded from the months that content lands in, - * but a newsletter looks *back* at the previous calendar month (blog posts, - * advisories, releases, new pipelines, recent events, proposals) and *forward* at - * this + next month (upcoming events). That offset means a seeded month can end up - * with every section empty — e.g. a lone advisory dated in a month whose preceding - * month has nothing. This mirrors the section gating in NewsletterLayout so we only - * keep months where at least one section would actually render, and never publish a - * page that is just the header and footer. + * A newsletter looks *back* a month (posts, advisories, releases, new pipelines, recent + * events, proposals) and *forward* (upcoming events), so a seeded candidate month can still + * end up empty — e.g. a lone advisory whose preceding month has nothing. This mirrors the + * section gating in NewsletterLayout so we never publish a page with just a header/footer. */ export function newsletterMonthHasContent( blogPosts: { id: string; data: { pubDate: Date } }[],