From 6e764c616a632d280d50fa1d8af74dc7c4dd75f8 Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Thu, 3 Sep 2026 15:46:43 +0200 Subject: [PATCH 01/18] feat: add multi-contributor credit system for adventures - Distinguish Adventure Designer (ideator) from Challenge Builder (level builder) in contributor badges and sidebar labels - Auto-derive Challenge Builders and Adventure Designers leaderboard sections from adventure YAML data instead of Discourse badges - Add discourse_username field to contributor schema for Discourse avatar cross-referencing - Restore challenge-builders/challenge-grand-builders sections to community-leaders.json refresh script so Discourse avatar cache remains populated - Sort Challenge Builders section by number of adventures contributed (descending) - Add Adventure Designers to challenges page leaderboard (end of list) - Add lightbulb icon for Adventure Designers section - Remove ADVENTURE_CONTRIBUTORS static list from team.ts; source all contributor data from the content collection Signed-off-by: Sinduri Guntupalli --- scripts/refresh-community-leaders.mjs | 40 ++++---- src/components/AdventureCard.astro | 15 ++- src/components/ChallengeBuildersSection.astro | 90 ++++++++++++++++-- src/components/CommunityLeaders.astro | 91 ++++++++++++++++++- src/components/CommunitySidebar.astro | 12 ++- src/components/ContributorBadge.astro | 89 ++++++++++++------ src/content.config.ts | 13 ++- .../adventures/blind-by-design/adventure.yaml | 1 + .../building-cloudhaven/adventure.yaml | 1 + .../adventures/dead-reckoning/adventure.yaml | 1 + .../echoes-lost-in-orbit/adventure.yaml | 1 + .../adventures/lex-imperfecta/adventure.yaml | 1 + .../the-ai-observatory/adventure.yaml | 1 + src/data/adventures/types.ts | 11 +-- src/data/community-leaders.json | 22 ++--- .../solutions/echoes-lost-in-orbit/expert.ts | 3 +- .../echoes-lost-in-orbit/intermediate.ts | 3 +- src/data/team.ts | 25 ----- src/lib/community-leaders.ts | 3 +- src/lib/lucide-icons.ts | 2 + src/pages/about.astro | 2 +- src/pages/adventures/[id].astro | 67 ++++++++++++-- .../adventures/[id]/levels/[levelId].astro | 1 + .../[id]/levels/[levelId]/solution.astro | 29 ++++-- src/pages/adventures/index.astro | 2 +- src/pages/challenges/[...tag].astro | 2 +- src/pages/contribute.astro | 2 +- 27 files changed, 400 insertions(+), 130 deletions(-) diff --git a/scripts/refresh-community-leaders.mjs b/scripts/refresh-community-leaders.mjs index 33b1a0692..699011d31 100644 --- a/scripts/refresh-community-leaders.mjs +++ b/scripts/refresh-community-leaders.mjs @@ -175,14 +175,14 @@ async function main() { const cAvatarId = col(cCols, "uploaded_avatar_id"); // Challenge query column indices - const chCols = challenge.columns; - const chUsername = col(chCols, "username"); - const chSolveCount = col(chCols, "solve_count"); - const chIsGrandBuilder = col(chCols, "is_grand_builder"); - const chChallengesCreated = col(chCols, "challenges_created"); + const chCols = challenge.columns; + const chUsername = col(chCols, "username"); + const chSolveCount = col(chCols, "solve_count"); + const chIsRockstar = col(chCols, "is_rockstar"); + const chIsGrandBuilder = col(chCols, "is_grand_builder"); + const chChallengesCreated = col(chCols, "challenges_created"); const chIsChallengeBuilder = col(chCols, "is_challenge_builder"); - const chIsRockstar = col(chCols, "is_rockstar"); - const chAvatarId = col(chCols, "uploaded_avatar_id"); + const chAvatarId = col(chCols, "uploaded_avatar_id"); const cRows = community.rows; const chRows = challenge.rows; @@ -215,27 +215,29 @@ async function main() { username: String(r[chUsername]), avatarUrl: buildAvatarUrl(String(r[chUsername]), r[chAvatarId] ?? null), count: Number(r[chChallengesCreated]), - })); + })) + .sort((a, b) => b.count - a.count) + .slice(0, POOL_SIZE); const builders = chRows - .filter((r) => r[chIsChallengeBuilder] && !r[chIsGrandBuilder] && !r[chIsRockstar]) - .sort((a, b) => Number(b[chChallengesCreated]) - Number(a[chChallengesCreated])) - .slice(0, POOL_SIZE) + .filter((r) => r[chIsChallengeBuilder] && !r[chIsGrandBuilder]) .map((r) => ({ username: String(r[chUsername]), avatarUrl: buildAvatarUrl(String(r[chUsername]), r[chAvatarId] ?? null), count: Number(r[chChallengesCreated]), - })); + })) + .sort((a, b) => b.count - a.count) + .slice(0, POOL_SIZE); const sections = [ - { id: "top-contributors", title: "Top Contributors", users: topByCol(cRows, cUsername, cTopics, cAvatarId, TOP_N) }, - { id: "challenge-rockstars", title: "Challenge Rockstars", users: rockstars }, + { id: "top-contributors", title: "Top Contributors", users: topByCol(cRows, cUsername, cTopics, cAvatarId, TOP_N) }, + { id: "challenge-rockstars", title: "Challenge Rockstars", users: rockstars }, { id: "challenge-grand-builders", title: "Challenge Grand Builders", users: grandBuilders }, - { id: "top-challenge-solvers", title: "Top Challenge Solvers", users: solvers }, - { id: "challenge-builders", title: "Challenge Builders", users: builders }, - { id: "most-liked", title: "Most Liked", users: topByCol(cRows, cUsername, cLikesReceived, cAvatarId, TOP_N) }, - { id: "most-replies", title: "Most Replies", users: topByCol(cRows, cUsername, cReplies, cAvatarId, TOP_N) }, - { id: "most-supportive", title: "Most Supportive", users: topByCol(cRows, cUsername, cLikesGiven, cAvatarId, TOP_N) }, + { id: "challenge-builders", title: "Challenge Builders", users: builders }, + { id: "top-challenge-solvers", title: "Top Challenge Solvers", users: solvers }, + { id: "most-liked", title: "Most Liked", users: topByCol(cRows, cUsername, cLikesReceived, cAvatarId, TOP_N) }, + { id: "most-replies", title: "Most Replies", users: topByCol(cRows, cUsername, cReplies, cAvatarId, TOP_N) }, + { id: "most-supportive", title: "Most Supportive", users: topByCol(cRows, cUsername, cLikesGiven, cAvatarId, TOP_N) }, ].filter((s) => s.users.length > 0); for (const s of sections) { diff --git a/src/components/AdventureCard.astro b/src/components/AdventureCard.astro index c61ef1ebd..74ff7cc7e 100644 --- a/src/components/AdventureCard.astro +++ b/src/components/AdventureCard.astro @@ -15,11 +15,22 @@ interface Props { tags: string[]; icon?: string; isLive?: boolean; - levels: { id: string; difficulty: Difficulty }[]; + levels: { id: string; difficulty: Difficulty; contributor?: { name: string; url?: string } }[]; contributor?: { name: string; url?: string }; }; } const { adventure } = Astro.props; + +let cardPillBuilder: { name: string; url?: string } | undefined; +let cardHasBuilders = false; +if (adventure.contributor) { + const levelBuilders = adventure.levels.filter((l) => l.contributor).map((l) => l.contributor!); + const uniqueNames = [...new Set(levelBuilders.map((b) => b.name))]; + if (uniqueNames.length === 1 && uniqueNames[0] !== adventure.contributor.name) { + cardPillBuilder = levelBuilders.find((b) => b.name === uniqueNames[0]); + } + cardHasBuilders = uniqueNames.length > 1; +} const base = import.meta.env.BASE_URL; const difficulties = adventure.levels.map((l) => l.difficulty).join(", "); const tagList = adventure.tags.slice(0, 4).join(", "); @@ -70,7 +81,7 @@ const label = tagList {adventure.contributor && (
- +
)} diff --git a/src/components/ChallengeBuildersSection.astro b/src/components/ChallengeBuildersSection.astro index 66a318e56..554dc7ed6 100644 --- a/src/components/ChallengeBuildersSection.astro +++ b/src/components/ChallengeBuildersSection.astro @@ -1,11 +1,89 @@ --- -import { ADVENTURE_CONTRIBUTORS } from "@/data/team"; +import { getCollection } from "astro:content"; import PersonNameLink from "@/components/PersonNameLink.astro"; import InlineProse from "@/components/InlineProse.astro"; const base = import.meta.env.BASE_URL; const hasAside = Astro.slots.has("aside"); -const contributors = ADVENTURE_CONTRIBUTORS; + +const ROLE_ORDER: Record = { Proposed: 0, Beginner: 1, Intermediate: 2, Expert: 3 }; + +type Contribution = { id: string; title: string; roles: string[]; totalLevels: number }; +type ContributorEntry = { + name: string; + url?: string; + aboutHtml?: string; + contributions: Contribution[]; +}; + +const adventures = await getCollection("adventures"); +const map = new Map(); + +function getOrCreate(name: string): ContributorEntry { + if (!map.has(name)) map.set(name, { name, contributions: [] }); + return map.get(name)!; +} + +for (const { id, data } of adventures) { + const { title } = data; + const totalLevels = data.levels.length; + + if (data.contributor) { + const entry = getOrCreate(data.contributor.name); + entry.url ??= data.contributor.url; + entry.aboutHtml ??= data.contributor.aboutHtml; + const existing = entry.contributions.find((c) => c.id === id); + if (existing) { + if (!existing.roles.includes("Proposed")) existing.roles.unshift("Proposed"); + } else { + entry.contributions.push({ id, title, roles: ["Proposed"], totalLevels }); + } + } + + const hasLevelContributors = data.levels.some((l) => l.contributor); + + for (const level of data.levels) { + // When no level has an explicit contributor, the adventure contributor built everything. + const levelContributor = level.contributor ?? (!hasLevelContributors ? data.contributor : undefined); + if (!levelContributor) continue; + const entry = getOrCreate(levelContributor.name); + entry.url ??= levelContributor.url; + entry.aboutHtml ??= levelContributor.aboutHtml; + const existing = entry.contributions.find((c) => c.id === id); + if (existing) { + if (!existing.roles.includes(level.difficulty)) existing.roles.push(level.difficulty); + } else { + entry.contributions.push({ id, title, roles: [level.difficulty], totalLevels }); + } + } +} + +function formatRoles(roles: string[], totalLevels: number): string { + const levelRoles = roles.filter((r) => r !== "Proposed"); + const hasProposed = roles.includes("Proposed"); + if (hasProposed && levelRoles.length > 0) { + return levelRoles.length === totalLevels + ? "Proposed & Built" + : `Proposed & Built · ${levelRoles.join(" · ")}`; + } + if (hasProposed) return "Proposed"; + return `Built · ${levelRoles.join(" · ")}`; +} + +const contributors = [...map.values()] + .map((c) => ({ + ...c, + contributions: c.contributions + .sort((a, b) => a.title.localeCompare(b.title)) + .map((contrib) => ({ + ...contrib, + roles: [...contrib.roles].sort( + (a, b) => (ROLE_ORDER[a] ?? 99) - (ROLE_ORDER[b] ?? 99), + ), + roleLabel: formatRoles(contrib.roles, contrib.totalLevels), + })), + })) + .sort((a, b) => b.contributions.length - a.contributions.length || a.name.localeCompare(b.name)); --- {contributors.length > 0 && ( @@ -24,10 +102,10 @@ const contributors = ADVENTURE_CONTRIBUTORS; {contributor.aboutHtml && ( )} -

adventures created

-
    - {contributor.adventures.map(({ id, title }) => ( -
  • +

    Adventures contributed to

    +
      + {contributor.contributions.map(({ id, title }) => ( +
    • diff --git a/src/components/CommunityLeaders.astro b/src/components/CommunityLeaders.astro index 483de901d..709016f29 100644 --- a/src/components/CommunityLeaders.astro +++ b/src/components/CommunityLeaders.astro @@ -1,4 +1,5 @@ --- +import { getCollection } from "astro:content"; import { LUCIDE_ICONS, type LucideIconKey } from "@/lib/lucide-icons"; import communityLeadersRaw from "@/data/community-leaders.json"; import AvatarLink from "@/components/AvatarLink.astro"; @@ -25,12 +26,100 @@ const SECTION_ICON_NAMES = { "challenge-rockstars": "star", "challenge-grand-builders": "building-2", "challenge-builders": "wrench", + "adventure-designers": "lightbulb", "most-liked": "heart", "most-replies": "message-circle", "most-supportive": "hand-heart", } satisfies Record; -const { sections: ALL_SECTIONS } = parseCommunityLeadersData(communityLeadersRaw); +// Derive builder and designer sections from the adventure collection. +// Count = total challenges (levels) built per person. Grand Builder threshold: > 12 challenges. +// When no level has an explicit contributor, the adventure contributor built all levels. +const GRAND_BUILDER_THRESHOLD = 12; +const adventures = await getCollection("adventures"); + +// Build a lookup of Discourse username → avatarUrl from all Discourse-fetched sections. +const { sections: discourseSections } = parseCommunityLeadersData(communityLeadersRaw); +const discourseAvatarMap = new Map(); +for (const section of discourseSections) { + for (const user of section.users) { + if (user.avatarUrl && !discourseAvatarMap.has(user.username)) { + discourseAvatarMap.set(user.username, user.avatarUrl); + } + } +} + +// Resolve avatar for a contributor: real Discourse photo if cached, else letter avatar, else undefined. +function resolveAvatar(discourseUsername?: string): string | undefined { + if (!discourseUsername) return undefined; + if (discourseAvatarMap.has(discourseUsername)) return discourseAvatarMap.get(discourseUsername); + return `https://avatars.discourse-cdn.com/v4/letter/${discourseUsername.charAt(0).toLowerCase()}/b5a626/40.png`; +} + +// name → { discourseUsername, challengeCount } +type BuilderEntry = { discourseUsername?: string; count: number }; +const challengeCountMap = new Map(); + +// name → { discourseUsername, adventureCount } +type DesignerEntry = { discourseUsername?: string; count: number }; +const designerCountMap = new Map(); + +for (const { data } of adventures) { + const hasLevelContributors = data.levels.some((l) => l.contributor); + + if (data.contributor) { + const { name, discourseUsername } = data.contributor; + const existing = designerCountMap.get(name) ?? { discourseUsername, count: 0 }; + existing.discourseUsername ??= discourseUsername; + existing.count++; + designerCountMap.set(name, existing); + } + + for (const level of data.levels) { + const c = level.contributor ?? (!hasLevelContributors ? data.contributor : undefined); + if (!c) continue; + const existing = challengeCountMap.get(c.name) ?? { discourseUsername: c.discourseUsername, count: 0 }; + existing.discourseUsername ??= c.discourseUsername; + existing.count++; + challengeCountMap.set(c.name, existing); + } +} + +const allBuilders = [...challengeCountMap.entries()] + .filter(([, e]) => e.count > 0) + .sort((a, b) => b[1].count - a[1].count) + .map(([username, e]) => ({ username, avatarUrl: resolveAvatar(e.discourseUsername), count: e.count })); + +const adventureSections: LeaderSection[] = ( + [ + { + id: "challenge-grand-builders" as const, + title: "Challenge Grand Builders", + users: allBuilders.filter((u) => u.count > GRAND_BUILDER_THRESHOLD), + }, + { + id: "challenge-builders" as const, + title: "Challenge Builders", + users: allBuilders.filter((u) => u.count <= GRAND_BUILDER_THRESHOLD), + }, + { + id: "adventure-designers" as const, + title: "Adventure Designers", + users: [...designerCountMap.entries()] + .sort((a, b) => b[1].count - a[1].count) + .map(([username, e]) => ({ username, avatarUrl: resolveAvatar(e.discourseUsername), count: e.count })), + }, + ] satisfies LeaderSection[] +).filter((s) => s.users.length > 0); + +const ADVENTURE_SECTION_IDS = new Set(["challenge-grand-builders", "challenge-builders", "adventure-designers"]); +const ALL_SECTIONS: LeaderSection[] = [ + ...discourseSections.filter((s) => !ADVENTURE_SECTION_IDS.has(s.id)), + ...adventureSections, +].sort((a, b) => + (Object.keys(SECTION_ICON_NAMES) as SectionId[]).indexOf(a.id as SectionId) - + (Object.keys(SECTION_ICON_NAMES) as SectionId[]).indexOf(b.id as SectionId), +); const visibleSections = ( sectionFilter diff --git a/src/components/CommunitySidebar.astro b/src/components/CommunitySidebar.astro index c617483d9..9c0d42650 100644 --- a/src/components/CommunitySidebar.astro +++ b/src/components/CommunitySidebar.astro @@ -30,10 +30,14 @@ interface Props { levelId: string; discussionUrl: string; contributor?: { name: string; url?: string }; + levelContributor?: { name: string; url?: string }; discussion: Discussion | null; leaderboardRows: LeaderboardRow[]; } -const { levelId, discussionUrl, contributor, discussion, leaderboardRows } = Astro.props; +const { levelId, discussionUrl, contributor, levelContributor, discussion, leaderboardRows } = Astro.props; +// On a level page, credit whoever built this specific level. +// Fall back to the adventure contributor if no level-specific builder is set. +const levelBuilder = levelContributor ?? contributor; const hasThread = !!discussionUrl && discussionUrl !== COMMUNITY_URL; const solvers: NonNullable = discussion?.solvers ?? []; @@ -65,11 +69,11 @@ const hasActivity = visible.length > 0;

      Community

      - {/* Challenge builder */} + {/* Level builder */} { - contributor && ( + levelBuilder && (
      - +
      ) } diff --git a/src/components/ContributorBadge.astro b/src/components/ContributorBadge.astro index 091db9c5d..5cdb21df2 100644 --- a/src/components/ContributorBadge.astro +++ b/src/components/ContributorBadge.astro @@ -3,39 +3,72 @@ import IconHammer from "~icons/lucide/hammer"; import IconExternalLink from "~icons/lucide/external-link"; interface Props { - name: string; - url?: string; + proposer?: { name: string; url?: string }; + builder?: { name: string; url?: string }; glow?: boolean; - label?: string; + // Suppress tags — required when ContributorBadge is inside an card element. + noLinks?: boolean; + // True when level builders exist but aren't shown in this pill (e.g. multiple builders on a card). + // Forces "Adventure Designer" label instead of "Adventure Builder". + hasBuilders?: boolean; } -const { name, url, glow = false, label = "Challenge Builder" } = Astro.props; + +const { proposer, builder, glow = false, noLinks = false, hasBuilders = false } = Astro.props; + +const isCompound = proposer && builder && builder.name !== proposer.name; +const label = !proposer + ? "Challenge Builder" + : isCompound || (!builder && hasBuilders) + ? "Adventure Designer" + : "Adventure Builder"; +const primary = proposer ?? builder; + const pill = [ "contributor-pill inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/5 px-2.5 py-1 text-xs text-primary", glow && "contributor-pill-glow", ]; +const sep = "inline-block h-3 w-px bg-current opacity-40"; --- -{ - url ? ( - - - ) : ( - -
      diff --git a/src/pages/adventures/[id].astro b/src/pages/adventures/[id].astro index 1da762093..d9a943d84 100644 --- a/src/pages/adventures/[id].astro +++ b/src/pages/adventures/[id].astro @@ -44,6 +44,25 @@ const path = `/adventures/${adventure.slug}/`; // One trail, rendered visually by and as BreadcrumbList JSON-LD by // , so the two can never disagree. const crumbs = [{ label: "Adventures", href: "/adventures/" }, { label: adventure.title }]; + +// Build a map of level builders grouped by name, preserving insertion order (first level seen). +const levelBuilderMap = new Map(); +for (const level of adventure.levels) { + if (!level.contributor) continue; + const { name, url, aboutHtml } = level.contributor; + if (!levelBuilderMap.has(name)) levelBuilderMap.set(name, { name, url, aboutHtml, levels: [] }); + levelBuilderMap.get(name)!.levels.push(level.difficulty); +} +const levelBuilders = [...levelBuilderMap.values()]; +const proposerName = adventure.contributor?.name; +const hasSingleBuilder = levelBuilders.length === 1; +const singleBuilderSameAsProposer = hasSingleBuilder && levelBuilders[0].name === proposerName; +const proposedAndBuilt = levelBuilders.length === 0 || singleBuilderSameAsProposer; + +// Card pill: show builder only when exactly one level builder differs from proposer. +const titlePillBuilder = hasSingleBuilder && !singleBuilderSameAsProposer ? levelBuilders[0] : undefined; +// True when multiple distinct builders exist — pill shows "Proposed by" without listing them. +const titleHasBuilders = levelBuilders.length > 1 && !singleBuilderSameAsProposer; --- {adventure.contributor && ( )} {adventure.month} @@ -178,10 +197,44 @@ const crumbs = [{ label: "Adventures", href: "/adventures/" }, { label: adventur )} {adventure.contributor && (
      -

      Adventure by

      - - {adventure.contributor.aboutHtml && ( - +

      Contributors

      + {proposedAndBuilt ? ( +
      + + {adventure.contributor.aboutHtml && ( + + )} +
      + ) : ( +
      +
      +

      Adventure Designer

      + + {adventure.contributor.aboutHtml && ( + + )} +
      + {levelBuilders.length > 0 && ( +
      +

      Challenge Builders

      +
      + {levelBuilders.map((builder) => ( +
      + +
      + {builder.levels.map((diff) => ( + + ))} +
      + {builder.aboutHtml && ( + + )} +
      + ))} +
      +
      + )} +
      )}
      )} diff --git a/src/pages/adventures/[id]/levels/[levelId].astro b/src/pages/adventures/[id]/levels/[levelId].astro index 343698a73..01bcec06e 100644 --- a/src/pages/adventures/[id]/levels/[levelId].astro +++ b/src/pages/adventures/[id]/levels/[levelId].astro @@ -467,6 +467,7 @@ const crumbs = [ levelId={level.id} discussionUrl={level.discussionUrl} contributor={adventure.contributor} + levelContributor={level.contributor} discussion={discussion} leaderboardRows={leaderboard?.rows ?? []} /> diff --git a/src/pages/adventures/[id]/levels/[levelId]/solution.astro b/src/pages/adventures/[id]/levels/[levelId]/solution.astro index 1d95eeb9d..f28a5ccd9 100644 --- a/src/pages/adventures/[id]/levels/[levelId]/solution.astro +++ b/src/pages/adventures/[id]/levels/[levelId]/solution.astro @@ -6,10 +6,10 @@ import IconTriangleAlert from '~icons/lucide/triangle-alert'; import IconChevronDown from '~icons/lucide/chevron-down'; import IconSparkles from '~icons/lucide/sparkles'; import IconExternalLink from '~icons/lucide/external-link'; +import IconHammer from '~icons/lucide/hammer'; import Layout from "@/layouts/Layout.astro"; import Breadcrumb from "@/components/Breadcrumb.astro"; import StructuredData from "@/components/StructuredData.astro"; -import ContributorBadge from "@/components/ContributorBadge.astro"; import DifficultyBadge from "@/components/DifficultyBadge.astro"; import SolutionBlocks from "@/components/SolutionBlocks.astro"; import SolutionStepNav from "@/components/SolutionStepNav.astro"; @@ -132,11 +132,28 @@ const crumbs = [ )} {solution.contributor && (
      - + {solution.contributor.url ? ( + + + ) : ( + +
      )} {level.topics.length > 0 && ( diff --git a/src/pages/adventures/index.astro b/src/pages/adventures/index.astro index cda3d8661..04a2c7eb5 100644 --- a/src/pages/adventures/index.astro +++ b/src/pages/adventures/index.astro @@ -93,7 +93,7 @@ const HOW_IT_WORKS = rawHowItWorks.map((item) => ({ ...item, IconComponent: LUCI diff --git a/src/pages/challenges/[...tag].astro b/src/pages/challenges/[...tag].astro index e4844c8e3..d85a1a413 100644 --- a/src/pages/challenges/[...tag].astro +++ b/src/pages/challenges/[...tag].astro @@ -83,7 +83,7 @@ const path = initialTag ? `/challenges/${tagToSlug(initialTag)}/` : "/challenges diff --git a/src/pages/contribute.astro b/src/pages/contribute.astro index 4115640af..82e23b8c5 100644 --- a/src/pages/contribute.astro +++ b/src/pages/contribute.astro @@ -200,7 +200,7 @@ const base = import.meta.env.BASE_URL; {/* Sidebar: Community Leaders */} From 0415f9561b6c7aaa0395398a507e994a3022b190 Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Thu, 3 Sep 2026 16:52:51 +0200 Subject: [PATCH 02/18] fix: address code review findings in multi-contributor credit system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix expert.ts: replace placeholder contributor with KATHARINA_SICK constant - Fix CommunitySidebar: pass proposer prop so adventure contributors get "Adventure Builder" label instead of "Challenge Builder" - Fix ChallengeBuildersSection: destructure and render roleLabel per contribution - Fix CommunityLeaders: credit adventure contributor for levels with no explicit level.contributor in mixed-attribution adventures; remove unused hasLevelContributors variable - Fix AdventureCard: compute contributor pill even when adventure.contributor is absent but level contributors exist; tighten pill gate to avoid empty div - Fix adventures/index.astro: rename ArrowRightIcon → ARROW_RIGHT_ICON, rawHowItWorks → inline array per SCREAMING_SNAKE_CASE convention - Fix [id].astro: type levelBuilderMap levels as Difficulty[] instead of string[], import Difficulty, remove unsafe cast on DifficultyBadge - Fix CommunitySidebar: rename avatarPalette → AVATAR_PALETTE - Fix solution.astro: make sr-only Solution Steps heading unconditional - Update community-leaders test: avatarUrl is now optional in schema - Add CommunityLeaders comment: username field in adventure sections is a display name, not a Discourse handle - Update ADVENTURES.md: document discourse_username on contributor, add level contributor field to level fields table and YAML example - Update styleguide.md: ContributorBadge new props, ChallengeBuildersSection data source, CommunitySidebar levelContributor prop, AdventureCard pill conditions, CommunityLeaders data source split Signed-off-by: Sinduri Guntupalli --- ADVENTURES.md | 6 ++++-- src/components/AdventureCard.astro | 10 +++++++--- src/components/ChallengeBuildersSection.astro | 6 +++--- src/components/CommunityLeaders.astro | 6 +++--- src/components/CommunitySidebar.astro | 6 +++--- .../solutions/echoes-lost-in-orbit/expert.ts | 3 ++- src/pages/adventures/[id].astro | 5 +++-- .../[id]/levels/[levelId]/solution.astro | 2 +- src/pages/adventures/index.astro | 10 ++++------ src/test/lib/community-leaders.test.ts | 18 ++++++++++++++++-- styleguide.md | 16 ++++++++-------- 11 files changed, 54 insertions(+), 34 deletions(-) diff --git a/ADVENTURES.md b/ADVENTURES.md index 25d556cc0..2b6d33b79 100644 --- a/ADVENTURES.md +++ b/ADVENTURES.md @@ -44,7 +44,7 @@ The authoritative schema is in [`src/content.config.ts`](src/content.config.ts) | `story` | Optional | markdown string | Short description shown on adventure cards and at the top of the adventure page. Card views strip HTML; set:html prose uses the rendered version. | | `backstory` | Optional | `string[]` (markdown) | Thematic narrative paragraphs rendered on the adventure page. | | `overview` | Optional | `string[]` (markdown) | Technical/content summary rendered on the adventure page. | -| `contributor` | Optional | object | `name` (required), `url` (optional URL), `about` (optional markdown). Survives every re-sync once set. | +| `contributor` | Optional | object | `name` (required), `url` (optional URL), `about` (optional markdown), `discourse_username` (optional string -- Discourse username used for avatar resolution in community leaderboards). Survives every re-sync once set. | | `community_category_id` | Optional | integer | Discourse category ID. Survives every re-sync once set; position is kept directly after `slug`. | | `rewards` | Optional | object | `deadline` (required inside; see format below), `eligibility` (markdown), `tiers` (array of `{label, description}`), `ranking_note` (markdown), `ranking_rules_url` (URL). | | `upcoming_levels` | Optional | object[] | Coming-soon placeholders: `{level?, name, difficulty}`. Survives re-syncs for levels not yet in the challenges repo. | @@ -87,6 +87,7 @@ Each entry in the `levels` array accepts the following fields. | `verification` | **Required** | object | `{command, description}` — the verification gate command and its description. | | `codespaces_machine` | Optional | `"4core"` | Machine size override for Codespaces. Only `"4core"` is accepted; other values fail the Zod schema. | | `hook` | Optional | string | Verification hook command. | +| `contributor` | Optional | object | Person who built this specific level (may differ from the adventure proposer). Same subfields as the adventure `contributor` (`name`, `url`, `about`, `discourse_username`). When set, takes precedence over the adventure contributor for credit display on the level page and in community leaderboard sections. | | `solved_count` | Optional | integer | Override for the displayed solved count. | | `top_players` | Optional | object[] | System-populated leaderboard data: `{username, count}`. Set by the leaderboard refresh script; do not edit by hand. | @@ -125,9 +126,10 @@ contributor: name: "Full Name" url: "https://example.com" about: "One sentence bio." + discourse_username: "their_forum_username" ``` -Add this to `src/data/adventures//adventure.yaml`. The `url` and `about` fields are optional but recommended. Once set, this block survives future re-syncs automatically. +Add this to `src/data/adventures//adventure.yaml`. The `url`, `about`, and `discourse_username` fields are optional but recommended -- `discourse_username` enables avatar resolution in community leaderboards. Once set, this block survives future re-syncs automatically. ### Confirm month diff --git a/src/components/AdventureCard.astro b/src/components/AdventureCard.astro index 74ff7cc7e..9e5d30c70 100644 --- a/src/components/AdventureCard.astro +++ b/src/components/AdventureCard.astro @@ -23,13 +23,17 @@ const { adventure } = Astro.props; let cardPillBuilder: { name: string; url?: string } | undefined; let cardHasBuilders = false; +const levelBuilders = adventure.levels.filter((l) => l.contributor).map((l) => l.contributor!); +const uniqueNames = [...new Set(levelBuilders.map((b) => b.name))]; if (adventure.contributor) { - const levelBuilders = adventure.levels.filter((l) => l.contributor).map((l) => l.contributor!); - const uniqueNames = [...new Set(levelBuilders.map((b) => b.name))]; if (uniqueNames.length === 1 && uniqueNames[0] !== adventure.contributor.name) { cardPillBuilder = levelBuilders.find((b) => b.name === uniqueNames[0]); } cardHasBuilders = uniqueNames.length > 1; +} else if (uniqueNames.length === 1) { + cardPillBuilder = levelBuilders[0]; +} else if (uniqueNames.length > 1) { + cardHasBuilders = true; } const base = import.meta.env.BASE_URL; const difficulties = adventure.levels.map((l) => l.difficulty).join(", "); @@ -79,7 +83,7 @@ const label = tagList ))} - {adventure.contributor && ( + {(adventure.contributor || cardPillBuilder) && (
      diff --git a/src/components/ChallengeBuildersSection.astro b/src/components/ChallengeBuildersSection.astro index 554dc7ed6..5439b1411 100644 --- a/src/components/ChallengeBuildersSection.astro +++ b/src/components/ChallengeBuildersSection.astro @@ -104,10 +104,10 @@ const contributors = [...map.values()] )}

      Adventures contributed to

        - {contributor.contributions.map(({ id, title }) => ( -
      • -
      • {title} + {roleLabel}
      • ))}
      diff --git a/src/components/CommunityLeaders.astro b/src/components/CommunityLeaders.astro index 709016f29..29f5b6634 100644 --- a/src/components/CommunityLeaders.astro +++ b/src/components/CommunityLeaders.astro @@ -65,8 +65,6 @@ type DesignerEntry = { discourseUsername?: string; count: number }; const designerCountMap = new Map(); for (const { data } of adventures) { - const hasLevelContributors = data.levels.some((l) => l.contributor); - if (data.contributor) { const { name, discourseUsername } = data.contributor; const existing = designerCountMap.get(name) ?? { discourseUsername, count: 0 }; @@ -76,7 +74,7 @@ for (const { data } of adventures) { } for (const level of data.levels) { - const c = level.contributor ?? (!hasLevelContributors ? data.contributor : undefined); + const c = level.contributor ?? data.contributor; if (!c) continue; const existing = challengeCountMap.get(c.name) ?? { discourseUsername: c.discourseUsername, count: 0 }; existing.discourseUsername ??= c.discourseUsername; @@ -85,6 +83,8 @@ for (const { data } of adventures) { } } +// username here is the contributor's display name (from YAML), not a Discourse handle. +// AvatarLink uses it as visible text only; resolveAvatar provides the actual avatar via discourseUsername. const allBuilders = [...challengeCountMap.entries()] .filter(([, e]) => e.count > 0) .sort((a, b) => b[1].count - a[1].count) diff --git a/src/components/CommunitySidebar.astro b/src/components/CommunitySidebar.astro index 9c0d42650..3b1f3feb3 100644 --- a/src/components/CommunitySidebar.astro +++ b/src/components/CommunitySidebar.astro @@ -13,7 +13,7 @@ const POSTS_VISIBLE = 3; // Inline avatar palette: bg color at 0.25 opacity, foreground text (Astro style= takes a string). const AVATAR_COLORS = ["--primary", "--difficulty-architect", "--teal", "--difficulty-builder", "--destructive"]; -const avatarPalette = AVATAR_COLORS.map( +const AVATAR_PALETTE = AVATAR_COLORS.map( (c) => `background-color:hsl(var(${c}) / 0.25);color:hsl(var(--foreground))`, ); @@ -73,7 +73,7 @@ const hasActivity = visible.length > 0; { levelBuilder && (
      - +
      ) } @@ -111,7 +111,7 @@ const hasActivity = visible.length > 0; )}