Link previews for work items
A work item's page, /<workspace>/-/work/<number>, gets a card of its own when it was filed in a public repository, and an old issue's address gets the card of the work item it became. Profiles count work items opened, and projects open work items linked to their repository.
- G1T-Session:
- ses_01m566357hejh9yeqjbetx23f3
4 files+96−620/4 viewed
| 112 | 112 | ||
| 113 | 113 | /** Lucide's icons, as the site uses them, at 24 units. */ | |
| 114 | 114 | export const ICON = { | |
| 115 | − | issue: `<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>`, | |
| 116 | − | issueDone: `<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>`, | |
| 117 | − | issueNotPlanned: `<circle cx="12" cy="12" r="10"/><line x1="9" x2="15" y1="15" y2="9"/>`, | |
| 115 | + | item: `<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>`, | |
| 116 | + | itemDone: `<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>`, | |
| 117 | + | itemNotPlanned: `<circle cx="12" cy="12" r="10"/><line x1="9" x2="15" y1="15" y2="9"/>`, | |
| 118 | 118 | pull: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" x2="6" y1="9" y2="21"/>`, | |
| 119 | 119 | pullDraft: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M18 6V5"/><path d="M18 11v-1"/><line x1="6" x2="6" y1="9" y2="21"/>`, | |
| 120 | 120 | merged: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>`, |
| 5 | 5 | * dot grid and lavender kept for accents. | |
| 6 | 6 | */ | |
| 7 | 7 | import { COLOR, ICON, LOCKUP_BOX, backdropSvg, dataUri, iconSvg, lockupSvg } from "./brand.ts"; | |
| 8 | − | import type { Card, IssueState } from "./resolve.ts"; | |
| 8 | + | import type { Card, ItemState } from "./resolve.ts"; | |
| 9 | 9 | import type { PullStatus } from "@g1t/contracts"; | |
| 10 | 10 | ||
| 11 | 11 | export const WIDTH = 1200; | |
| ⋯ | |||
| 308 | 308 | closed: ["Closed", ICON.pullClosed, TONE.closed], | |
| 309 | 309 | }; | |
| 310 | 310 | ||
| 311 | − | const ISSUE_BADGE: Record<IssueState, [string, string, Tone]> = { | |
| 312 | − | open: ["Open", ICON.issue, TONE.open], | |
| 313 | − | completed: ["Closed", ICON.issueDone, TONE.merged], | |
| 314 | − | not_planned: ["Not planned", ICON.issueNotPlanned, TONE.quiet], | |
| 311 | + | const ITEM_BADGE: Record<ItemState, [string, string, Tone]> = { | |
| 312 | + | open: ["Open", ICON.item, TONE.open], | |
| 313 | + | completed: ["Closed", ICON.itemDone, TONE.merged], | |
| 314 | + | not_planned: ["Not planned", ICON.itemNotPlanned, TONE.quiet], | |
| 315 | 315 | }; | |
| 316 | 316 | ||
| 317 | 317 | function count(n: number, one: string, many: string): [string, string] { | |
| ⋯ | |||
| 350 | 350 | case "person": { | |
| 351 | 351 | const [merged, mergedNoun] = count(card.pullsMerged, "pull request merged", "pull requests merged"); | |
| 352 | 352 | const [pulls, pullNoun] = count(card.pullsOpen, "open pull request", "open pull requests"); | |
| 353 | − | const [issues, issueNoun] = count(card.issues, "issue opened", "issues opened"); | |
| 353 | + | const [items, itemNoun] = count(card.items, "work item opened", "work items opened"); | |
| 354 | 354 | return frame({ | |
| 355 | 355 | address: `g1t.sh/u/${card.username}`, | |
| 356 | 356 | // The username above the name they go by; "Profile" when there is none. | |
| ⋯ | |||
| 360 | 360 | facts: [ | |
| 361 | 361 | fact(ICON.merged, merged, mergedNoun), | |
| 362 | 362 | fact(ICON.pull, pulls, pullNoun), | |
| 363 | − | fact(ICON.issue, issues, issueNoun), | |
| 363 | + | fact(ICON.item, items, itemNoun), | |
| 364 | 364 | ], | |
| 365 | 365 | }); | |
| 366 | 366 | } | |
| 367 | 367 | ||
| 368 | 368 | case "project": { | |
| 369 | − | const [issues, issueNoun] = count(card.issues, "open issue", "open issues"); | |
| 369 | + | const [items, itemNoun] = count(card.items, "open work item", "open work items"); | |
| 370 | 370 | const [pulls, pullNoun] = count(card.pulls, "open pull request", "open pull requests"); | |
| 371 | 371 | const named = card.name.toLowerCase() !== card.repo.toLowerCase(); | |
| 372 | 372 | return frame({ | |
| ⋯ | |||
| 374 | 374 | eyebrow: eyebrow(named ? `${card.owner}/${card.repo}` : `${card.owner} /`, true), | |
| 375 | 375 | title: named ? title(card.name, 2) : repoTitle(card.repo), | |
| 376 | 376 | description: card.description, | |
| 377 | − | facts: [fact(ICON.issue, issues, issueNoun), fact(ICON.pull, pulls, pullNoun)], | |
| 377 | + | facts: [fact(ICON.item, items, itemNoun), fact(ICON.pull, pulls, pullNoun)], | |
| 378 | 378 | }); | |
| 379 | 379 | } | |
| 380 | 380 | ||
| 381 | − | case "issue": { | |
| 382 | − | const [label, shapes, tone] = ISSUE_BADGE[card.state]; | |
| 381 | + | case "item": { | |
| 382 | + | const [label, shapes, tone] = ITEM_BADGE[card.state]; | |
| 383 | 383 | return frame({ | |
| 384 | − | address: `g1t.sh/${card.owner}/${card.repo}/issues/${card.number}`, | |
| 385 | − | eyebrow: eyebrowWithNumber(`${card.owner}/${card.repo}`, card.number), | |
| 384 | + | address: `g1t.sh/${card.workspace}/-/work/${card.number}`, | |
| 385 | + | eyebrow: eyebrowWithNumber(card.workspace, card.number), | |
| 386 | 386 | title: title(card.title), | |
| 387 | 387 | description: null, | |
| 388 | − | facts: [badge(label, shapes, tone), plain("Issue"), dot(), byline(card.author, card.requestedBy)], | |
| 388 | + | facts: [badge(label, shapes, tone), plain("Work item"), dot(), byline(card.author, card.requestedBy)], | |
| 389 | 389 | }); | |
| 390 | 390 | } | |
| 391 | 391 | ||
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import type { Issue, Project, Pull, Repo, Result, Workspace } from "@g1t/contracts"; | |
| 4 | + | import type { Project, Pull, Repo, Result, WorkItem, Workspace } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { RENDER_VERSION, cacheKey } from "./cache.ts"; | |
| 7 | 7 | import { type Sources, cardPath, clean, docsCard, resolve, segments } from "./resolve.ts"; | |
| ⋯ | |||
| 30 | 30 | "acme/vault": repo("acme", "vault", true), | |
| 31 | 31 | }; | |
| 32 | 32 | ||
| 33 | − | const ISSUE = { number: 7, title: "Fix the thing", state: "closed", reason: "not_planned", author, requestedBy: null } as Issue; | |
| 33 | + | // Work item 41 of acme, filed in acme/web as its issue #7. | |
| 34 | + | const ITEM = { workspace: "acme", number: 41, title: "Fix the thing", state: "closed", reason: "not_planned", author, requestedBy: null } as WorkItem; | |
| 34 | 35 | // A change g1t made for ada. | |
| 35 | 36 | const PULL = { number: 8, title: "Make the thing", status: "merged", author: { id: "usr_g1t_agent", username: "g1t", kind: "agent" }, requestedBy: author } as Pull; | |
| 36 | 37 | ||
| ⋯ | |||
| 60 | 61 | get: async (path, viewer) => seen(viewer, REPOS[`${path.namespace}/${path.name}`.toLowerCase()] ? ok(REPOS[`${path.namespace}/${path.name}`.toLowerCase()]) : notFound), | |
| 61 | 62 | }, | |
| 62 | 63 | work: { | |
| 63 | − | counts: async (path, viewer) => seen(viewer, hidden(path) ? notFound : ok({ issues: 3, pulls: 2 })), | |
| 64 | − | getIssue: async (path, number, viewer) => | |
| 65 | − | seen(viewer, !hidden(path) && number === 7 ? ok({ issue: ISSUE, pulls: [], comments: [] }) : notFound), | |
| 64 | + | counts: async (path, viewer) => seen(viewer, hidden(path) ? notFound : ok({ items: 3, pulls: 2 })), | |
| 65 | + | // Item 42 was filed in the private acme/vault: no one outside sees it. | |
| 66 | + | getWorkItem: async (workspace, number, viewer) => | |
| 67 | + | seen(viewer, workspace === "acme" && number === 41 ? ok({ item: ITEM, pulls: [], comments: [] }) : notFound), | |
| 68 | + | resolveRef: async (viewer, context, number) => | |
| 69 | + | seen( | |
| 70 | + | viewer, | |
| 71 | + | "repo" in context && !hidden(context.repo) && number === 7 | |
| 72 | + | ? { kind: "work_item" as const, workspace: "acme", number: 41, itemId: "iss_7" } | |
| 73 | + | : { kind: "none" as const }, | |
| 74 | + | ), | |
| 66 | 75 | getPull: async (path, number, viewer) => | |
| 67 | − | seen(viewer, !hidden(path) && number === 8 ? ok({ pull: PULL, issue: null, comments: [] } as never) : notFound), | |
| 76 | + | seen(viewer, !hidden(path) && number === 8 ? ok({ pull: PULL, item: null, comments: [] } as never) : notFound), | |
| 68 | 77 | byAuthor: async (_username, viewer) => | |
| 69 | − | seen(viewer, ok({ items: [], next: null, repos: [], counts: { pullsMerged: 4, pullsOpen: 1, pulls: 6, issues: 2, issuesOpen: 1 } })), | |
| 78 | + | seen(viewer, ok({ items: [], next: null, repos: [], counts: { pullsMerged: 4, pullsOpen: 1, pulls: 6, items: 2, itemsOpen: 1 } })), | |
| 70 | 79 | }, | |
| 71 | 80 | projects: { | |
| 72 | 81 | get: async (workspace, slug, viewer) => | |
| ⋯ | |||
| 127 | 136 | bio: "Engines.", | |
| 128 | 137 | pullsMerged: 4, | |
| 129 | 138 | pullsOpen: 1, | |
| 130 | − | issues: 2, | |
| 139 | + | items: 2, | |
| 131 | 140 | avatar: null, | |
| 132 | 141 | }); | |
| 133 | 142 | assert.ok(viewers.every((viewer) => viewer === null)); | |
| ⋯ | |||
| 144 | 153 | repo: "web", | |
| 145 | 154 | name: "Web app", | |
| 146 | 155 | description: "The storefront", | |
| 147 | − | issues: 3, | |
| 156 | + | items: 3, | |
| 148 | 157 | pulls: 2, | |
| 149 | 158 | }); | |
| 150 | 159 | }); | |
| 151 | 160 | ||
| 152 | − | test("issues, pull requests and Soon pages get cards of their own", async () => { | |
| 161 | + | test("work items, pull requests and Soon pages get cards of their own", async () => { | |
| 153 | 162 | const { sources: s } = sources(); | |
| 154 | − | assert.deepEqual(await resolve("/acme/web/issues/7", s), { | |
| 155 | − | kind: "issue", | |
| 156 | − | owner: "acme", | |
| 157 | − | repo: "web", | |
| 158 | − | number: 7, | |
| 163 | + | const item = { | |
| 164 | + | kind: "item", | |
| 165 | + | workspace: "acme", | |
| 166 | + | number: 41, | |
| 159 | 167 | title: "Fix the thing", | |
| 160 | 168 | state: "not_planned", | |
| 161 | 169 | author: "ada", | |
| 162 | 170 | requestedBy: null, | |
| 163 | − | }); | |
| 171 | + | }; | |
| 172 | + | assert.deepEqual(await resolve("/acme/-/work/41", s), item); | |
| 173 | + | assert.deepEqual(await resolve("/Acme/-/work/41", s), item); | |
| 174 | + | // An old issue's address shows the work item it became. | |
| 175 | + | assert.deepEqual(await resolve("/acme/web/issues/7", s), item); | |
| 176 | + | // One no one outside the workspace may see gets the generic card. | |
| 177 | + | assert.deepEqual(await resolve("/acme/-/work/42", s), { kind: "brand" }); | |
| 164 | 178 | const pull = await resolve("/acme/web/pull/8", s); | |
| 165 | 179 | assert.equal(pull.kind, "pull"); | |
| 166 | 180 | assert.equal(pull.kind === "pull" && pull.state, "merged"); | |
| ⋯ | |||
| 199 | 213 | ||
| 200 | 214 | test("every lookup is made with no viewer", async () => { | |
| 201 | 215 | const { sources: s, viewers } = sources(); | |
| 202 | − | for (const path of ["/acme", "/acme/web", "/acme/web/issues/7", "/acme/web/pull/8", "/acme/vault/pull/8"]) { | |
| 216 | + | for (const path of ["/acme", "/acme/web", "/acme/-/work/41", "/acme/web/issues/7", "/acme/web/pull/8", "/acme/vault/pull/8"]) { | |
| 203 | 217 | await resolve(path, s); | |
| 204 | 218 | } | |
| 205 | 219 | assert.ok(viewers.length > 0); | |
| ⋯ | |||
| 259 | 273 | assert.equal(cardPath("/u/Ada"), "/u/ada"); | |
| 260 | 274 | assert.equal(cardPath("/acme/web/issues/12"), "/acme/web/issues/12"); | |
| 261 | 275 | assert.equal(cardPath("/acme/web/issues/012"), "/acme/web"); | |
| 276 | + | assert.equal(cardPath("/Acme/-/work/12"), "/acme/-/work/12"); | |
| 277 | + | assert.equal(cardPath("/acme/-/work/12/extra"), "/acme"); | |
| 278 | + | assert.equal(cardPath("/acme/-/work/labels"), "/acme"); | |
| 262 | 279 | assert.equal(cardPath("/acme/web/soon/no-such-thing"), "/acme/web"); | |
| 263 | 280 | assert.equal(cardPath("//evil"), "/"); | |
| 264 | 281 | }); | |
| 265 | 282 | ||
| 266 | 283 | test("a path resolves to the same card as the page it is keyed by", async () => { | |
| 267 | 284 | const { sources: s } = sources(); | |
| 268 | − | for (const path of ["/acme/web/tree/main", "/acme/web/pull/7/files", "/acme/-/settings", "/acme/web/issues/3"]) { | |
| 285 | + | for (const path of ["/acme/web/tree/main", "/acme/web/pull/7/files", "/acme/-/settings", "/acme/web/issues/3", "/Acme/-/work/41"]) { | |
| 269 | 286 | assert.deepEqual(await resolve(path, s), await resolve(cardPath(path), s), path); | |
| 270 | 287 | } | |
| 271 | 288 | }); | |
| 6 | 6 | * page that does not exist, or anything that fails to load gets the generic | |
| 7 | 7 | * card: never a name or a title that a link preview could leak. | |
| 8 | 8 | */ | |
| 9 | − | import type { IdentityApi, Issue, Project, PullStatus, ReposApi, WorkApi, ProjectsApi } from "@g1t/contracts"; | |
| 9 | + | import type { IdentityApi, Project, PullStatus, ReposApi, WorkApi, WorkItem, ProjectsApi } from "@g1t/contracts"; | |
| 10 | 10 | ||
| 11 | 11 | // The one list of Soon pages, shared with the site so the two never disagree. | |
| 12 | 12 | import { roadmapItem } from "../../../apps/web/app/lib/roadmap.ts"; | |
| 13 | 13 | ||
| 14 | − | export type IssueState = "open" | "completed" | "not_planned"; | |
| 14 | + | export type ItemState = "open" | "completed" | "not_planned"; | |
| 15 | 15 | ||
| 16 | 16 | export type Card = | |
| 17 | 17 | /** | |
| ⋯ | |||
| 41 | 41 | /** Over public repositories only. */ | |
| 42 | 42 | pullsMerged: number; | |
| 43 | 43 | pullsOpen: number; | |
| 44 | − | issues: number; | |
| 44 | + | items: number; | |
| 45 | 45 | avatar?: string | null; | |
| 46 | 46 | icon?: string; | |
| 47 | 47 | } | |
| ⋯ | |||
| 51 | 51 | repo: string; | |
| 52 | 52 | name: string; | |
| 53 | 53 | description: string | null; | |
| 54 | − | issues: number; | |
| 54 | + | /** Open work items linked to the repository. */ | |
| 55 | + | items: number; | |
| 55 | 56 | pulls: number; | |
| 56 | 57 | } | |
| 57 | 58 | | { | |
| 58 | − | kind: "issue"; | |
| 59 | − | owner: string; | |
| 60 | − | repo: string; | |
| 59 | + | /** A work item, at `/<workspace>/-/work/<number>`. */ | |
| 60 | + | kind: "item"; | |
| 61 | + | workspace: string; | |
| 61 | 62 | number: number; | |
| 62 | 63 | title: string; | |
| 63 | − | state: IssueState; | |
| 64 | + | state: ItemState; | |
| 64 | 65 | author: string; | |
| 65 | 66 | /** For one g1t's agent filed: the person it was working for. */ | |
| 66 | 67 | requestedBy: string | null; | |
| ⋯ | |||
| 86 | 87 | export type Sources = { | |
| 87 | 88 | identity: Pick<IdentityApi, "getWorkspace" | "profile">; | |
| 88 | 89 | repos: Pick<ReposApi, "get">; | |
| 89 | − | work: Pick<WorkApi, "counts" | "getIssue" | "getPull" | "byAuthor">; | |
| 90 | + | work: Pick<WorkApi, "counts" | "getWorkItem" | "resolveRef" | "getPull" | "byAuthor">; | |
| 90 | 91 | projects: Pick<ProjectsApi, "get" | "list">; | |
| 91 | 92 | }; | |
| 92 | 93 | ||
| ⋯ | |||
| 117 | 118 | address: "g1t.sh/search", | |
| 118 | 119 | eyebrow: "Search", | |
| 119 | 120 | title: "Search all of g1t", | |
| 120 | − | description: "Repositories, code, issues, pull requests and people, in one search.", | |
| 121 | + | description: "Repositories, code, work items, pull requests and people, in one search.", | |
| 121 | 122 | }, | |
| 122 | 123 | policies: { | |
| 123 | 124 | kind: "page", | |
| ⋯ | |||
| 254 | 255 | return parts.length === 2 && page in PAGES ? join(lower, parts[1].toLowerCase()) : "/"; | |
| 255 | 256 | } | |
| 256 | 257 | if (!NAME.test(owner)) return "/"; | |
| 258 | + | if (repo === "-" && section === "work" && item !== undefined && isNumber(item) && parts.length === 4) return join(lower, "-", "work", item); | |
| 257 | 259 | if (repo === undefined || repo === "-") return join(lower); | |
| 258 | 260 | if (!NAME.test(repo)) return "/"; | |
| 261 | + | // An old issue's address: the work item it became. | |
| 259 | 262 | if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) return join(owner, repo, "issues", item); | |
| 260 | 263 | if (section === "pull" && item !== undefined && isNumber(item)) return join(owner, repo, "pull", item); | |
| 261 | 264 | if (section === "soon" && item !== undefined && parts.length === 4 && roadmapItem(item)) return join(owner, repo, "soon", item); | |
| ⋯ | |||
| 292 | 295 | bio: profile.bio, | |
| 293 | 296 | pullsMerged: counts?.pullsMerged ?? 0, | |
| 294 | 297 | pullsOpen: counts?.pullsOpen ?? 0, | |
| 295 | − | issues: counts?.issues ?? 0, | |
| 298 | + | items: counts?.items ?? 0, | |
| 296 | 299 | avatar: profile.avatar, | |
| 297 | 300 | }; | |
| 298 | 301 | } | |
| ⋯ | |||
| 304 | 307 | } | |
| 305 | 308 | if (!NAME.test(owner)) return null; | |
| 306 | 309 | ||
| 310 | + | // A work item: only one filed in a public repository is anyone's to see. | |
| 311 | + | if (repo === "-" && section === "work" && item !== undefined && isNumber(item) && parts.length === 4) { | |
| 312 | + | return itemCard(work, owner.toLowerCase(), Number(item)); | |
| 313 | + | } | |
| 314 | + | ||
| 307 | 315 | // A workspace, or one of its own pages under `-`. | |
| 308 | 316 | if (repo === undefined || repo === "-") { | |
| 309 | 317 | const workspace = await identity.getWorkspace(owner.toLowerCase()); | |
| ⋯ | |||
| 328 | 336 | const { namespace, name } = found.value; | |
| 329 | 337 | const canonical = { namespace, name }; | |
| 330 | 338 | ||
| 339 | + | // An old issue's address names the work item it became. | |
| 331 | 340 | if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) { | |
| 332 | − | const issue = await work.getIssue(canonical, Number(item), null); | |
| 333 | − | if (issue.ok) { | |
| 334 | − | return { | |
| 335 | − | kind: "issue", | |
| 336 | − | owner: namespace, | |
| 337 | − | repo: name, | |
| 338 | − | number: issue.value.issue.number, | |
| 339 | − | title: issue.value.issue.title, | |
| 340 | − | state: issueState(issue.value.issue), | |
| 341 | − | author: issue.value.issue.author.username, | |
| 342 | − | requestedBy: issue.value.issue.requestedBy?.username ?? null, | |
| 343 | − | }; | |
| 341 | + | const target = await work.resolveRef(null, { repo: canonical }, Number(item)); | |
| 342 | + | if (target.kind === "work_item") { | |
| 343 | + | const card = await itemCard(work, target.workspace, target.number); | |
| 344 | + | if (card) return card; | |
| 344 | 345 | } | |
| 345 | 346 | } | |
| 346 | 347 | ||
| ⋯ | |||
| 386 | 387 | name: shown?.name ?? name, | |
| 387 | 388 | // Only projects have descriptions; a private project keeps its own to itself. | |
| 388 | 389 | description: shown?.description ?? null, | |
| 389 | − | issues: counts?.ok ? counts.value.issues : 0, | |
| 390 | + | items: counts?.ok ? counts.value.items : 0, | |
| 390 | 391 | pulls: counts?.ok ? counts.value.pulls : 0, | |
| 391 | 392 | }; | |
| 392 | 393 | } | |
| ⋯ | |||
| 395 | 396 | return /^[1-9][0-9]{0,8}$/.test(value); | |
| 396 | 397 | } | |
| 397 | 398 | ||
| 398 | − | function issueState(issue: Issue): IssueState { | |
| 399 | − | if (issue.state === "open") return "open"; | |
| 400 | − | return issue.reason === "not_planned" ? "not_planned" : "completed"; | |
| 399 | + | /** A work item's card, looked up with no viewer. */ | |
| 400 | + | async function itemCard(work: Sources["work"], workspace: string, number: number): Promise<Card | null> { | |
| 401 | + | const found = await work.getWorkItem(workspace, number, null); | |
| 402 | + | if (!found.ok) return null; | |
| 403 | + | const item = found.value.item; | |
| 404 | + | return { | |
| 405 | + | kind: "item", | |
| 406 | + | workspace: item.workspace, | |
| 407 | + | number: item.number, | |
| 408 | + | title: item.title, | |
| 409 | + | state: itemState(item), | |
| 410 | + | author: item.author.username, | |
| 411 | + | requestedBy: item.requestedBy?.username ?? null, | |
| 412 | + | }; | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | function itemState(item: WorkItem): ItemState { | |
| 416 | + | if (item.state === "open") return "open"; | |
| 417 | + | return item.reason === "not_planned" ? "not_planned" : "completed"; | |
| 401 | 418 | } | |
| 402 | 419 | ||
| 403 | 420 | /** The card for a page of the docs, from what the page says about itself. */ | |
Comments
Sign in to comment.