diff --git a/src/content.config.ts b/src/content.config.ts index 7585623..15e9d6b 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -2,7 +2,7 @@ import { defineCollection, z } from "astro:content"; import { glob } from 'astro/loaders'; const posts = defineCollection({ - loader: glob({ pattern: "**/*.mdx", base: "./src/content/posts" }), + loader: glob({ pattern: "{essays,news,opinion}/**/*.mdx", base: "./src/content/posts" }), schema: z.object({ title: z.string(), date: z.coerce.date(), @@ -21,7 +21,18 @@ const pages = defineCollection({ }) }); +const changelogs = defineCollection({ + loader: glob({ pattern: "**/*.mdx", base: "./src/content/posts/changelogs" }), + schema: z.object({ + title: z.string(), + date: z.coerce.date(), + author: z.string(), + excerpt: z.string().optional() + }) +}); + export const collections = { posts, - pages + pages, + changelogs }; \ No newline at end of file diff --git a/src/content/posts/changelogs/ysws-progress-report-1.mdx b/src/content/posts/changelogs/ysws-progress-report-1.mdx new file mode 100644 index 0000000..feee04a --- /dev/null +++ b/src/content/posts/changelogs/ysws-progress-report-1.mdx @@ -0,0 +1,63 @@ +--- +title: "YSWS Quality & Integrity Progress Report #1" +date: '2026-04-29' +author: "@lfd" +--- +**What happened** + +Since March, 20,630 projects have gone through the spot check pipeline. Most of that is automated, a script doing the tedious work of checking fields and flagging obvious issues. The numbers are: 426 rejected on first review, 97 fines issued, 207 projects eliminated from the database. + +**How spot checks work now** + +When I started doing this, the process was basically: open a project, look at it, decide if it's real. That works when you have fifty projects. It does not work when you have twenty thousand. + +So I split it into three passes. + +The first pass is automated. A script runs through every project and checks it against the rules at [hack.club/ysws-rules](https://hack.club/ysws-rules). Missing fields, incomplete data, hours that don't add up, obvious red flags. + +The second pass is where I come back in. The automated process isn't perfect. It flags things that turn out to be edge cases, and occasionally it misses things that aren't. So I re-review every conflict the script surfaced, correct the false positives, and catch what slipped through. + +The third pass is the real work. I open the repo. I read the commits. I look at the Hackatime heartbeats. And I decide whether the hours someone logged actually match the work that's there. This is the pass that catches hour inflation, projects that look polished on the surface but have three commits and forty hours logged, projects where the code was clearly generated in an afternoon but the approved hours say otherwise. + +14,236 checks happened in March. 6,393 so far in April. The volume is high because we're working through the backlog program by program, and there are a lot of programs. + +**Programs in 2026** + +Right now, ten programs account for the bulk of approved projects this year. Here's where each one stands in the spot check process: + +| Program | Checked | Rejected | Fined | Eliminated | +| ---------------------- | ------: | -------: | ----: | ---------: | +| Campfire Satellites \* | 2,561 | 40 | 0 | 0 | +| Blueprint \* | 1,652 | 13 | 0 | 0 | +| Siege | 1,255 | 12 | 2 | 3 | +| Flavortown | 1,160 | 28 | 1 | 2 | +| Midnight | 592 | 0 | 1 | 6 | +| Sleepover | 441 | 0 | 1 | 2 | +| Construct \* | 369 | 10 | 0 | 0 | +| Milkyway | 340 | 0 | 0 | 3 | +| Stasis | 320 | 0 | 4 | 43 | +| HC: The Game | 271 | 0 | 1 | 3 | + +\* Only the first automated pass is complete. Second and third passes have not started yet. + +Not every program has been through all three passes. Campfire Satellites, Blueprint, and Construct have only had the automated check so far. Their rejection, fine, and elimination numbers are going to change once I actually sit down with the repos and the commit histories. The programs further down the table, like Stasis, Midnight, and Flavortown, have been partially through the full process, and that's where you see the fines and eliminations showing up. + +These numbers are going to look different in a few weeks. There are a lot of projects still waiting for a human to actually look at them. + +**Fines** + +97 fines have been issued so far. That's 742 hours removed from the database and $2,527 in fine money collected. + +Here's how the math works. When a spot check finds hours that shouldn't be in the database, those hours get removed and the program gets fined $12 per hour. The program can earn $8.50 of that back by properly re-processing the project: going back to the submitter, getting the issue fixed, and re-submitting it through the normal review flow. The remaining $3.50 is permanent. + +**Eliminations** + +207 projects have been eliminated from the database. These are projects that were approved but shouldn't have been at all: fraudulent submissions, duplicates, projects that didn't meet the minimum bar. 1,328 hours removed in 2026. + +The elimination rate is about 2.7% of this year's approvals. Low enough that fraud isn't a systemic problem, but high enough that it clearly exists and needs to be actively watched. + +**What's next** + +There are thousands of projects sitting in the queue waiting for a human to open the repo and actually look. That's going to take a while, and the numbers in this report are going to shift as it happens. :) + +--- diff --git a/src/data/changelog.json b/src/data/changelog.json index cbcc19b..f593590 100644 --- a/src/data/changelog.json +++ b/src/data/changelog.json @@ -2,19 +2,16 @@ { "change": "Astronomical potential", "date": "2026-04-15", - "author": "@matmanna", - "slackId": "U07VA44DNBA" + "author": "@matmanna" }, { "change": "Added RSS feed", "date": "2026-03-18", - "author": "@rowan", - "slackId": "U080A3QP42C" + "author": "@rowan" }, { "change": "Site launched", "date": "2026-03-09", - "author": "@eps", - "slackId": "U09Q8MLTE58" + "author": "@eps" } ] diff --git a/src/lib/content.ts b/src/lib/content.ts index 2d370fd..1276818 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -35,13 +35,32 @@ export type Post = { followUps?: PostReference[]; }; -export type ChangelogEntry = { - change: string; +type ChangelogBase = { date: string; author: string; - slackId?: string; }; +export type ShortChangelogEntry = ChangelogBase & { + kind: "short"; + change: string; +}; + +export type LongChangelogEntry = ChangelogBase & { + kind: "long"; + slug: string; + url: string; + title: string; + excerpt: string; + paragraphs: string[]; + leadingImage?: { + src: string; + alt: string; + }; + entry: CollectionEntry<"changelogs">; +}; + +export type ChangelogEntry = ShortChangelogEntry | LongChangelogEntry; + export type Acknowledgement = { name: string; slackId?: string; @@ -226,7 +245,36 @@ export async function getFrontpageData(): Promise { } export async function getChangelogEntries(): Promise { - return changelogData; + const shortEntries: ShortChangelogEntry[] = changelogData.map((entry) => ({ + kind: "short", + change: entry.change, + date: entry.date, + author: entry.author + })); + + const longCollection = await getCollection("changelogs"); + const longEntries: LongChangelogEntry[] = longCollection.map((entry) => { + const bodyBlocks = getBodyBlocks(entry.body!); + const leadingImage = extractImageFromBlock(bodyBlocks[0] ?? ""); + const paragraphs = extractTextBlocks(entry.body!); + const dateString = entry.data.date.toISOString().slice(0, 10); + return { + kind: "long", + slug: entry.id, + url: `/changelog/${entry.id}/`, + title: entry.data.title, + date: dateString, + author: entry.data.author, + excerpt: toExcerpt({ body: entry.body!, data: entry.data }), + paragraphs, + leadingImage, + entry + }; + }); + + return [...shortEntries, ...longEntries].sort( + (a, b) => new Date(`${b.date}T00:00:00Z`).getTime() - new Date(`${a.date}T00:00:00Z`).getTime() + ); } export async function getAcknowledgements(): Promise { diff --git a/src/pages/changelog/[slug].astro b/src/pages/changelog/[slug].astro new file mode 100644 index 0000000..3cd84ca --- /dev/null +++ b/src/pages/changelog/[slug].astro @@ -0,0 +1,60 @@ +--- +import BaseLayout from "../../layouts/BaseLayout.astro"; +import { formatStoryDate, getChangelogEntries, getSiteConfig, type LongChangelogEntry } from "../../lib/content"; +import { render } from "astro:content"; + +export async function getStaticPaths() { + const entries = await getChangelogEntries(); + return entries + .filter((entry): entry is LongChangelogEntry => entry.kind === "long") + .map((entry) => ({ + params: { slug: entry.slug }, + props: { slug: entry.slug } + })); +} + +const site = await getSiteConfig(); +const entries = await getChangelogEntries(); +const slug = Astro.props?.slug ?? Astro.params.slug; + +const changelog = entries.find( + (entry): entry is LongChangelogEntry => entry.kind === "long" && entry.slug === slug +); +if (!changelog) { + throw new Error(`No longform changelog found for slug "${slug}"`); +} + +const { Content } = await render(changelog.entry); +--- + + +
+
+

{changelog.title}

+ +
+
+ +
+ +
+ + +
+
diff --git a/src/pages/changelogs.astro b/src/pages/changelogs.astro index 0e427ac..6d658e6 100644 --- a/src/pages/changelogs.astro +++ b/src/pages/changelogs.astro @@ -1,7 +1,6 @@ --- import BaseLayout from "../layouts/BaseLayout.astro"; -import SlackMention from "../components/SlackMention.astro"; -import { getChangelogEntries, getSiteConfig } from "../lib/content"; +import { getChangelogEntries, getSiteConfig, truncateWords } from "../lib/content"; const site = await getSiteConfig(); const changelogEntries = await getChangelogEntries(); @@ -12,17 +11,52 @@ const changelogEntries = await getChangelogEntries();
- {changelogEntries.map((entry) => ( -
-

{entry.change}

-

- {entry.date} · {(() => { - const authorName = entry.author.trim().replace(/^@+/, ""); - return entry.slackId ? : entry.author; - })()} -

-
- ))} + {changelogEntries.map((entry) => { + if (entry.kind === "long") { + return ( + +

{entry.title}

+

{truncateWords(entry.excerpt, 18)}

+

{entry.date} · {entry.author}

+
+ ); + } + + return ( +
+ {entry.change} + + {entry.date} · {entry.author} + +
+ ); + })}
+ + diff --git a/src/pages/feed.xml.js b/src/pages/feed.xml.js index f220b26..c066bff 100644 --- a/src/pages/feed.xml.js +++ b/src/pages/feed.xml.js @@ -1,5 +1,5 @@ import rss from "@astrojs/rss"; -import { getPosts, getSiteConfig } from "../lib/content"; +import { getChangelogEntries, getPosts, getSiteConfig } from "../lib/content"; import { legacyRedirects } from "../data/legacy-redirects.mjs"; const legacyPaths = new Set(Object.keys(legacyRedirects).map((path) => path.replace(/\/$/, ""))); @@ -14,27 +14,50 @@ function escapeHtml(input) { export async function GET(context) { const site = await getSiteConfig(); const posts = await getPosts(); + const changelogs = await getChangelogEntries(); - return rss({ - title: site.title, - description: site.description, - site: context.site, - items: posts.map((post) => { - const baseSlug = post.slug.split("/").pop(); - const legacyKey = baseSlug ? `/${baseSlug}` : null; - const legacyLink = legacyKey && legacyPaths.has(legacyKey) ? `${legacyKey}/` : post.url; + const postItems = posts.map((post) => { + const baseSlug = post.slug.split("/").pop(); + const legacyKey = baseSlug ? `/${baseSlug}` : null; + const legacyLink = legacyKey && legacyPaths.has(legacyKey) ? `${legacyKey}/` : post.url; + + const paragraphContent = post.paragraphs.length + ? post.paragraphs.map((paragraph) => `

${escapeHtml(paragraph)}

`).join("") + : `

${escapeHtml(post.excerpt)}

`; + + return { + title: post.title, + description: post.excerpt, + pubDate: post.date, + link: legacyLink, + content: paragraphContent + }; + }); - const paragraphContent = post.paragraphs.length - ? post.paragraphs.map((paragraph) => `

${escapeHtml(paragraph)}

`).join("") - : `

${escapeHtml(post.excerpt)}

`; + const longChangelogItems = changelogs + .filter((entry) => entry.kind === "long") + .map((entry) => { + const paragraphContent = entry.paragraphs.length + ? entry.paragraphs.map((paragraph) => `

${escapeHtml(paragraph)}

`).join("") + : `

${escapeHtml(entry.excerpt)}

`; return { - title: post.title, - description: post.excerpt, - pubDate: post.date, - link: legacyLink, + title: entry.title, + description: entry.excerpt, + pubDate: new Date(`${entry.date}T00:00:00Z`), + link: entry.url, content: paragraphContent }; - }) + }); + + const items = [...postItems, ...longChangelogItems].sort( + (a, b) => b.pubDate.getTime() - a.pubDate.getTime() + ); + + return rss({ + title: site.title, + description: site.description, + site: context.site, + items }); } diff --git a/src/pages/index.astro b/src/pages/index.astro index d70ee09..a68d6a8 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -112,12 +112,13 @@ const acknowledgements = await getAcknowledgements(); {recentChangelog.map((entry, index) => ( <>
- {entry.change} + {entry.kind === "long" ? ( + {entry.title} + ) : ( + {entry.change} + )} - {entry.date} · {(() => { - const authorName = entry.author.trim().replace(/^@+/, ""); - return entry.slackId ? : entry.author; - })()} + {entry.date} · {entry.author}
{index < recentChangelog.length - 1 &&
} diff --git a/src/styles/main.scss b/src/styles/main.scss index 8ac8163..398285c 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -307,6 +307,14 @@ a { line-height: 1.3; } + .changelog-change-link { + color: inherit; + text-decoration: none; + + &:visited { color: inherit; } + &:hover { text-decoration: underline; } + } + .changelog-meta { font-size: 0.82rem; color: #555;