GitHub Project #2 sprint board for TrueMain: /sprint opens a pane of the open issues on the current (or next) iteration grouped by Status and ordered by…

TrueMain is a League of Legends analytics site. It answers one question per champion: what do the players who truly main this champion actually do? — and turns that into build, rune, skill-order and matchup guidance backed by real match data instead of raw pick-rate averages.
The site is live at truemain.lol.
Most stat sites aggregate every game played on a champion, which drowns the signal in one-trick-free noise. TrueMain instead identifies true mains — players whose games-played-to-mastery ratio on a champion shows sustained, current investment — and computes its statistics from their matches only. The result is closer to "what do dedicated specialists build and how do they play the early game" than "what does the average queue do".
Data flows through three .NET services and two Nuxt frontends:
Riot API ──► Ingestor ──► PostgreSQL ──► Api ──► web (truemain.lol)
│ └─► admin (ops portal)
└── MongoDB (logs, audit, Riot-usage metrics)
A .NET worker that runs a set of scheduled, independently recorded processes:
An ASP.NET Core service exposing the read side: champion aggregates, true-main leaderboards, player profiles, and authenticated /ops endpoints for the admin portal. Reads are purpose-built query services returning read models, each shaped by the question it answers — no generic repository.
Data owns the EF Core model (PostgreSQL), migrations, repositories used by the ingestion write side, and the Mongo-backed logging/metrics sinks. Core holds shared League domain types (platforms, regional routing, queues, ranks).
The public Nuxt frontend: champion pages (builds, runes, skill order, matchups, synergies), player pages, and search. It talks to the Api through a Nitro server proxy, so the browser never reaches the backend directly.
A standalone Nuxt portal (separate deployable, not a route of web) for operating the pipeline: process runs and health, manual seeding, Riot API usage metrics, and data audits. Sits behind session authentication.
backend/ .NET solution — Api, Ingestor, Core, Data, tests (unit, integration, test kit)
web/ public Nuxt frontend
admin/ standalone Nuxt admin portal
docs/ API reference, RFCs, production migration notes
compose*.yaml Docker stacks (dev with hot-reload, preprod and prod from published images)
Everything runs as Docker containers on a single host. In production, Caddy terminates TLS (Let's Encrypt) and is the only public entry point; the frontends and databases live on an internal network. CI builds the backend in Release with analyzers as errors, runs unit tests on every run and integration tests (against real PostgreSQL) on pull requests, builds both frontends, and publishes images to GHCR. Pull requests get an automated Claude code review with a blocking verdict.
docs/api.md — HTTP endpoint reference (parameters and response shapes).docs/preprod.md — preprod environment: :preprod images tracking develop, fresh database with a dedicated Riot API key, data-diet knobs, deployment runbook.docs/load-testing.md — load testing preprod with k6 from GitHub Actions: scenarios, what a simulated visitor does, the rate-limit caveat, how to read the summary.docs/riot-production-key-application.md — what has to be true before applying for a Riot production API key, and what the application must say.docs/riot-key-switch.md — runbook for replacing the Riot API key: PUUIDs are encrypted per key, so the switch invalidates every stored identifier and is recovered through the Riot ID.docs/diagrams/architecture.drawio — architecture diagram.docs/phase-*.md — RFCs behind the major evolutions of the data model and pipeline. All three are implemented and kept as historical records; each carries a status banner saying what shipped differently from the proposal.TrueMain is licensed under the PolyForm Noncommercial License 1.0.0: you may read, run, modify and share the code for any noncommercial purpose — personal use, study, research, hobby projects. Any commercial use, including running it or a derivative as part of a product or service, needs a separate license from the copyright holder.
Third-party code and assets keep their own licenses. League of Legends data and artwork belong to Riot Games; TrueMain is not endorsed by Riot Games.
hooks/register.tsx 157 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, BoardFilter, BoardItem } from '../types'
5import { ISSUES_QUERY, ISSUES_SEARCH, ITERATIONS_QUERY, OWNER, PROJECT_NUMBER, filterTitle, parseIssuesPage, parseIterations, pickIterations, renderText, select, startPrompt } from './board'
6
7const PANE = 'truemain-board'
8const board = atom({ plugin: 'truemain-board', key: 'board' } as const, null)
9const filter = atom({ plugin: 'truemain-board', key: 'filter' } as const, 'current')
10const FILTERS: readonly BoardFilter[] = ['current', 'next', 'todo', 'all']
11const STALE_MS = 120_000
12
13let isLoading = false
14
15async function load($: EngineInterface, force = false): Promise<Board | null> {
16 const held = await read($, board)
17 const now = await $.clock.now()
18 if (!force && held !== null && now - held.fetchedAt < STALE_MS) return held
19 if (isLoading) return held
20 isLoading = true
21 try {
22 const cwd = await $.session.cwd()
23 const gql = async (query: string, vars: readonly string[]) => {
24 try {
25 const ran = await $.process.run(['gh', 'api', 'graphql', '-f', `query=${query}`, ...vars], { cwd, timeoutMs: 60_000 })
26 if (ran.exitCode !== 0) return { error: ran.stderr.trim().slice(0, 200) || `gh exited ${ran.exitCode}` }
27 return { json: JSON.parse(ran.stdout) as unknown }
28 } catch (error) {
29 return { error: String(error).slice(0, 200) }
30 }
31 }
32
33 const errors: string[] = []
34 const items: BoardItem[] = []
35 let after: string | null = null
36 for (let page = 0; page < 6; page += 1) {
37 const answer = await gql(ISSUES_QUERY, ['-f', `q=${ISSUES_SEARCH}`, ...(after === null ? [] : ['-f', `after=${after}`])])
38 if ('error' in answer) {
39 errors.push(`issues: ${answer.error}`)
40 break
41 }
42 const parsed = parseIssuesPage(answer.json)
43 items.push(...parsed.items)
44 after = parsed.next
45 if (after === null) break
46 }
47
48 const iterationsAnswer = await gql(ITERATIONS_QUERY, ['-f', `owner=${OWNER}`, '-F', `number=${PROJECT_NUMBER}`])
49 const iterations = 'error' in iterationsAnswer ? [] : parseIterations(iterationsAnswer.json)
50 if ('error' in iterationsAnswer) errors.push(`iterations: ${iterationsAnswer.error}`)
51 const today = new Date(now).toISOString().slice(0, 10)
52 const { current, next } = pickIterations(iterations, today)
53
54 const fresh: Board = { fetchedAt: now, items, iterations, current, next, error: errors.length === 0 ? null : errors.join(' | ') }
55 await update($, board, () => fresh)
56 return fresh
57 } finally {
58 isLoading = false
59 }
60}
61
62function parseFilter(arg: string): BoardFilter | null {
63 const word = arg.trim().toLowerCase()
64 return (FILTERS as readonly string[]).includes(word) ? (word as BoardFilter) : word === '' ? 'current' : null
65}
66
67export const register: Register = on => {
68 on('session.start', async ($, e, next) => {
69 await $.command.register({
70 name: 'sprint',
71 description: 'Open the TrueMain sprint board (Project #2): open issues of the current sprint by Status and Priority; click a row to draft "Commence la #N"',
72 argumentHint: '[current|next|todo|all]',
73 })
74 await $.tool.register({
75 name: 'sprint_issues',
76 description:
77 'TrueMain GitHub Project #2: the open issues planned on the current sprint (or next / unplanned todo / all), with their board Status and Priority, ordered In Progress → Todo, then P0 → P3. Use it to answer "what is on the sprint", "what should I pick next" or before proposing new work; it replaces paging gh project item-list.',
78 inputSchema: {
79 type: 'object',
80 properties: {
81 filter: { type: 'string', enum: ['current', 'next', 'todo', 'all'], description: 'Which issues (default current).' },
82 refresh: { type: 'boolean', description: 'Re-query GitHub even if the last pull is under 2 minutes old.' },
83 },
84 },
85 })
86 return next(e)
87 })
88
89 on('command.run', { command: 'sprint' }, async ($, e) => {
90 const chosen = parseFilter(e.args)
91 if (chosen === null) return { text: `Unknown filter "${e.args.trim()}": use current, next, todo or all.` }
92 await update($, filter, () => chosen)
93 const loaded = await load($)
94 await $.ui.open({ id: PANE, title: 'Sprint board' })
95 return { text: loaded === null ? 'The board could not be loaded.' : renderText(loaded, chosen) }
96 })
97
98 on('tool.call', { tool: 'mcp__truemain-board__sprint_issues' }, async ($, e) => {
99 const input = e as { filter?: unknown; refresh?: unknown }
100 const chosen = parseFilter(typeof input.filter === 'string' ? input.filter : '') ?? 'current'
101 const loaded = await load($, input.refresh === true)
102 return { result: loaded === null ? 'The board could not be loaded.' : renderText(loaded, chosen) }
103 })
104
105 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
106 const { Box, Button, Text } = $.ui.resolve(e)
107 const loaded = await read($, board)
108 const chosen = await read($, filter)
109 const columns = e.props.bodyColumns
110 if (loaded === null) {
111 return (
112 <Box flexDirection="column">
113 <Text dimColor>Loading the board…</Text>
114 </Box>
115 )
116 }
117 const items = select(loaded, chosen)
118 const rows = Math.max(3, (e.viewport?.rows ?? 30) - 6)
119 const titleRoom = Math.max(8, columns - 22)
120 let group: string | null | undefined
121 const lines: JSX.Element[] = []
122 for (const item of items.slice(0, rows)) {
123 if (item.status !== group) {
124 group = item.status
125 lines.push(
126 <Text bold key={`g-${group ?? 'none'}`}>
127 {group ?? 'no Status'}
128 </Text>,
129 )
130 }
131 const title = item.title.length > titleRoom ? `${item.title.slice(0, titleRoom - 1)}…` : item.title
132 lines.push(
133 <Button key={`i-${item.number}`} plain onPress={() => void $.prompt.fill({ text: startPrompt(item), mode: 'replace' })}>
134 <Text color="suggestion">#{item.number}</Text> <Text color={item.priority === 'P0' ? 'error' : item.priority === 'P1' ? 'warning' : 'subtle'}>{item.priority ?? '--'}</Text> {title}
135 </Button>,
136 )
137 }
138 return (
139 <Box flexDirection="column" gap={0}>
140 <Text dimColor wrap="truncate">
141 {filterTitle(loaded, chosen)} · {items.length} issue{items.length === 1 ? '' : 's'}
142 {loaded.error === null ? '' : ' · !'}
143 </Text>
144 <Box flexDirection="row" gap={1}>
145 {FILTERS.map(name => (
146 <Button key={`f-${name}`} plain variant={name === chosen ? 'primary' : 'secondary'} label={name === chosen ? `[${name}]` : name} onPress={() => update($, filter, () => name)} />
147 ))}
148 <Button key="refresh" plain label="↻" onPress={() => void load($, true)} />
149 </Box>
150 {lines}
151 {items.length > rows && <Text dimColor>… {items.length - rows} more (use /sprint for the full list)</Text>}
152 {items.length === 0 && <Text dimColor>Nothing here.</Text>}
153 </Box>
154 )
155 })
156}
157hooks/board.ts 138 lines1import type { Board, BoardFilter, BoardItem, BoardIteration } from '../types'
2
3export const OWNER = 'ilyanfraimbault'
4export const REPO = 'TrueMain'
5export const PROJECT_NUMBER = 2
6
7export const ISSUES_QUERY = `query($q:String!,$after:String){search(query:$q,type:ISSUE,first:100,after:$after){issueCount pageInfo{hasNextPage endCursor} nodes{... on Issue{number title state updatedAt labels(first:10){nodes{name}} projectItems(first:5){nodes{project{number} fieldValues(first:12){nodes{... on ProjectV2ItemFieldSingleSelectValue{name field{... on ProjectV2SingleSelectField{name}}} ... on ProjectV2ItemFieldIterationValue{title field{... on ProjectV2IterationField{name}}}}}}}}}}}`
8export const ISSUES_SEARCH = `repo:${OWNER}/${REPO} is:issue is:open`
9
10export const ITERATIONS_QUERY = `query($owner:String!,$number:Int!){user(login:$owner){projectV2(number:$number){field(name:"Sprint"){... on ProjectV2IterationField{configuration{iterations{title startDate duration} completedIterations{title startDate duration}}}}}}}`
11
12type Json = Record<string, unknown>
13const obj = (value: unknown): Json => (typeof value === 'object' && value !== null ? (value as Json) : {})
14const str = (value: unknown, fallback = ''): string => (typeof value === 'string' ? value : fallback)
15const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
16
17/** One page of ISSUES_QUERY: its items and the cursor of the next page, if any. */
18export function parseIssuesPage(raw: unknown): { items: BoardItem[]; next: string | null } {
19 const search = obj(obj(obj(raw).data).search)
20 const items: BoardItem[] = []
21 for (const node of list(search.nodes)) {
22 const issue = obj(node)
23 if (typeof issue.number !== 'number') continue
24 let status: string | null = null
25 let priority: string | null = null
26 let sprint: string | null = null
27 let isOnBoard = false
28 for (const item of list(obj(issue.projectItems).nodes)) {
29 const entry = obj(item)
30 if (obj(entry.project).number !== PROJECT_NUMBER) continue
31 isOnBoard = true
32 for (const value of list(obj(entry.fieldValues).nodes)) {
33 const field = obj(value)
34 const name = str(obj(field.field).name)
35 if (name === 'Status') status = str(field.name) || null
36 else if (name === 'Priority') priority = str(field.name) || null
37 else if (name === 'Sprint') sprint = str(field.title) || null
38 }
39 }
40 items.push({
41 number: issue.number,
42 title: str(issue.title),
43 labels: list(obj(issue.labels).nodes).map(label => str(obj(label).name)).filter(name => name !== ''),
44 updatedAt: str(issue.updatedAt),
45 status,
46 priority,
47 sprint,
48 isOnBoard,
49 })
50 }
51 const pageInfo = obj(search.pageInfo)
52 return { items, next: pageInfo.hasNextPage === true ? str(pageInfo.endCursor) || null : null }
53}
54
55/** ITERATIONS_QUERY parsed: every iteration, past and future, by start date. */
56export function parseIterations(raw: unknown): BoardIteration[] {
57 const configuration = obj(obj(obj(obj(obj(obj(raw).data).user).projectV2).field).configuration)
58 const all = [...list(configuration.iterations), ...list(configuration.completedIterations)]
59 return all
60 .map(item => obj(item))
61 .filter(item => typeof item.title === 'string' && typeof item.startDate === 'string')
62 .map(item => ({ title: str(item.title), startDate: str(item.startDate), endDate: addDays(str(item.startDate), Number(item.duration ?? 14)) }))
63 .sort((a, b) => a.startDate.localeCompare(b.startDate))
64}
65
66/** `date + days` as YYYY-MM-DD (UTC). */
67export function addDays(date: string, days: number): string {
68 const at = new Date(`${date}T00:00:00Z`)
69 at.setUTCDate(at.getUTCDate() + days)
70 return at.toISOString().slice(0, 10)
71}
72
73/** The iteration containing `today` and the first one starting after it, as project-set.sh resolves `current` and `next`. */
74export function pickIterations(iterations: readonly BoardIteration[], today: string): { current: BoardIteration | null; next: BoardIteration | null } {
75 const current = iterations.find(it => it.startDate <= today && today < it.endDate) ?? null
76 const next = iterations.find(it => it.startDate > today) ?? null
77 return { current, next }
78}
79
80const STATUS_ORDER: Record<string, number> = { 'In Progress': 0, Todo: 1, Done: 3 }
81const statusRank = (status: string | null) => (status === null ? 2 : (STATUS_ORDER[status] ?? 2))
82const priorityRank = (priority: string | null) => {
83 const rank = priority === null ? Number.NaN : Number(priority.replace(/^P/, ''))
84 return Number.isNaN(rank) ? 9 : rank
85}
86
87/** The items the filter keeps, ordered by Status (In Progress, Todo, none), then Priority, then number. */
88export function select(board: Board, filter: BoardFilter): BoardItem[] {
89 const kept = board.items.filter(item => {
90 switch (filter) {
91 case 'current':
92 return board.current !== null && item.sprint === board.current.title
93 case 'next':
94 return board.next !== null && item.sprint === board.next.title
95 case 'todo':
96 return item.sprint === null && (item.status === 'Todo' || item.status === null)
97 default:
98 return true
99 }
100 })
101 return kept.sort((a, b) => statusRank(a.status) - statusRank(b.status) || priorityRank(a.priority) - priorityRank(b.priority) || a.number - b.number)
102}
103
104export function filterTitle(board: Board, filter: BoardFilter): string {
105 switch (filter) {
106 case 'current':
107 return board.current === null ? 'current sprint (none configured for today)' : `${board.current.title} (${board.current.startDate} → ${board.current.endDate})`
108 case 'next':
109 return board.next === null ? 'next sprint (none configured)' : `${board.next.title} (${board.next.startDate} → ${board.next.endDate})`
110 case 'todo':
111 return 'unplanned backlog (no sprint, Todo or no status)'
112 default:
113 return 'every open issue'
114 }
115}
116
117/** The `/sprint` output and the sprint_issues tool's answer. */
118export function renderText(board: Board, filter: BoardFilter): string {
119 const items = select(board, filter)
120 const lines = [`${filterTitle(board, filter)} — ${items.length} open issue${items.length === 1 ? '' : 's'}`]
121 let group: string | null | undefined
122 for (const item of items) {
123 if (item.status !== group) {
124 group = item.status
125 lines.push(`\n${group ?? (item.isOnBoard ? 'no Status' : 'not on the board')}`)
126 }
127 lines.push(` #${item.number} ${item.priority ?? '--'} ${item.title}${filter === 'all' && item.sprint !== null ? ` [${item.sprint}]` : ''}`)
128 }
129 if (items.length === 0) lines.push(' (nothing)')
130 if (board.error !== null) lines.push(`\nNote: ${board.error}`)
131 return lines.join('\n')
132}
133
134/** What a click on a row puts in the prompt box. */
135export function startPrompt(item: BoardItem): string {
136 return `Commence la #${item.number} (${item.title})`
137}
138types/index.d.ts 32 lines1export type BoardItem = {
2 number: number
3 title: string
4 labels: string[]
5 updatedAt: string
6 /** Project #2 fields; null when the issue is not on the board or the field is unset. */
7 status: string | null
8 priority: string | null
9 sprint: string | null
10 isOnBoard: boolean
11}
12
13export type BoardIteration = { title: string; startDate: string; endDate: string }
14
15export type BoardFilter = 'current' | 'next' | 'todo' | 'all'
16
17export type Board = {
18 fetchedAt: number
19 /** Every open issue of the repository. */
20 items: BoardItem[]
21 iterations: BoardIteration[]
22 current: BoardIteration | null
23 next: BoardIteration | null
24 error: string | null
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'truemain-board': { board: Board | null; filter: BoardFilter }
30 }
31}
32