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
16 changes: 11 additions & 5 deletions sites/main-site/tests/newsletter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,27 @@ 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);

const months = getNewsletterMonths(
[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.
Expand Down
47 changes: 27 additions & 20 deletions sites/main-site/utils/newsletter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }[],
Expand All @@ -104,31 +108,38 @@ export function getNewsletterMonths(
advisories: { data: { publishedDate: Date } }[] = [],
): NewsletterMonth[] {
const monthSet = new Set<string>();
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) => {
Expand All @@ -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 } }[],
Expand Down
Loading