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…

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.
days window).days days, today last, in your local timezone, from each ticket's completedAt.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.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.
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.
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
hooks/linear-tickets.tsx 311 lines1/**
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}
311hooks/linear-api.ts 338 lines1/**
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