SLOPSHOPPER

linear-tickets

Your Linear tickets in a side pane, with their status and a chart of how you are progressing: tickets grouped by state (in progress, todo, backlog, done), a…

newpanecommandtoaststatusnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · linear-tickets
│ ┃ linear ✕ › fix the failing auth test and add an audit log call │ ┃ linear: 0 in progress · 0 todo · 0 done │ ┃ (14d) ● linear-tickets: linear-tickets: set linearApiKey in the plugin opti │ ┃ ⏺ Read(src/auth.ts) │ ┃ closed per day · 0 in 14d · max 0/day ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ⎿ 3 pass, 1 fail │ ┃ 14d ago today │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ refresh ] [ close ] │ ┃ read-only · status, charts and cycle from ✻ Worked for 42s · done 4:20 PM │ ┃ Linear │ › /linear │ ⎿ linear-tickets: linear: set linearApiKey in the plugin options │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · linear
linear: 0 in progress · 0 todo · 0 done (14d) closed per day · 0 in 14d · max 0/day ▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 14d ago today [ refresh ] [ close ] read-only · status, charts and cycle from Linear
README

linear-tickets

Your Linear tickets in a side pane, with their status and a chart of how you are progressing.

linear: 2 in progress · 5 todo · 12 done (14d)
██████████████████▒▒▒▒▒▒░░░░░░░░░░░░████████████████████████
closed per day · 12 in 14d · max 4/day
        █          █
   ▄    █     ▄    █
▁ ▁█▁▁ ▁█▁▁ ▁ █▁▁ ▁█▁
14d ago                          today
cycle 12 Sprint 40% done · 36% of time
scope ▅▅▆▆▇▇
done  ▁▂▂▃▄▅
In progress (2)
◐ CLA-149 High  Wizard, pricing and the slot

The drawing above is schematic; the real chart is as wide as the pane allows, one column per day, four rows tall. /linear opens it (/linear stop closes it). A row opens the ticket's detail (state, priority, estimate, project, due date) and a copy url button; the backlog folds open from its header.

What the charts are

  • Status bar: your tickets split into in progress, todo, backlog and done (done = closed inside the days window).
  • Closed per day: a bar chart of how many tickets you closed on each of the last days days, today last, in your local timezone, from each ticket's completedAt.
  • Cycle burn-up: when your tickets sit in a cycle running now, that cycle's scopeHistory and completedScopeHistory as two sparklines, with its progress next to the share of its time already gone. Behind schedule is a "done" percentage below the "of time" one.

Scope and secrets

Read-only: one GraphQL query for issues and one for the cycle, never a mutation. The API key comes from the plugin options (linearApiKey, stored as sensitive), goes only to api.linear.app in the Authorization header (a personal key as given, an OAuth token lin_oauth_… as Bearer), and is never logged or drawn. Ticket titles are read into the pane in memory and not stored.

Writing back (moving a ticket to In Progress, commenting) is deliberately left out of this first version.

Options

  linearApiKey: string        Linear API key (required)
  assignee: string            "me" | "anyone" (default me)
  teamKey: string             only this team, the CLA of CLA-143 (default: every team)
  project: string             only projects whose name contains this (case-insensitive)
  days: number                history, 7-60 (default 14)
  pollSeconds: number         refresh interval, 30-3600 (default 120)
  status: boolean             status line summary; keeps polling with the pane closed (default false)
  openOnStart: boolean        open the pane at session start (default false)

The query asks for the first 100 tickets that are open or closed inside the window, most recently updated first, without pagination: with assignee: anyone or a very busy account the done count and the closed-per-day chart can undercount, so narrow it with teamKey or project. The pane lists at most 10 in-progress, 8 todo and 5 done tickets (+N more says how many are left) and 12 backlog tickets once it is folded open. pollSeconds is honoured to the nearest 5 seconds. Fields used were checked against Linear's published schema.graphql: issues(filter, first, orderBy), Issue.state.type, completedAt, cycle, and cycle(id) with scopeHistory and completedScopeHistory.

Install

npx claude-code-templates@latest --mod integrations/linear-tickets
claude

It is written to .claude/skills/linear-tickets/, which Claude Code auto-loads as linear-tickets@skills-dir once the workspace trust prompt is accepted. For one session with hot reload: claude --plugin-dir .claude/skills/linear-tickets. claude plugin validate .claude/skills/linear-tickets prints every event it hooks and every $ call it makes; claude plugin test .claude/skills/linear-tickets runs its tests.

Options are read from user settings (~/.claude/settings.json, never project settings), --settings <file> or managed settings, under the plugin's full id:

{ "pluginConfigs": { "linear-tickets@skills-dir": { "options": { } } } }

Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods

Source 2 files
hooks/linear-tickets.tsx 311 lines
1/**
2 * linear-tickets — Claude Mod
3 *
4 * Your Linear tickets in a pane, with their status and how you are progressing:
5 *
6 *   - a status bar of the tickets you hold (in progress, todo, backlog, done)
7 *   - a bar chart of tickets closed per day over the last `days` days
8 *   - the burn-up of the cycle your tickets sit in (scope against completed)
9 *   - the tickets themselves, grouped by state; a row opens its detail, with
10 *     the ticket's URL one button away from your clipboard
11 *
12 * Read-only: it reads issues and one cycle through Linear's GraphQL API and
13 * never writes. `$.clock.every(5000)` decides when to read, every
14 * `pollSeconds`, and only while the pane is open or the status line is on.
15 * The key is sent only to api.linear.app, in the Authorization header, and is
16 * never logged or drawn. Never hardcode it in this file.
17 *
18 * Needs Claude Code >= 2.1.287 and a
19 * Linear API key in the plugin's options.
20 *
21 * Options (pluginConfigs["linear-tickets@skills-dir"].options):
22 *   linearApiKey: string        required; without it the mod does nothing
23 *   assignee: "me" | "anyone"   whose tickets (default me)
24 *   teamKey: string             only this team (the CLA of CLA-143)
25 *   project: string             only projects whose name contains this
26 *   days: number                history shown, 7-60 (default 14)
27 *   pollSeconds: number         refresh interval, 30-3600 (default 120)
28 *   status: boolean             status line summary, keeps polling with the pane closed (default false)
29 *   openOnStart: boolean        open the pane at session start (default false)
30 */
31import type { Register } from 'claude-code'
32import {
33  BUCKET_COLOR,
34  BUCKET_GLYPH,
35  BUCKET_LABEL,
36  barChart,
37  clampInt,
38  count,
39  currentCycleId,
40  cycleElapsed,
41  doneByDay,
42  fit,
43  getCycle,
44  listTickets,
45  sortTickets,
46  spark,
47  stacked,
48  summaryText,
49} from './linear-api.ts'
50import type { Bucket, Counts, Cycle, Fetch, Ticket } from './linear-api.ts'
51
52const PANE = 'linear'
53const COMMAND = 'linear'
54const TICK_MS = 5000
55const DAY = 86_400_000
56const CAPS: Record<Bucket, number> = { started: 10, todo: 8, backlog: 0, done: 5 }
57
58let tickets: Ticket[] = []
59let cycle: Cycle | undefined
60let error: string | undefined
61let isOpen = false
62let isBusy = false
63let lastReadAt = 0
64let selected: string | undefined
65let showBacklog = false
66let timer: { cancel: () => void } | undefined
67
68const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
69
70export const register: Register = (on, options) => {
71  const key = text(options.linearApiKey)
72  const assignee = options.assignee === 'anyone' ? 'anyone' : 'me'
73  const teamKey = text(options.teamKey)
74  const project = text(options.project)
75  const days = clampInt(options.days, 14, 7, 60)
76  const pollMs = clampInt(options.pollSeconds, 120, 30, 3600) * 1000
77  const showStatus = options.status === true
78  const openOnStart = options.openOnStart === true
79  const scope = { assignee, teamKey, project } as const
80
81  const statusText = () => (key && tickets.length > 0 ? summaryText(count(tickets), days) : undefined)
82
83  async function read(f: Fetch): Promise<void> {
84    isBusy = true
85    try {
86      const since = new Date(Date.now() - days * DAY).toISOString()
87      tickets = sortTickets(await listTickets(f, key, scope, since))
88      const id = currentCycleId(tickets, Date.now())
89      cycle = id ? await getCycle(f, key, id).catch(() => undefined) : undefined
90      error = undefined
91    } catch (err) {
92      error = err instanceof Error ? err.message : String(err)
93    } finally {
94      lastReadAt = Date.now()
95      isBusy = false
96    }
97  }
98
99  on('session.start', async ($, e, next) => {
100    const r = await next(e)
101    await $.command
102      .register({
103        name: COMMAND,
104        description: 'Your Linear tickets by status, with a progress chart (stop closes)',
105        argumentHint: '[stop]',
106        immediate: true,
107      })
108      .catch(err => $.ui.log(`linear-tickets: /${COMMAND} not registered: ${err}`))
109    if (!key) {
110      $.ui.log('linear-tickets: set linearApiKey in the plugin options to list your tickets', { to: 'debug' })
111      return r
112    }
113
114    timer?.cancel()
115    timer = $.clock.every(TICK_MS, () => {
116      if (isBusy || (!isOpen && !showStatus)) return
117      if (Date.now() - lastReadAt < pollMs - TICK_MS / 2) return
118      void read((url, init) => $.http.fetch(url, init)).then(() => {
119        $.ui.status(showStatus ? statusText() : undefined)
120        $.ui.invalidate('ui.render')
121      })
122    })
123
124    if (openOnStart) {
125      await read((url, init) => $.http.fetch(url, init))
126      isOpen = true
127      await $.ui.open({ id: PANE, title: 'linear' }).catch(() => {
128        isOpen = false
129      })
130      $.ui.status(showStatus ? statusText() : undefined)
131    }
132    return r
133  })
134
135  on('session.end', async ($, e, next) => {
136    timer?.cancel()
137    timer = undefined
138    return next(e)
139  })
140
141  on('command.run', { command: COMMAND }, async ($, e) => {
142    if (!key) return { text: 'linear: set linearApiKey in the plugin options' }
143    if (['stop', 'close'].includes(e.args.trim().toLowerCase())) {
144      await $.ui.close({ id: PANE }).catch(() => undefined)
145      isOpen = false
146      return { text: 'linear closed' }
147    }
148    await read((url, init) => $.http.fetch(url, init))
149    isOpen = true
150    await $.ui.open({ id: PANE, title: 'linear', focus: true })
151    $.ui.status(showStatus ? statusText() : undefined)
152    $.ui.invalidate('ui.render')
153    if (error) return { text: `linear: ${error}` }
154    return { text: `${summaryText(count(tickets), days)} · /${COMMAND} stop closes` }
155  })
156
157  on('ui.close', async ($, e, next) => {
158    if (e.id !== PANE) return next(e)
159    isOpen = false
160    return next(e)
161  })
162
163  on('ui.press', async ($, e, next) => {
164    if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
165    const r = await next(e)
166    const target = e.element
167    if (target === 'close') {
168      await $.ui.close({ id: PANE }).catch(() => undefined)
169      return r
170    }
171    if (target === 'refresh') {
172      if (!isBusy) await read((url, init) => $.http.fetch(url, init))
173    } else if (target === 'backlog') {
174      showBacklog = !showBacklog
175    } else if (target.startsWith('t:')) {
176      const id = tickets[Number(target.slice(2))]?.id
177      selected = id === selected ? undefined : id
178    } else if (target === 'copy') {
179      const url = tickets.find(t => t.id === selected)?.url
180      if (url) {
181        await $.ui.copy({ text: url, surface: e.surface }).catch(() => undefined)
182        $.ui.toast(`linear: copied ${url}`)
183      }
184    }
185    $.ui.status(showStatus ? statusText() : undefined)
186    $.ui.invalidate('ui.render')
187    return r
188  })
189
190  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
191    if (e.requestId !== PANE) return next(e)
192    const { Box, Text, Button } = $.ui.resolve(e)
193    const width = Math.max(30, e.props.bodyColumns - 1)
194    const now = Date.now()
195    const noop = () => {}
196    const counts: Counts = count(tickets)
197    const perDay = doneByDay(tickets, now, days)
198    const chart = barChart(perDay.slice(-Math.min(days, width - 2)), 4)
199    const bar = stacked(
200      [
201        { n: counts.started, glyph: 'started' },
202        { n: counts.todo, glyph: 'todo' },
203        { n: counts.backlog, glyph: 'backlog' },
204        { n: counts.done, glyph: 'done' },
205      ],
206      width,
207    )
208    const chosen = selected ? tickets.find(t => t.id === selected) : undefined
209    const indexOf = new Map(tickets.map((t, i) => [t.id, i] as const))
210    const groups: Bucket[] = ['started', 'todo', 'done']
211
212    return (
213      <Box flexDirection="column">
214        <Text bold>{fit(summaryText(counts, days), width)}</Text>
215        {error ? <Text color="red">{fit(error, width)}</Text> : null}
216
217        <Box key="bar" flexDirection="row">
218          {bar.map((part, i) => (
219            <Text key={`bar:${i}`} color={BUCKET_COLOR[part.glyph as Bucket]} dimColor={part.glyph === 'backlog'}>
220              {(part.glyph === 'backlog' ? '░' : '█').repeat(part.cells)}
221            </Text>
222          ))}
223        </Box>
224
225        <Box key="chart" flexDirection="column" marginTop={1}>
226          <Text dimColor>{fit(`closed per day · ${perDay.reduce((a, b) => a + b, 0)} in ${days}d · max ${Math.max(0, ...perDay)}/day`, width)}</Text>
227          {chart.map((row, i) => (
228            <Text key={`c:${i}`} color="green">{row}</Text>
229          ))}
230          <Text dimColor>{fit(`${days}d ago${' '.repeat(Math.max(1, Math.min(days, width - 2) - `${days}d ago`.length - 5))}today`, width)}</Text>
231        </Box>
232
233        {cycle ? (
234          <Box key="cycle" flexDirection="column" marginTop={1}>
235            <Text>
236              <Text bold>{`cycle ${cycle.number}${cycle.name ? ` ${cycle.name}` : ''} `}</Text>
237              <Text color="green">{`${Math.round(cycle.progress * 100)}% done`}</Text>
238              <Text dimColor>{` · ${Math.round(cycleElapsed(cycle, now) * 100)}% of time`}</Text>
239            </Text>
240            {cycle.scope.length > 1 ? <Text dimColor>{fit(`scope ${spark(cycle.scope)}`, width)}</Text> : null}
241            {cycle.completed.length > 1 ? <Text color="green">{fit(`done  ${spark(cycle.completed)}`, width)}</Text> : null}
242          </Box>
243        ) : null}
244
245        {groups.map(bucket => {
246          const rows = tickets.filter(t => t.bucket === bucket)
247          if (rows.length === 0) return null
248          return (
249            <Box key={`g:${bucket}`} flexDirection="column" marginTop={1}>
250              <Text bold color="cyan">{`${BUCKET_LABEL[bucket]} (${rows.length})`}</Text>
251              {rows.slice(0, CAPS[bucket]).map(t => (
252                <Box key={`row:${t.id}`} flexDirection="row">
253                  <Text color={BUCKET_COLOR[bucket]}>{`${BUCKET_GLYPH[bucket]} `}</Text>
254                  <Button
255                    key={`t:${indexOf.get(t.id)}`}
256                    plain
257                    dimColor={selected !== t.id && bucket === 'done'}
258                    label={fit(`${t.identifier}${t.priority > 0 && t.priority < 3 ? ` ${t.priorityLabel ?? ''}` : ''}  ${t.title}`, width - 2)}
259                    onPress={noop}
260                  />
261                </Box>
262              ))}
263              {rows.length > CAPS[bucket] ? <Text dimColor>{`  +${rows.length - CAPS[bucket]} more`}</Text> : null}
264            </Box>
265          )
266        })}
267
268        {counts.backlog > 0 ? (
269          <Box key="backlog-box" flexDirection="column" marginTop={1}>
270            <Button key="backlog" plain label={`${showBacklog ? '▾' : '▸'} Backlog (${counts.backlog})`} onPress={noop} />
271            {showBacklog
272              ? tickets
273                  .filter(t => t.bucket === 'backlog')
274                  .slice(0, 12)
275                  .map(t => (
276                    <Box key={`bl:${t.id}`} flexDirection="row">
277                      <Text dimColor>{'· '}</Text>
278                      <Button key={`t:${indexOf.get(t.id)}`} plain dimColor label={fit(`${t.identifier}  ${t.title}`, width - 2)} onPress={noop} />
279                    </Box>
280                  ))
281              : null}
282          </Box>
283        ) : null}
284
285        {chosen ? (
286          <Box key="detail" flexDirection="column" marginTop={1} borderStyle="round" borderDimColor paddingX={1}>
287            <Text bold>{fit(`${chosen.identifier} ${chosen.title}`, width - 4)}</Text>
288            <Text dimColor>
289              {fit(
290                [chosen.stateName, chosen.priorityLabel, chosen.estimate !== undefined ? `${chosen.estimate} pts` : '', chosen.project, chosen.dueDate ? `due ${chosen.dueDate}` : '']
291                  .filter(Boolean)
292                  .join(' · '),
293                width - 4,
294              )}
295            </Text>
296            {chosen.url ? <Button key="copy" label="copy url" hotkey="c" onPress={noop} /> : null}
297          </Box>
298        ) : null}
299
300        <Box key="foot" marginTop={1} flexDirection="column">
301          <Box flexDirection="row" columnGap={1}>
302            <Button key="refresh" label="refresh" hotkey="r" onPress={noop} />
303            <Button key="close" label="close" onPress={noop} />
304          </Box>
305          <Text dimColor>{fit('read-only · status, charts and cycle from Linear', width)}</Text>
306        </Box>
307      </Box>
308    )
309  })
310}
311
hooks/linear-api.ts 338 lines
1/**
2 * linear-api.ts — the Linear GraphQL reads the mod makes, and the arithmetic
3 * behind its charts. No `$` in here.
4 *
5 * Reference: https://linear.app/developers (POST https://api.linear.app/graphql).
6 * A personal API key goes in `Authorization` as it is; an OAuth token
7 * (`lin_oauth_...`) goes as `Bearer <token>`. Fields used, checked against
8 * linear/linear's schema.graphql:
9 *   issues(filter, first, orderBy)   Issue { identifier title url priority priorityLabel estimate
10 *                                    updatedAt startedAt completedAt dueDate
11 *                                    state { name type color } cycle { id number name startsAt endsAt }
12 *                                    project { name } assignee { name } }
13 *   cycle(id)                        Cycle { number name progress startsAt endsAt
14 *                                    scopeHistory completedScopeHistory issueCountHistory completedIssueCountHistory }
15 * Workflow state `type` is one of triage, backlog, unstarted, started, completed, canceled.
16 * The key is only sent to api.linear.app and never logged.
17 */
18
19export const ENDPOINT = 'https://api.linear.app/graphql'
20
21export type Fetch = (
22  url: string,
23  init?: { method?: string; headers?: Record<string, string>; body?: string },
24) => Promise<{ status: number; ok: boolean; text: string }>
25
26export type Bucket = 'started' | 'todo' | 'backlog' | 'done'
27
28export type Ticket = {
29  id: string
30  identifier: string
31  title: string
32  url: string
33  stateName: string
34  stateType: string
35  bucket: Bucket | 'canceled'
36  priority: number
37  priorityLabel?: string
38  estimate?: number
39  updatedAt?: number
40  startedAt?: number
41  completedAt?: number
42  dueDate?: string
43  project?: string
44  cycle?: { id: string; number: number; name?: string; startsAt: number; endsAt: number }
45}
46
47export type Cycle = {
48  number: number
49  name?: string
50  progress: number
51  startsAt: number
52  endsAt: number
53  scope: number[]
54  completed: number[]
55}
56
57const obj = (v: unknown): Record<string, unknown> | undefined =>
58  v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined
59const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
60const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
61const time = (v: unknown): number | undefined => {
62  const s = str(v)
63  const t = s ? Date.parse(s) : NaN
64  return Number.isNaN(t) ? undefined : t
65}
66const numbers = (v: unknown): number[] => (Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : [])
67
68export function bucketOf(type: string): Ticket['bucket'] {
69  switch (type) {
70    case 'started':
71      return 'started'
72    case 'unstarted':
73      return 'todo'
74    case 'completed':
75      return 'done'
76    case 'canceled':
77      return 'canceled'
78    default:
79      return 'backlog'
80  }
81}
82
83export const ISSUES_QUERY = `query Tickets($filter: IssueFilter, $first: Int) {
84  issues(filter: $filter, first: $first, orderBy: updatedAt) {
85    nodes {
86      id identifier title url priority priorityLabel estimate
87      updatedAt startedAt completedAt dueDate
88      state { name type }
89      project { name }
90      cycle { id number name startsAt endsAt }
91    }
92  }
93}`
94
95export const CYCLE_QUERY = `query Cycle($id: String!) {
96  cycle(id: $id) {
97    number name progress startsAt endsAt
98    scopeHistory completedScopeHistory
99  }
100}`
101
102export type Scope = { assignee: 'me' | 'anyone'; teamKey: string; project: string }
103
104/** Open tickets plus those closed since `sinceIso`, narrowed by the scope. */
105export function buildFilter(scope: Scope, sinceIso: string): Record<string, unknown> {
106  const and: Record<string, unknown>[] = []
107  if (scope.assignee === 'me') and.push({ assignee: { isMe: { eq: true } } })
108  if (scope.teamKey) and.push({ team: { key: { eq: scope.teamKey } } })
109  if (scope.project) and.push({ project: { name: { containsIgnoreCase: scope.project } } })
110  and.push({
111    or: [{ state: { type: { nin: ['completed', 'canceled'] } } }, { completedAt: { gte: sinceIso } }],
112  })
113  return { and }
114}
115
116export function parseTicket(raw: unknown): Ticket | undefined {
117  const n = obj(raw)
118  const id = str(n?.id)
119  const identifier = str(n?.identifier)
120  if (!n || !id || !identifier) return undefined
121  const state = obj(n.state)
122  const stateType = str(state?.type) ?? 'backlog'
123  const cycle = obj(n.cycle)
124  const cycleId = str(cycle?.id)
125  const startsAt = time(cycle?.startsAt)
126  const endsAt = time(cycle?.endsAt)
127  return {
128    id,
129    identifier,
130    title: str(n.title) ?? '(no title)',
131    url: str(n.url) ?? '',
132    stateName: str(state?.name) ?? stateType,
133    stateType,
134    bucket: bucketOf(stateType),
135    priority: num(n.priority) ?? 0,
136    priorityLabel: str(n.priorityLabel),
137    estimate: num(n.estimate),
138    updatedAt: time(n.updatedAt),
139    startedAt: time(n.startedAt),
140    completedAt: time(n.completedAt),
141    dueDate: str(n.dueDate),
142    project: str(obj(n.project)?.name),
143    cycle:
144      cycleId && startsAt !== undefined && endsAt !== undefined
145        ? { id: cycleId, number: num(cycle?.number) ?? 0, name: str(cycle?.name), startsAt, endsAt }
146        : undefined,
147  }
148}
149
150/** GraphQL answers 200 with `errors` for a bad query or key; surface the first message. */
151export function graphqlData(text: string): Record<string, unknown> {
152  const body = obj(JSON.parse(text))
153  const errors = body?.errors
154  if (Array.isArray(errors) && errors.length > 0) {
155    throw new Error(`Linear: ${str(obj(errors[0])?.message) ?? 'the query was refused'}`)
156  }
157  const data = obj(body?.data)
158  if (!data) throw new Error('Linear answered without data')
159  return data
160}
161
162export function parseTickets(text: string): Ticket[] {
163  const nodes = obj(graphqlData(text).issues)?.nodes
164  return (Array.isArray(nodes) ? nodes : []).map(parseTicket).filter((t): t is Ticket => t !== undefined && t.bucket !== 'canceled')
165}
166
167export function parseCycle(text: string): Cycle | undefined {
168  const c = obj(graphqlData(text).cycle)
169  const startsAt = time(c?.startsAt)
170  const endsAt = time(c?.endsAt)
171  if (!c || startsAt === undefined || endsAt === undefined) return undefined
172  return {
173    number: num(c.number) ?? 0,
174    name: str(c.name),
175    progress: num(c.progress) ?? 0,
176    startsAt,
177    endsAt,
178    scope: numbers(c.scopeHistory),
179    completed: numbers(c.completedScopeHistory),
180  }
181}
182
183async function post(f: Fetch, key: string, query: string, variables: Record<string, unknown>): Promise<string> {
184  const res = await f(ENDPOINT, {
185    method: 'POST',
186    headers: {
187      'content-type': 'application/json',
188      authorization: key.startsWith('lin_oauth_') ? `Bearer ${key}` : key,
189    },
190    body: JSON.stringify({ query, variables }),
191  })
192  if (res.status === 401 || res.status === 403) throw new Error(`Linear refused the key (${res.status}); check linearApiKey`)
193  if (!res.ok && res.status >= 500) throw new Error(`Linear answered ${res.status}`)
194  return res.text
195}
196
197export async function listTickets(f: Fetch, key: string, scope: Scope, sinceIso: string): Promise<Ticket[]> {
198  return parseTickets(await post(f, key, ISSUES_QUERY, { filter: buildFilter(scope, sinceIso), first: 100 }))
199}
200
201export async function getCycle(f: Fetch, key: string, id: string): Promise<Cycle | undefined> {
202  return parseCycle(await post(f, key, CYCLE_QUERY, { id }))
203}
204
205/** The cycle running at `now` among the tickets', the one most of them sit in. */
206export function currentCycleId(tickets: readonly Ticket[], now: number): string | undefined {
207  const counts = new Map<string, number>()
208  for (const t of tickets) {
209    if (t.cycle && t.cycle.startsAt <= now && now <= t.cycle.endsAt) counts.set(t.cycle.id, (counts.get(t.cycle.id) ?? 0) + 1)
210  }
211  let best: string | undefined
212  let n = 0
213  for (const [id, c] of counts) if (c > n) [best, n] = [id, c]
214  return best
215}
216
217const DAY = 86_400_000
218
219/**
220 * Local day number of a time: whole days since the epoch in the viewer's timezone.
221 * `tz` is an offset in minutes (as `Date.getTimezoneOffset` returns) or a function
222 * of the time. Left out, each time uses the offset in force at that moment, so a
223 * daylight-saving change inside the window does not shift older days.
224 */
225export type TzOffset = number | ((ms: number) => number)
226const localOffset = (ms: number) => new Date(ms).getTimezoneOffset()
227export const dayOf = (ms: number, tz: TzOffset = localOffset) =>
228  Math.floor((ms - (typeof tz === 'function' ? tz(ms) : tz) * 60_000) / DAY)
229
230/** Tickets closed on each of the last `days` days, oldest first, today last. */
231export function doneByDay(tickets: readonly Ticket[], now: number, days: number, tz?: TzOffset): number[] {
232  const today = dayOf(now, tz)
233  const out = new Array<number>(days).fill(0)
234  for (const t of tickets) {
235    if (t.bucket !== 'done' || t.completedAt === undefined) continue
236    const ago = today - dayOf(t.completedAt, tz)
237    if (ago >= 0 && ago < days) out[days - 1 - ago] += 1
238  }
239  return out
240}
241
242export type Counts = Record<Bucket, number>
243
244export function count(tickets: readonly Ticket[]): Counts {
245  const c: Counts = { started: 0, todo: 0, backlog: 0, done: 0 }
246  for (const t of tickets) if (t.bucket !== 'canceled') c[t.bucket] += 1
247  return c
248}
249
250export const summaryText = (c: Counts, days: number) =>
251  `linear: ${c.started} in progress · ${c.todo} todo · ${c.done} done (${days}d)`
252
253const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
254
255/** One row of block characters, scaled to the largest value (a zero stays blank). */
256export function spark(values: readonly number[]): string {
257  const max = Math.max(0, ...values)
258  return values.map(v => (v <= 0 || max === 0 ? ' ' : BLOCKS[Math.min(7, Math.ceil((v / max) * 8) - 1)])).join('')
259}
260
261/**
262 * A vertical bar chart `height` rows tall, one column per value, top row first;
263 * each column is as tall as its value is large next to the biggest one.
264 */
265export function barChart(values: readonly number[], height: number): string[] {
266  const max = Math.max(0, ...values)
267  const rows: string[] = []
268  for (let r = height - 1; r >= 0; r--) {
269    rows.push(
270      values
271        .map(v => {
272          if (max === 0 || v <= 0) return r === 0 ? '▁' : ' '
273          const eighths = Math.round((v / max) * height * 8)
274          const below = r * 8
275          if (eighths >= below + 8) return '█'
276          if (eighths <= below) return ' '
277          return BLOCKS[eighths - below - 1]
278        })
279        .join(''),
280    )
281  }
282  return rows
283}
284
285/** A horizontal bar of `width` cells split in proportion to `parts`, each as its own run of one glyph. */
286export function stacked(parts: readonly { n: number; glyph: string }[], width: number): { glyph: string; cells: number }[] {
287  const total = parts.reduce((s, p) => s + p.n, 0)
288  if (total === 0) return []
289  const out = parts.map(p => ({ glyph: p.glyph, cells: Math.floor((p.n / total) * width), n: p.n }))
290  let used = out.reduce((s, p) => s + p.cells, 0)
291  // a state with tickets is never invisible; leftover cells go to the largest
292  for (const p of out) if (p.n > 0 && p.cells === 0 && used < width) (p.cells = 1), (used += 1)
293  const largest = out.reduce((a, b) => (b.n > a.n ? b : a), out[0]!)
294  largest.cells += width - used
295  return out.filter(p => p.cells > 0).map(({ glyph, cells }) => ({ glyph, cells }))
296}
297
298/** Fraction of the cycle that has elapsed at `now`, 0 to 1. */
299export function cycleElapsed(c: Pick<Cycle, 'startsAt' | 'endsAt'>, now: number): number {
300  const span = c.endsAt - c.startsAt
301  return span <= 0 ? 1 : Math.min(1, Math.max(0, (now - c.startsAt) / span))
302}
303
304export function fit(text: string, width: number): string {
305  return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
306}
307
308export function clampInt(v: unknown, fallback: number, min: number, max: number): number {
309  const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : fallback
310  return Math.min(max, Math.max(min, n))
311}
312
313export const BUCKET_LABEL: Record<Bucket, string> = {
314  started: 'In progress',
315  todo: 'Todo',
316  backlog: 'Backlog',
317  done: 'Done',
318}
319
320export const BUCKET_GLYPH: Record<Bucket, string> = { started: '◐', todo: '○', backlog: '·', done: '●' }
321export const BUCKET_COLOR: Record<Bucket, string | undefined> = {
322  started: 'yellow',
323  todo: 'blue',
324  backlog: undefined,
325  done: 'green',
326}
327
328/** Urgent first, then high, medium, low; no priority last (Linear's 0 means none). */
329export const priorityRank = (p: number) => (p === 0 ? 5 : p)
330
331export function sortTickets(tickets: readonly Ticket[]): Ticket[] {
332  return [...tickets].sort((a, b) => {
333    if (a.bucket === 'done' && b.bucket === 'done') return (b.completedAt ?? 0) - (a.completedAt ?? 0)
334    const r = priorityRank(a.priority) - priorityRank(b.priority)
335    return r !== 0 ? r : (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
336  })
337}
338