SLOPSHOPPER

truemain-board

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…

newpaneguardcommandtoolprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · truemain-board
│ ┃ Sprint board ✕ › fix the failing auth test and add an audit log call │ ┃ current sprint (none configured for today) … │ ┃ [current] next todo all ↻ ⏺ Read(src/auth.ts) │ ┃ Nothing here. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sprint │ ⎿ truemain-board: current sprint (none configured for today) — 0 o │ ⎿ truemain-board: (nothing) │ ⎿ truemain-board: │ ⎿ truemain-board: Note: issues: SyntaxError: JSON Parse error: Une │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sprint board
current sprint (none configured for today) · 0 issues · ! [current] next todo all ↻ Nothing here.
README

TrueMain

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.

The idea

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".

How it works

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)

Ingestor — the data pipeline

A .NET worker that runs a set of scheduled, independently recorded processes:

  • Discovery walks ranked ladders across platforms to find candidate players.
  • Match ingestion / harvest pulls solo/duo (queue 420) match and timeline data for known accounts. Other queues are dropped at ingest — TrueMain is ranked-only by design.
  • Scoring / main analysis computes the games-vs-mastery signal that promotes a player to true main status for a champion.
  • Aggregation folds match participants into per-champion aggregates: builds, runes, skill orders, matchups (with their 15-minute lane outcomes and gold/XP gaps), synergies, bans, kill positions. Aggregates are computed per patch and rank scope; past patches are frozen once their live matches are retired.
  • Retention trims raw match data that has already been aggregated, keeping the database bounded.

Api

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 & Core

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).

web

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.

admin

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.

Repository layout

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)

Infrastructure

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.

Further reading

  • 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.

License

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.

Source 3 files
hooks/register.tsx 157 lines
1import { 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}
157
hooks/board.ts 138 lines
1import 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}
138
types/index.d.ts 32 lines
1export 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