diff --git a/.gitignore b/.gitignore index b21c707..15ead89 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ examples/*/static/gitlab-assets/ node_modules/.cache/ .env -external/ \ No newline at end of file +external/ +# Visual brainstorming companion +.superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md index 4bf7ea8..ec26056 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,14 @@ docusaurus build `dangerouslySetInnerHTML`. There is an XSS regression test in `src/gitlab/markdown.test.ts` — keep it green. - **CSS modules** are typed via `src/css.d.ts`. +- **In `src/components/*` (browser-bundled), never spread a `Map`/`Set` iterator** + — use `Array.from(map.values())`, not `[...map.values()]` (same for `.keys()` / + `.entries()`). Docusaurus bundles these files with Babel, whose loose / + `iterableIsArray` spread assumption mis-compiles `[...nonArrayIterable]` (a Map + iterator has no `.length`/indices), yielding a wrong result and runtime errors + like `Cannot read properties of undefined (reading 'keys')`. `tsc`-only code + (plugin/remark/`src/gitlab/*`) runs in Node and isn't affected, but prefer + `Array.from` there too for consistency. - **Code highlighting** uses `prism-react-renderer` (a normal, SSR-safe npm dependency), NOT `@theme/CodeBlock`. Importing Docusaurus theme aliases (`@theme/*`) from this pre-bundled package breaks the Docusaurus SSR build with diff --git a/README.md b/README.md index 8a3d5d0..fa92bd3 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ ![NPM Version](https://img.shields.io/npm/v/%40ebuildy%2Fdocusaurus-plugin-gitlab) -Embed **GitLab** resources — project info, README, releases, issues, and any -file or code snippet — directly in your **Docusaurus 3** documentation using MDX +Embed **GitLab** resources — project info, README, releases, issues, and any +file or code snippet — directly in your **Docusaurus 3** documentation using MDX components. ![Screenshot](./docs/screenshot1.png) @@ -13,11 +13,11 @@ components. All data is fetched **at build time** and baked into your static site. No API tokens or network calls ever reach the browser, and pages stay fast. -- ✅ Works with **gitlab.com** and self-hosted GitLab (configurable host) -- ✅ Authenticated (private projects) or public, via a build-time token -- ✅ Five ready-to-use JSX components -- ✅ README images **and badges are downloaded and localized** (offline-safe, frozen at build time) -- ✅ On-disk caching, theme-aware (Infima) styling, graceful error fallbacks +- ✅ Works with **gitlab.com** and self-hosted GitLab (configurable host) +- ✅ Authenticated (private projects) or public, via a build-time token +- ✅ Five ready-to-use JSX components +- ✅ README images **and badges are downloaded and localized** (offline-safe, frozen at build time) +- ✅ On-disk caching, theme-aware (Infima) styling, graceful error fallbacks > Requires Docusaurus **3.x** and Node **20, 22, or 24** (Docusaurus 3 itself > needs Node 20+). @@ -29,7 +29,7 @@ npm install @ebuildy/docusaurus-plugin-gitlab ``` > **ESM-only.** This package ships as ES modules (all of its remark/rehype -> dependencies are ESM). Load it from an ESM config — `docusaurus.config.ts` or +> dependencies are ESM). Load it from an ESM config — `docusaurus.config.ts` or > `docusaurus.config.mjs` (the examples below use `import`). If your site still > uses a CommonJS `docusaurus.config.js` on **Node < 20.19**, either switch the > config to ESM or load the plugin with `await import(...)`. On **Node 20.19+** @@ -82,7 +82,7 @@ import * as Gitlab from "@ebuildy/docusaurus-plugin-gitlab/components"; export default { ...MDXComponents, ...Gitlab }; ``` -Now write the components in any `.mdx` page — no per-page imports needed. +Now write the components in any `.mdx` page — no per-page imports needed. ## Components @@ -92,7 +92,7 @@ namespace path (`project="group/subgroup/repo"`). ### `` A card with name, description, topics, stars/forks, and last activity. It can -also embed compact **releases**, **commits**, and **issues** sections — each is +also embed compact **releases**, **commits**, and **issues** sections — each is opt-in and only fetched when its count prop is a positive number. ```mdx @@ -106,24 +106,24 @@ opt-in and only fetched when its count prop is a positive number. | Prop | Type | Default | Description | |---|---|---|---| -| `project` | string \| number | — | **Required.** Project path or ID | +| `project` | string \| number | — | **Required.** Project path or ID | | `showStats` | boolean | `true` | Show the stars / forks / created / last-activity row | | `showLinks` | boolean | `true` | Link the release / commit / issue items. Set `false` to render them as plain text (the card title stays a link) | | `link` | string | project's `web_url` | Override the card title's link target | -| `releases` | number | — | Embed the latest N releases. Absent or `≤ 0` — not fetched, not rendered | -| `commits` | number | — | Embed the latest N commits. Absent or `≤ 0` — not fetched, not rendered | -| `issues` | number | — | Embed the latest N issues. Absent or `≤ 0` — not fetched, not rendered | +| `releases` | number | — | Embed the latest N releases. Absent or `≤ 0` — not fetched, not rendered | +| `commits` | number | — | Embed the latest N commits. Absent or `≤ 0` — not fetched, not rendered | +| `issues` | number | — | Embed the latest N issues. Absent or `≤ 0` — not fetched, not rendered | | `releasesLayout` | `"list"` \| `"cards"` | `"list"` | Layout for the releases section. An invalid value fails the build | | `commitsLayout` | `"list"` \| `"cards"` | `"list"` | Layout for the commits section. An invalid value fails the build | | `issuesLayout` | `"list"` \| `"cards"` | `"list"` | Layout for the issues section. An invalid value fails the build | -> Each section's `list` layout renders one compact line per item — release: +> Each section's `list` layout renders one compact line per item — release: > tag and name; commit: linked short SHA, title, and author; issue: linked > `#iid` and title. Every item shows its date (absolute, e.g. `May 1, 2020`) > pinned to the right. `cards` renders a richer variant of the same data. > -> The `showStats` row can also show extra pills — total commit count, -> contributor count, open issue count, repository size — automatically, +> The `showStats` row can also show extra pills — total commit count, +> contributor count, open issue count, repository size — automatically, > whenever that data is available; there's no attribute to request them. Set > `showStats={false}` to hide the whole row (pills included). Commit count and > repository size come from the project's `statistics`, which GitLab only @@ -143,13 +143,13 @@ localized; links resolve back to GitLab. | Prop | Type | Default | Description | |---|---|---|---| -| `project` | string \| number | — | **Required.** | +| `project` | string \| number | — | **Required.** | | `ref` | string | default branch | Branch, tag, or commit SHA | | `toc` | `"hidden" \| "inline" \| "sidebar"` | _auto_ | Where to render the table of contents | > **Table of contents:** if the README contains a GitLab `[[_TOC_]]` marker on its > own line, it is replaced at build time with a generated table of contents linking -> to the document's `h2`–`h5` headings (which receive slug `id`s). This also works +> to the document's `h2`–`h5` headings (which receive slug `id`s). This also works > for markdown embedded via `` and for release notes. The `toc` prop > overrides this default: `toc="inline"` always renders the inline TOC (even without > a marker); `toc="sidebar"` renders the README's headings in the page's native @@ -162,7 +162,7 @@ localized; links resolve back to GitLab. > ``` > > Note: in `sidebar` mode the README is injected as pre-rendered HTML, so -> Docusaurus' broken-anchor checker can't see its heading anchors — harmless at the +> Docusaurus' broken-anchor checker can't see its heading anchors — harmless at the > default `onBrokenAnchors: "warn"` (build succeeds, links work), but would fail a > build configured with `onBrokenAnchors: "throw"`. @@ -186,7 +186,7 @@ A list of releases with notes, dates, and asset links. | Prop | Type | Default | Description | |---|---|---|---| -| `project` | string \| number | — | **Required.** | +| `project` | string \| number | — | **Required.** | | `limit` | number | `10` | Max releases to show | | `includePrereleases` | boolean | `false` | Include upcoming/pre-releases | @@ -200,10 +200,10 @@ A filtered list of issues. | Prop | Type | Default | Description | |---|---|---|---| -| `project` | string \| number | — | **Required.** | +| `project` | string \| number | — | **Required.** | | `state` | string | `opened` | `opened`, `closed`, or `all` | -| `labels` | string | — | Comma-separated label filter | -| `milestone` | string | — | Milestone title filter | +| `labels` | string | — | Comma-separated label filter | +| `milestone` | string | — | Milestone title filter | | `limit` | number | `20` | Max issues to show | ### `` @@ -220,8 +220,8 @@ syntax-highlighted code block (via `prism-react-renderer`). | Prop | Type | Default | Description | |---|---|---|---| -| `project` | string \| number | — | **Required.** | -| `path` | string | — | **Required.** File path within the repo | +| `project` | string \| number | — | **Required.** | +| `path` | string | — | **Required.** File path within the repo | | `ref` | string | default branch | Branch, tag, or commit SHA | | `lines` | string | whole file | Line range for code files, e.g. `"10-25"` (1-based, inclusive) | @@ -235,7 +235,7 @@ The instance topic catalog as links, each with a project-count bubble. | Prop | Type | Default | Description | |---|---|---|---| -| `filter` | string | — | Case-insensitive regex on the topic title | +| `filter` | string | — | Case-insensitive regex on the topic title | | `order` | string | `name` | `name`, `name:asc`, or `name:desc` | | `limit` | number | all | Max topics to show | @@ -249,10 +249,10 @@ A project's or group's labels as links to the filtered issues list. `list` or `c | Prop | Type | Default | Description | |---|---|---|---| -| `project` | string \| number | — | Provide either `project` or `group` | -| `group` | string \| number | — | Provide either `project` or `group` | +| `project` | string \| number | — | Provide either `project` or `group` | +| `group` | string \| number | — | Provide either `project` or `group` | | `layout` | string | `list` | `list` or `cards` | -| `filter` | string | — | Case-insensitive regex on the label name | +| `filter` | string | — | Case-insensitive regex on the label name | | `order` | string | `name` | `name`, `name:asc`, or `name:desc` | | `limit` | number | all | Max labels to show | @@ -261,9 +261,38 @@ The `cards` layout accepts grid props: `cardColumns` (fixed column count), `card `align` (`start`/`center`). Both components render [scoped labels/topics](https://docs.gitlab.com/ee/user/project/labels.html#scoped-labels) -(`scope::value`, e.g. `Abilities::Performance`) as a two-part badge — the scope keeps its +(`scope::value`, e.g. `Abilities::Performance`) as a two-part badge — the scope keeps its color and the value gets a dark-gray background. The split is on the last `::`. +### `` + +Renders a timeline of GitLab **epics** (Premium/Ultimate, group-level) or +**milestones** (free; project or group). All data is fetched at build time. + +```mdx + + + +``` + +| Prop | Values | Default | Notes | +|---|---|---|---| +| `source` | `epics` \| `milestones` | `epics` | Fetch path | +| `group` | group path/id | — | Required for epics; one of group/project for milestones | +| `project` | project path/id | — | Milestones only | +| `layout` | `gantt` \| `timeline` | `gantt` | Horizontal bars vs. vertical spine | +| `layoutFit` | `page` \| `content` | `page` | Gantt only: `page` pins to the page width (ticks reduced to quarters/years by span, year rules bolded); `content` expands with a horizontal scrollbar | +| `scale` | `quarters` \| `months` \| `weeks` | auto | Auto from span; prop overrides | +| `state` | `opened` \| `closed` \| `all` | `opened` | | +| `labels` | comma-separated | — | Label filter | +| `from` / `to` | `YYYY-MM-DD` | derived | Explicit window | +| `limit` | number | `50` | Max items (≤ 500) | +| `order` | `start` \| `due` \| `title` | `start` | Sort key | +| `groupBy` | `none` \| `label` \| `parent` | `none` | Section headings. In the **timeline** layout, `none` groups by **year → quarter** | +| `colorBy` | `source` \| `label` \| `state` | `source` | Bar/card tint | +| `showProgress` | boolean | `true` | Epics only | +| `showLabels` | boolean | `false` | Inline label chips | + ## Generating pages from a group Instead of writing one page per project by hand, drop a single directive on a @@ -271,7 +300,7 @@ Instead of writing one page per project by hand, drop a single directive on a a GitLab group at build time. The generated pages become **children of the declaring page** in the sidebar. -Put the directive on the folder's index doc — `index.mdx`, `README.mdx`, or a +Put the directive on the folder's index doc — `index.mdx`, `README.mdx`, or a doc named after its folder (Docusaurus's [category index convention](https://docusaurus.io/docs/sidebar/autogenerated#category-index-convention)): @@ -288,9 +317,9 @@ title: Group projects | Attribute | Type | Default | Description | |---|---|---|---| -| `group` | string \| number | — | **Required.** Group path or ID | -| `sections` | string | `"readme"` | Comma-separated list of `info`, `readme`, `releases`, `issues` — becomes the components rendered on each generated project page | -| `topics` | string | — | Comma-separated topic filter; only projects with **all** listed topics are included | +| `group` | string \| number | — | **Required.** Group path or ID | +| `sections` | string | `"readme"` | Comma-separated list of `info`, `readme`, `releases`, `issues` — becomes the components rendered on each generated project page | +| `topics` | string | — | Comma-separated topic filter; only projects with **all** listed topics are included | | `includeSubgroups` | boolean | `false` | Include projects from subgroups | | `includeArchived` | boolean | `false` | Include archived projects | @@ -310,7 +339,7 @@ docs/team/ ``` Generated files are **git-ignored** (the plugin writes a scoped `.gitignore` in -the folder that ignores only what it generated — never your index page) and are +the folder that ignores only what it generated — never your index page) and are regenerated on every build, tracked via a `.gitlab-generated` manifest so stale pages are removed on regeneration. Never hand-edit or commit them. Generation runs once at plugin init, before the docs plugin scans the filesystem, so the @@ -324,15 +353,15 @@ npx docusaurus gitlab:generate ``` > During `docusaurus start`, generation runs once per process at startup. -> Editing the `{@generateGitlabPages …}` attributes (group, sections, topics, -> …) requires restarting `docusaurus start` to regenerate — it is not +> Editing the `{@generateGitlabPages …}` attributes (group, sections, topics, +> …) requires restarting `docusaurus start` to regenerate — it is not > re-evaluated on hot reload. On the declaring page itself, the directive is replaced with a -`` card grid — one card per project, linking to its generated +`` card grid — one card per project, linking to its generated child page. Because the declaring page is a folder index, it is served at a directory URL with a trailing slash (e.g. `/team/`), so each card links to the -child with a bare relative slug (`` → `/team/`). If your site sets +child with a bare relative slug (`` → `/team/`). If your site sets `trailingSlash: false`, adjust routing accordingly. ### `::include` directives inside included markdown @@ -348,7 +377,7 @@ markdown: - Relative paths resolve to a file in the **same GitLab project and ref** as the enclosing include, fetched through the same cached client. -- Remote URLs (`::include{file=https://…}`) are fetched only when their host is +- Remote URLs (`::include{file=https://…}`) are fetched only when their host is listed in the `includeAllowedHosts` plugin option (empty by default, so remote includes are off until you opt in). - Markdown targets (`.md`/`.mdx`/`.markdown`) are spliced inline and expanded @@ -363,7 +392,7 @@ markdown: ``` ```` - The directive is replaced by the file content in place — no extra fence, no + The directive is replaced by the file content in place — no extra fence, no markdown processing. - A failed include aborts the build in `strict` mode, or renders an inline warning otherwise. @@ -372,24 +401,24 @@ markdown: | Option | Type | Default | Description | |---|---|---|---| -| `host` | string | — | **Required.** GitLab base URL (e.g. `https://gitlab.com`) | -| `token` | string | — | Personal/Project Access Token. Optional for public reads. Build-time only | +| `host` | string | — | **Required.** GitLab base URL (e.g. `https://gitlab.com`) | +| `token` | string | — | Personal/Project Access Token. Optional for public reads. Build-time only | | `strict` | boolean | `true` in prod, `false` in dev | On a failed fetch: `true` aborts the build; `false` renders a fallback | | `cache` | `{ ttl: number }` \| `false` | `{ ttl: 3600 }` | On-disk cache TTL (seconds), or `false` to disable | | `assetDir` | string | `static/gitlab-assets` | Where README images/badges are downloaded | | `assetBaseUrl` | string | `/gitlab-assets` | URL path the downloaded assets are served from | | `fixAutolinks` | boolean | `true` | Rewrite CommonMark autolinks in included markdown to MDX-safe links (include placeholders only) | -| `fixVoidTags` | boolean | `true` | Self-close HTML void elements (`
` → `
`) in included markdown (include placeholders only) | -| `fixInlineStyles` | boolean | `true` | Convert HTML string `style="…"` attributes to JSX style objects in included markdown | +| `fixVoidTags` | boolean | `true` | Self-close HTML void elements (`
` → `
`) in included markdown (include placeholders only) | +| `fixInlineStyles` | boolean | `true` | Convert HTML string `style="…"` attributes to JSX style objects in included markdown | | `convertAlerts` | boolean | `true` | Translate GitLab alert blockquotes (`> [!note]`) to Docusaurus admonitions (`:::note`) in included markdown | | `stripToc` | boolean | `false` | Remove a redundant "Table of Contents" section (and `[[_TOC_]]` marker) from included markdown | | `outProcessors` | `Array<(md: string) => string \| Promise>` | `[]` | Extra post-processors for included markdown, run after the built-in fixes | -| `includeAllowedHosts` | `string[]` | `[]` | Hostnames allowed as remote `::include{file=https://…}` targets | +| `includeAllowedHosts` | `string[]` | `[]` | Hostnames allowed as remote `::include{file=https://…}` targets | | `debug` | boolean | `false` | Emit build-time debug traces for the include pipeline (resolved placeholders and `::include` directives) via `@docusaurus/logger` | -| `markdownRenderChain` | `PluggableList` | _default chain_ | Override the markdown→sanitized-HTML plugin chain (see below) | +| `markdownRenderChain` | `PluggableList` | _default chain_ | Override the markdown→sanitized-HTML plugin chain (see below) | The token is read at build time only. Provide it via an environment variable -(`GITLAB_TOKEN`) — never commit it. +(`GITLAB_TOKEN`) — never commit it. ### Customizing the markdown render chain @@ -398,8 +427,8 @@ markdown files) is rendered at build time by a `unified` plugin chain. By defaul it is: ```text -remarkParse → remarkGemoji → remarkGfm → remarkRehype({ allowDangerousHtml }) - → rehypeRaw → rehypeSanitize +remarkParse → remarkGemoji → remarkGfm → remarkRehype({ allowDangerousHtml }) + → rehypeRaw → rehypeSanitize ``` Override or extend it with the `markdownRenderChain` option. Spread the exported @@ -409,7 +438,7 @@ default to add plugins: import { defaultMarkdownRenderChain } from "@ebuildy/docusaurus-plugin-gitlab"; import rehypeHighlight from "rehype-highlight"; -// docusaurus.config.ts — plugin options +// docusaurus.config.ts — plugin options { host: "https://gitlab.com", markdownRenderChain: [...defaultMarkdownRenderChain, rehypeHighlight], @@ -438,12 +467,12 @@ cached on disk so local `docusaurus start` hot-reloads don't hammer the API. Because everything happens at build time, your published HTML is self-contained: no tokens shipped, no client-side API calls, no CORS. -For a deeper tour of the internals — the three build-time pipelines, the module -map, and data-flow diagrams — see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md). +For a deeper tour of the internals — the three build-time pipelines, the module +map, and data-flow diagrams — see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md). ## Styling -The components ship **without any bundled CSS** — they render plain, stable class +The components ship **without any bundled CSS** — they render plain, stable class names so you stay in full control of the look. The package includes an optional, light/dark-aware theme (`theme.css`) you can apply as-is or use as a starting point. It's built on [Infima](https://infima.dev/) variables, so it tracks your diff --git a/docs/superpowers/plans/2026-07-10-gitlab-roadmap-component.md b/docs/superpowers/plans/2026-07-10-gitlab-roadmap-component.md new file mode 100644 index 0000000..b57998d --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-gitlab-roadmap-component.md @@ -0,0 +1,1495 @@ +# GitlabRoadmap Component Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `` pipeline-3 component that renders GitLab group **epics** or **milestones** on a build-time timeline, in either a horizontal Gantt or a vertical timeline layout. + +**Architecture:** Follows the existing pipeline-3 loop (`remark/index.ts` → `registry.ts` → fetcher → `data` prop → pure component). All timeline geometry (scale selection, positioning, ticks, grouping) lives in a new pure module `src/gitlab/roadmap.ts` computed at build time, so the React components are dumb renderers. Two thin gitbeaker client methods add the epics/milestones fetch paths. + +**Tech Stack:** TypeScript (ESM, `.js` import extensions), `@gitbeaker/rest`, React (SSR), Vitest + React Testing Library, CSS modules. + +**Design spec:** [`../specs/2026-07-10-gitlab-roadmap-component-design.md`](../specs/2026-07-10-gitlab-roadmap-component-design.md) + +--- + +## Conventions for every task + +- **ESM imports** inside `src/` carry explicit `.js` extensions (e.g. `import { buildRoadmap } from "./roadmap.js"`). +- **TDD:** write the failing test first, watch it fail, implement, watch it pass, commit. +- **Commits are GPG-signed automatically** (`commit.gpgsign=true`). If a commit is unsigned, re-run with `git commit -S`. Verify with `git log -1 --format="%G?"` → expect `G`. +- Run one test file with `npx vitest run `. Run the whole suite with `npm run test`. Typecheck with `npm run typecheck`. +- Do **not** run the slow e2e (`test/e2e/build.test.ts`) per task; it runs once at the end (Task 14). + +--- + +## File structure + +| File | Responsibility | Task | +|---|---|---| +| `src/gitlab/types.ts` (modify) | Add roadmap domain types | 1 | +| `src/gitlab/roadmap.ts` (create) | Pure geometry: scale, window, positioning, ticks, grouping, `buildRoadmap` | 2 | +| `src/gitlab/roadmap.test.ts` (create) | Unit tests for the geometry module | 2 | +| `src/gitlab/client.ts` (modify) | `getGroupEpics`, `getGroupMilestones`, `getProjectMilestones` | 3 | +| `src/gitlab/client.test.ts` (modify) | Tests for the three new client methods | 3 | +| `src/gitlab/fetchers.ts` (modify) | `fetchRoadmap` — normalize + validate + `buildRoadmap` + memo | 4, 5 | +| `src/gitlab/fetchers.test.ts` (modify) | Fetcher tests (epics, milestones, degrade, cache key) | 4, 5 | +| `src/remark/registry.ts` (modify) | `GitlabRoadmap: fetchRoadmap` | 6 | +| `src/components/GitlabRoadmap.tsx` (create) | Layout dispatcher | 7 | +| `src/components/RoadmapGantt.tsx` (create) | Horizontal bars | 8 | +| `src/components/RoadmapTimeline.tsx` (create) | Vertical spine | 9 | +| `src/components/roadmapColor.ts` (create) | `colorBy` tint resolver (shared by both layouts) | 7 | +| `src/components/styles.module.css` (modify) | Roadmap styles | 8, 9 | +| `src/components/index.ts` + `src/index.ts` (modify) | Exports | 10 | +| `README.md` + `examples/site/docs/components/roadmap.md` | Docs + live examples | 11 | + +--- + +## Task 1: Domain types + +**Files:** +- Modify: `src/gitlab/types.ts` (append) + +- [ ] **Step 1: Add the roadmap types** + +Append to `src/gitlab/types.ts`: + +```ts +/** A GitLab label reduced to what the roadmap renders. */ +export interface LabelRef { + name: string; + color: string; + textColor: string; +} + +export type RoadmapSource = "epics" | "milestones"; +export type RoadmapState = "opened" | "closed"; +export type RoadmapScale = "quarters" | "months" | "weeks"; + +/** One epic/milestone normalized from the GitLab API. */ +export interface RoadmapItemData { + id: number; + iid: number; + title: string; + state: RoadmapState; + /** ISO `YYYY-MM-DD`, or null when the source has no such date. */ + startDate: string | null; + dueDate: string | null; + webUrl: string; + /** Epic color (e.g. `#1f75cb`); absent for milestones. */ + color?: string; + /** Completion 0..100; epics only, null when not derivable. */ + progress?: number | null; + parentId?: number | null; + parentTitle?: string | null; + labels: LabelRef[]; +} + +/** An item after geometry: same fields plus its bar placement. */ +export interface RoadmapPositionedItem extends RoadmapItemData { + /** Bar left edge as a percentage of the timeline window (0..100). */ + offsetPct: number; + /** Bar width as a percentage of the window (>0). */ + widthPct: number; +} + +export interface ScaleTick { + label: string; + /** Tick position as a percentage of the window (0..100). */ + offsetPct: number; +} + +export interface RoadmapGroup { + key: string; + /** Section heading; null for the single ungrouped bucket. */ + title: string | null; + items: RoadmapPositionedItem[]; +} + +/** The fully positioned model the component renders — no math in React. */ +export interface RoadmapData { + source: RoadmapSource; + scale: RoadmapScale; + rangeStart: string; // ISO YYYY-MM-DD + rangeEnd: string; + ticks: ScaleTick[]; + groups: RoadmapGroup[]; +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `npm run typecheck` +Expected: PASS (types are additive, nothing consumes them yet). + +- [ ] **Step 3: Commit** + +```bash +git add src/gitlab/types.ts +git commit -m "feat: add roadmap domain types" +``` + +--- + +## Task 2: Geometry module (`roadmap.ts`) + +This is the bulk of the logic and is pure (no network). Build it test-first as one cohesive module. + +**Files:** +- Create: `src/gitlab/roadmap.ts` +- Test: `src/gitlab/roadmap.test.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `src/gitlab/roadmap.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + selectScale, + positionItem, + buildTicks, + groupItems, + buildRoadmap, +} from "./roadmap"; +import type { RoadmapItemData, RoadmapPositionedItem } from "./types"; + +function item(partial: Partial): RoadmapItemData { + return { + id: 1, iid: 1, title: "X", state: "opened", + startDate: null, dueDate: null, webUrl: "https://x", labels: [], + ...partial, + }; +} + +describe("selectScale", () => { + it("picks weeks for a short span (<= 92 days)", () => { + expect(selectScale("2026-01-01", "2026-02-01")).toBe("weeks"); + }); + it("picks months for a mid span (<= 366 days)", () => { + expect(selectScale("2026-01-01", "2026-07-01")).toBe("months"); + }); + it("picks quarters for a long span (> 366 days)", () => { + expect(selectScale("2026-01-01", "2028-06-01")).toBe("quarters"); + }); +}); + +describe("positionItem", () => { + it("positions a bar as a percentage of the window", () => { + const p = positionItem( + item({ startDate: "2026-01-01", dueDate: "2026-01-06" }), + "2026-01-01", + "2026-01-11", + ); + expect(p.offsetPct).toBe(0); + expect(p.widthPct).toBe(50); + }); + it("clamps a bar that starts before the window and keeps a minimum width", () => { + const p = positionItem( + item({ dueDate: "2026-01-02" }), // start falls back to due → zero-length + "2026-01-01", + "2026-01-11", + ); + expect(p.offsetPct).toBeGreaterThanOrEqual(0); + expect(p.widthPct).toBeGreaterThan(0); + expect(p.offsetPct + p.widthPct).toBeLessThanOrEqual(100); + }); +}); + +describe("buildTicks", () => { + it("emits one tick per month across the window", () => { + const ticks = buildTicks("2026-01-01", "2026-04-01", "months"); + expect(ticks.map((t) => t.label)).toEqual(["Jan", "Feb", "Mar"]); + expect(ticks[0].offsetPct).toBe(0); + }); +}); + +describe("groupItems", () => { + const positioned = (name: string, parent: string | null): RoadmapPositionedItem => ({ + ...item({ title: name, parentTitle: parent, labels: [] }), + offsetPct: 0, widthPct: 10, + }); + it("returns a single unnamed group when groupBy is none", () => { + const groups = groupItems([positioned("a", null)], "none"); + expect(groups).toHaveLength(1); + expect(groups[0].title).toBeNull(); + }); + it("splits into one section per parent title when groupBy is parent", () => { + const groups = groupItems( + [positioned("a", "Platform"), positioned("b", "Growth"), positioned("c", null)], + "parent", + ); + expect(groups.map((g) => g.title).sort()).toEqual(["(no parent)", "Growth", "Platform"]); + }); +}); + +describe("buildRoadmap", () => { + it("drops undated items, sorts, positions, and wraps in RoadmapData", () => { + const data = buildRoadmap( + [ + item({ id: 1, title: "late", startDate: "2026-06-01", dueDate: "2026-08-01" }), + item({ id: 2, title: "early", startDate: "2026-01-01", dueDate: "2026-03-01" }), + item({ id: 3, title: "undated" }), + ], + { source: "epics", order: "start", groupBy: "none" }, + ); + expect(data.source).toBe("epics"); + expect(data.groups[0].items.map((i) => i.title)).toEqual(["early", "late"]); + expect(data.groups[0].items).toHaveLength(2); // undated dropped + expect(data.rangeStart <= "2026-01-01").toBe(true); + expect(data.ticks.length).toBeGreaterThan(0); + }); + + it("honors an explicit scale override and window", () => { + const data = buildRoadmap( + [item({ id: 1, title: "a", startDate: "2026-02-01", dueDate: "2026-03-01" })], + { source: "epics", order: "start", groupBy: "none", scale: "weeks", from: "2026-01-01", to: "2026-04-01" }, + ); + expect(data.scale).toBe("weeks"); + expect(data.rangeStart).toBe("2026-01-01"); + expect(data.rangeEnd).toBe("2026-04-01"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/gitlab/roadmap.test.ts` +Expected: FAIL — `Failed to resolve import "./roadmap"`. + +- [ ] **Step 3: Implement the module** + +Create `src/gitlab/roadmap.ts`: + +```ts +import type { + RoadmapData, + RoadmapGroup, + RoadmapItemData, + RoadmapPositionedItem, + RoadmapScale, + RoadmapSource, + ScaleTick, +} from "./types.js"; + +const MS_PER_DAY = 86_400_000; +const MIN_WIDTH_PCT = 1; // keep zero-length/point items visible +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +function parseDay(iso: string): number { + const [y, m, d] = iso.split("-").map(Number); + return Date.UTC(y, m - 1, d); +} +function toISODate(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} +function spanDays(startISO: string, endISO: string): number { + return (parseDay(endISO) - parseDay(startISO)) / MS_PER_DAY; +} + +/** Round a timestamp down to the start of its scale unit (Monday / 1st / quarter). */ +function snapDown(ms: number, scale: RoadmapScale): number { + const d = new Date(ms); + const y = d.getUTCFullYear(); + const m = d.getUTCMonth(); + if (scale === "weeks") { + const day = d.getUTCDay(); // 0=Sun..6=Sat + const backToMonday = day === 0 ? 6 : day - 1; + return ms - backToMonday * MS_PER_DAY; + } + if (scale === "months") return Date.UTC(y, m, 1); + return Date.UTC(y, Math.floor(m / 3) * 3, 1); // quarters +} + +/** Advance a boundary timestamp by exactly one scale unit. */ +function advance(ms: number, scale: RoadmapScale): number { + const d = new Date(ms); + const y = d.getUTCFullYear(); + const m = d.getUTCMonth(); + if (scale === "weeks") return ms + 7 * MS_PER_DAY; + if (scale === "months") return Date.UTC(y, m + 1, 1); + return Date.UTC(y, m + 3, 1); // quarters +} + +/** Round a timestamp up to the next scale boundary (unchanged if already on one). */ +function snapUp(ms: number, scale: RoadmapScale): number { + const down = snapDown(ms, scale); + return down === ms ? ms : advance(down, scale); +} + +export function selectScale(startISO: string, endISO: string): RoadmapScale { + const days = spanDays(startISO, endISO); + if (days <= 92) return "weeks"; + if (days <= 366) return "months"; + return "quarters"; +} + +/** Raw min-start / max-due across items (falling back to the other date). */ +function rawBounds(items: RoadmapItemData[]): { startISO: string; endISO: string } { + const starts = items.map((i) => i.startDate ?? i.dueDate!).filter(Boolean); + const ends = items.map((i) => i.dueDate ?? i.startDate!).filter(Boolean); + const startMs = Math.min(...starts.map(parseDay)); + const endMs = Math.max(...ends.map(parseDay)); + return { startISO: toISODate(startMs), endISO: toISODate(endMs) }; +} + +function deriveWindow( + items: RoadmapItemData[], + scale: RoadmapScale, + from: string | undefined, + to: string | undefined, +): { rangeStart: string; rangeEnd: string } { + const raw = rawBounds(items); + const startMs = from ? parseDay(from) : snapDown(parseDay(raw.startISO), scale); + let endMs = to ? parseDay(to) : snapUp(parseDay(raw.endISO), scale); + if (endMs <= startMs) endMs = advance(startMs, scale); // guarantee a positive window + return { rangeStart: toISODate(startMs), rangeEnd: toISODate(endMs) }; +} + +export function positionItem( + it: RoadmapItemData, + rangeStart: string, + rangeEnd: string, +): { offsetPct: number; widthPct: number } { + const s = parseDay(it.startDate ?? it.dueDate!); + const e = parseDay(it.dueDate ?? it.startDate!); + const total = parseDay(rangeEnd) - parseDay(rangeStart); + const offsetPct = Math.min(Math.max(((s - parseDay(rangeStart)) / total) * 100, 0), 100); + const rawWidth = ((e - s) / total) * 100; + const widthPct = Math.max(Math.min(rawWidth, 100 - offsetPct), MIN_WIDTH_PCT); + return { offsetPct, widthPct }; +} + +function tickLabel(ms: number, scale: RoadmapScale): string { + const d = new Date(ms); + if (scale === "quarters") return `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`; + if (scale === "weeks") return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; + return MONTHS[d.getUTCMonth()]; +} + +export function buildTicks(rangeStart: string, rangeEnd: string, scale: RoadmapScale): ScaleTick[] { + const startMs = parseDay(rangeStart); + const endMs = parseDay(rangeEnd); + const total = endMs - startMs; + const ticks: ScaleTick[] = []; + for (let cur = startMs; cur < endMs; cur = advance(cur, scale)) { + ticks.push({ label: tickLabel(cur, scale), offsetPct: ((cur - startMs) / total) * 100 }); + } + return ticks; +} + +export function groupItems( + items: RoadmapPositionedItem[], + groupBy: "none" | "label" | "parent", +): RoadmapGroup[] { + if (groupBy === "none") return [{ key: "all", title: null, items }]; + const map = new Map(); + for (const it of items) { + const keys = + groupBy === "label" + ? it.labels.length + ? it.labels.map((l) => l.name) + : ["(no label)"] + : [it.parentTitle ?? "(no parent)"]; + for (const k of keys) { + const g = map.get(k) ?? { key: k, title: k, items: [] }; + g.items.push(it); + map.set(k, g); + } + } + return [...map.values()]; +} + +function sortItems(items: RoadmapItemData[], order: "start" | "due" | "title"): RoadmapItemData[] { + const key = (i: RoadmapItemData): string => + order === "title" ? i.title : order === "due" ? i.dueDate ?? i.startDate ?? "" : i.startDate ?? i.dueDate ?? ""; + return [...items].sort((a, b) => key(a).localeCompare(key(b))); +} + +export interface BuildRoadmapOptions { + source: RoadmapSource; + order: "start" | "due" | "title"; + groupBy: "none" | "label" | "parent"; + scale?: RoadmapScale; + from?: string; + to?: string; +} + +export function buildRoadmap(items: RoadmapItemData[], opts: BuildRoadmapOptions): RoadmapData { + const dated = items.filter((i) => i.startDate || i.dueDate); + const sorted = sortItems(dated, opts.order); + const raw = rawBounds(sorted); + const scale = opts.scale ?? selectScale(raw.startISO, raw.endISO); + const { rangeStart, rangeEnd } = deriveWindow(sorted, scale, opts.from, opts.to); + const positioned: RoadmapPositionedItem[] = sorted.map((i) => ({ + ...i, + ...positionItem(i, rangeStart, rangeEnd), + })); + return { + source: opts.source, + scale, + rangeStart, + rangeEnd, + ticks: buildTicks(rangeStart, rangeEnd, scale), + groups: groupItems(positioned, opts.groupBy), + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/gitlab/roadmap.test.ts` +Expected: PASS (all cases green). + +- [ ] **Step 5: Typecheck and commit** + +```bash +npm run typecheck +git add src/gitlab/roadmap.ts src/gitlab/roadmap.test.ts +git commit -m "feat: add roadmap timeline geometry module" +``` + +--- + +## Task 3: Client methods + +**Files:** +- Modify: `src/gitlab/client.ts` +- Test: `src/gitlab/client.test.ts` + +- [ ] **Step 1: Add mocks + a failing test** + +In `src/gitlab/client.test.ts`, add these mock fns near the other `const ...Mock = vi.fn();` declarations (top of file): + +```ts +const epicsAllMock = vi.fn(); +const groupMilestonesAllMock = vi.fn(); +const projectMilestonesAllMock = vi.fn(); +``` + +Add them to the object returned by the `Gitlab` mock (inside `vi.mock("@gitbeaker/rest", ...)`): + +```ts + Epics: { all: epicsAllMock }, + GroupMilestones: { all: groupMilestonesAllMock }, + ProjectMilestones: { all: projectMilestonesAllMock }, +``` + +Add resets in `beforeEach` alongside the others: + +```ts + epicsAllMock.mockReset(); + groupMilestonesAllMock.mockReset(); + projectMilestonesAllMock.mockReset(); +``` + +Add this test block at the end of the file (before the final closing brace of the top-level `describe`, or as a new `describe`): + +```ts +describe("roadmap sources", () => { + it("getGroupEpics passes filters and bounded pagination", async () => { + epicsAllMock.mockResolvedValue([{ id: 1 }]); + const client = new GitLabClient({ host: "https://gitlab.com", token: "t" }); + const res = await client.getGroupEpics("g", { state: "opened", labels: "a", orderBy: "start_date", sort: "asc" }); + expect(res).toEqual([{ id: 1 }]); + expect(epicsAllMock).toHaveBeenCalledWith("g", { + state: "opened", labels: "a", orderBy: "start_date", sort: "asc", perPage: 100, maxPages: 5, + }); + }); + + it("getGroupMilestones and getProjectMilestones fetch with bounded pagination", async () => { + groupMilestonesAllMock.mockResolvedValue([{ id: 2 }]); + projectMilestonesAllMock.mockResolvedValue([{ id: 3 }]); + const client = new GitLabClient({ host: "https://gitlab.com", token: "t" }); + expect(await client.getGroupMilestones("g")).toEqual([{ id: 2 }]); + expect(await client.getProjectMilestones("p/x")).toEqual([{ id: 3 }]); + expect(groupMilestonesAllMock).toHaveBeenCalledWith("g", { perPage: 100, maxPages: 5 }); + expect(projectMilestonesAllMock).toHaveBeenCalledWith("p/x", { perPage: 100, maxPages: 5 }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/gitlab/client.test.ts` +Expected: FAIL — `client.getGroupEpics is not a function`. + +- [ ] **Step 3: Implement the client methods** + +In `src/gitlab/client.ts`, add an epics-query interface next to `IssuesQuery`: + +```ts +export interface EpicsQuery { + state?: string; + labels?: string; + orderBy?: string; + sort?: string; +} +``` + +Add these methods to the `GitLabClient` class (e.g. after `getGroupProjects`): + +```ts + async getGroupEpics(group: ProjectRef, opts: EpicsQuery = {}): Promise { + return this.api.Epics.all(group, { + ...(opts.state ? { state: opts.state } : {}), + ...(opts.labels ? { labels: opts.labels } : {}), + ...(opts.orderBy ? { orderBy: opts.orderBy } : {}), + ...(opts.sort ? { sort: opts.sort } : {}), + perPage: DEFAULT_PER_PAGE, + maxPages: DEFAULT_MAX_PAGES, + }); + } + + async getGroupMilestones(group: ProjectRef): Promise { + return this.api.GroupMilestones.all(group, { perPage: DEFAULT_PER_PAGE, maxPages: DEFAULT_MAX_PAGES }); + } + + async getProjectMilestones(project: ProjectRef): Promise { + return this.api.ProjectMilestones.all(project, { perPage: DEFAULT_PER_PAGE, maxPages: DEFAULT_MAX_PAGES }); + } +``` + +> Note: the first test expects `getGroupEpics` to forward all four filter keys. Because the spread only includes keys when truthy, the test passes all four so every key is present. This keeps real calls from sending empty filters. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/gitlab/client.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck and commit** + +```bash +npm run typecheck +git add src/gitlab/client.ts src/gitlab/client.test.ts +git commit -m "feat: add epics and milestones client methods" +``` + +--- + +## Task 4: Fetcher — epics path + +**Files:** +- Modify: `src/gitlab/fetchers.ts` +- Test: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Write the failing test** + +In `src/gitlab/fetchers.test.ts`, add `fetchRoadmap` to the import from `./fetchers`: + +```ts +import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels, fetchGroupProjects, fetchRoadmap } from "./fetchers"; +``` + +Add this `describe` block at the end of the file: + +```ts +describe("fetchRoadmap (epics)", () => { + const epics = [ + { id: 10, iid: 1, title: "Auth", state: "opened", start_date: "2026-01-01", due_date: "2026-03-01", + web_url: "https://gitlab.com/groups/g/-/epics/1", color: "#1f75cb", parent_id: null, labels: ["backend"] }, + { id: 11, iid: 2, title: "Billing", state: "closed", start_date: "2026-02-01", due_date: "2026-05-01", + web_url: "https://gitlab.com/groups/g/-/epics/2", color: "#6666c4", parent_id: 10, labels: [] }, + ]; + + it("normalizes epics into positioned RoadmapData", async () => { + const client = { + getGroupEpics: vi.fn(async () => epics), + getGroupLabels: vi.fn(async () => [{ name: "backend", color: "#dbeafe", text_color: "#1e40af" }]), + }; + const c = ctx(client); + const data = await fetchRoadmap(c, { source: "epics", group: "g" }); + expect(data.source).toBe("epics"); + const items = data.groups.flatMap((g) => g.items); + expect(items.map((i) => i.title).sort()).toEqual(["Auth", "Billing"]); + const auth = items.find((i) => i.title === "Auth")!; + expect(auth.startDate).toBe("2026-01-01"); + expect(auth.color).toBe("#1f75cb"); + expect(auth.labels).toEqual([{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }]); + expect(auth.widthPct).toBeGreaterThan(0); + expect(client.getGroupEpics).toHaveBeenCalled(); + }); + + it("throws when source is epics but group is missing", async () => { + const c = ctx({}); + await expect(fetchRoadmap(c, { source: "epics" })).rejects.toThrow(/group/); + }); + + it("degrades: rethrows in strict mode", async () => { + const client = { getGroupEpics: vi.fn(async () => { throw new Error("403 tier"); }) }; + const c = ctx(client); + c.options.strict = true; + await expect(fetchRoadmap(c, { source: "epics", group: "g" })).rejects.toThrow("403 tier"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: FAIL — `fetchRoadmap` is not exported. + +- [ ] **Step 3: Implement the epics path** + +In `src/gitlab/fetchers.ts`, add imports at the top (extend the existing type import and the roadmap import): + +```ts +import { buildRoadmap, type BuildRoadmapOptions } from "./roadmap.js"; +``` + +Add to the existing `import type { ... } from "./types"` block: `LabelRef`, `RoadmapData`, `RoadmapItemData`, `RoadmapScale`. + +Add these helpers and the fetcher (near the other fetchers): + +```ts +function readRoadmapSource(value: unknown): "epics" | "milestones" { + if (value === undefined || value === "epics") return "epics"; + if (value === "milestones") return "milestones"; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "source" must be "epics" or "milestones"; got ${JSON.stringify(value)}.`, + ); +} + +function readRoadmapScale(value: unknown): RoadmapScale | undefined { + if (value === undefined) return undefined; + if (value === "quarters" || value === "months" || value === "weeks") return value; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "scale" must be "quarters", "months", or "weeks"; got ${JSON.stringify(value)}.`, + ); +} + +function readRoadmapOrder(value: unknown): "start" | "due" | "title" { + if (value === undefined || value === "start") return "start"; + if (value === "due" || value === "title") return value; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "order" must be "start", "due", or "title"; got ${JSON.stringify(value)}.`, + ); +} + +function readGroupBy(value: unknown): "none" | "label" | "parent" { + if (value === undefined || value === "none") return "none"; + if (value === "label" || value === "parent") return value; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "groupBy" must be "none", "label", or "parent"; got ${JSON.stringify(value)}.`, + ); +} + +/** Build a name→LabelRef lookup from group/project labels for color resolution. */ +async function labelIndex( + ctx: GitLabContext, + scope: { group?: string | number; project?: string | number }, +): Promise> { + const raw = + scope.project !== undefined + ? await ctx.client.getProjectLabels(scope.project).catch(() => []) + : scope.group !== undefined + ? await ctx.client.getGroupLabels(scope.group).catch(() => []) + : []; + const idx = new Map(); + for (const l of raw as any[]) { + idx.set(l.name, { name: l.name, color: l.color, textColor: l.text_color }); + } + return idx; +} + +function resolveLabels(names: string[], idx: Map): LabelRef[] { + return names.map( + (name) => idx.get(name) ?? { name, color: "#e5e7eb", textColor: "#1f2937" }, + ); +} + +export async function fetchRoadmap(ctx: GitLabContext, attrs: Attrs): Promise { + const source = readRoadmapSource(attrs.source); + const group = attrs.group as string | number | undefined; + const project = attrs.project as string | number | undefined; + const scale = readRoadmapScale(attrs.scale); + const order = readRoadmapOrder(attrs.order); + const groupBy = readGroupBy(attrs.groupBy); + const state = (attrs.state as string) ?? "opened"; + const labels = attrs.labels as string | undefined; + const from = attrs.from as string | undefined; + const to = attrs.to as string | undefined; + const limit = typeof attrs.limit === "number" ? attrs.limit : 50; + + if (source === "epics" && group === undefined) { + throw new Error(`@ebuildy/docusaurus-plugin-gitlab: requires a "group".`); + } + if (source === "milestones" && (group === undefined) === (project === undefined)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: requires exactly one of "project" or "group".`, + ); + } + + const scopeKey = project !== undefined ? `p:${String(project)}` : `g:${String(group)}`; + const key = `roadmap:${source}:${scopeKey}:${state}:${labels ?? ""}:${from ?? ""}:${to ?? ""}:${scale ?? "auto"}:${order}:${groupBy}:${limit}`; + + // On failure this simply throws; the remark layer (src/remark/index.ts) turns a + // thrown fetch into an `error` prop (Fallback) when `strict` is false, and + // aborts the build when true — identical to every other fetcher. + return memo(ctx, key, async () => { + const buildOpts: BuildRoadmapOptions = { source, order, groupBy }; + if (scale) buildOpts.scale = scale; + if (from) buildOpts.from = from; + if (to) buildOpts.to = to; + + const items = + source === "epics" + ? await fetchEpicItems(ctx, group!, { state, labels, order }, limit) + : await fetchMilestoneItems(ctx, { group, project }, state, limit); + + return buildRoadmap(items, buildOpts); + }); +} + +async function fetchEpicItems( + ctx: GitLabContext, + group: string | number, + q: { state: string; labels?: string; order: "start" | "due" | "title" }, + limit: number, +): Promise { + const orderBy = q.order === "title" ? "title" : q.order === "due" ? "due_date" : "start_date"; + const raw = await ctx.client.getGroupEpics(group, { + state: q.state, + ...(q.labels ? { labels: q.labels } : {}), + orderBy, + sort: "asc", + }); + const idx = await labelIndex(ctx, { group }); + const byId = new Map(raw.map((e: any) => [e.id, e])); + return raw.slice(0, limit).map((e: any) => ({ + id: e.id, + iid: e.iid, + title: e.title, + state: e.state === "closed" ? "closed" : "opened", + startDate: e.start_date ?? null, + dueDate: e.due_date ?? null, + webUrl: e.web_url, + color: e.color, + progress: computeProgress(e.descendant_counts), + parentId: e.parent_id ?? null, + parentTitle: e.parent_id != null ? byId.get(e.parent_id)?.title ?? null : null, + labels: resolveLabels(Array.isArray(e.labels) ? e.labels : [], idx), + } satisfies RoadmapItemData)); +} + +/** GitLab epic list payloads may include descendant issue counts; derive % from them. */ +function computeProgress(counts: any): number | null { + if (!counts) return null; + const opened = Number(counts.opened_issues ?? 0); + const closed = Number(counts.closed_issues ?? 0); + const total = opened + closed; + return total > 0 ? Math.round((closed / total) * 100) : null; +} +``` + +> `fetchMilestoneItems` is defined in Task 5. This task's tests exercise only the epics path; the milestones import will be added next. + +- [ ] **Step 4: Temporarily stub the milestone path so the file compiles** + +Add a placeholder just below `fetchEpicItems` so `fetchRoadmap` type-checks until Task 5 replaces it: + +```ts +async function fetchMilestoneItems( + _ctx: GitLabContext, + _scope: { group?: string | number; project?: string | number }, + _state: string, + _limit: number, +): Promise { + throw new Error("milestones source not yet implemented"); +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: PASS (epics tests green; milestone path untested here). + +- [ ] **Step 6: Typecheck and commit** + +```bash +npm run typecheck +git add src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: add fetchRoadmap epics path" +``` + +--- + +## Task 5: Fetcher — milestones path + +**Files:** +- Modify: `src/gitlab/fetchers.ts` +- Test: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `src/gitlab/fetchers.test.ts`: + +```ts +describe("fetchRoadmap (milestones)", () => { + const milestones = [ + { id: 1, iid: 5, title: "v1.0", state: "active", start_date: "2026-01-01", due_date: "2026-02-01", + web_url: "https://gitlab.com/g/r/-/milestones/5" }, + { id: 2, iid: 6, title: "v1.1", state: "closed", start_date: "2026-02-01", due_date: "2026-03-01", + web_url: "https://gitlab.com/g/r/-/milestones/6" }, + ]; + + it("normalizes project milestones and maps active→opened", async () => { + const client = { + getProjectMilestones: vi.fn(async () => milestones), + getProjectLabels: vi.fn(async () => []), + }; + const c = ctx(client); + const data = await fetchRoadmap(c, { source: "milestones", project: "g/r" }); + expect(data.source).toBe("milestones"); + const items = data.groups.flatMap((g) => g.items); + expect(items.find((i) => i.title === "v1.0")!.state).toBe("opened"); + expect(items.find((i) => i.title === "v1.1")!.state).toBe("closed"); + expect(items.every((i) => i.color === undefined)).toBe(true); + expect(client.getProjectMilestones).toHaveBeenCalledWith("g/r"); + }); + + it("fetches group milestones when group is given", async () => { + const client = { getGroupMilestones: vi.fn(async () => milestones), getGroupLabels: vi.fn(async () => []) }; + const c = ctx(client); + const data = await fetchRoadmap(c, { source: "milestones", group: "g" }); + expect(data.groups.flatMap((g) => g.items)).toHaveLength(2); + expect(client.getGroupMilestones).toHaveBeenCalledWith("g"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: FAIL — throws `milestones source not yet implemented`. + +- [ ] **Step 3: Replace the placeholder with the real implementation** + +Replace the `fetchMilestoneItems` stub added in Task 4 with: + +```ts +async function fetchMilestoneItems( + ctx: GitLabContext, + scope: { group?: string | number; project?: string | number }, + state: string, + limit: number, +): Promise { + // Milestone API state vocabulary is active/closed; map our opened→active. + const apiState = state === "opened" ? "active" : state; // "closed" and "all" pass through + const raw = + scope.project !== undefined + ? await ctx.client.getProjectMilestones(scope.project) + : await ctx.client.getGroupMilestones(scope.group!); + const filtered = + apiState === "all" ? raw : raw.filter((m: any) => m.state === apiState); + return filtered.slice(0, limit).map((m: any) => ({ + id: m.id, + iid: m.iid, + title: m.title, + state: m.state === "closed" ? "closed" : "opened", + startDate: m.start_date ?? null, + dueDate: m.due_date ?? null, + webUrl: m.web_url, + progress: null, + parentId: null, + parentTitle: null, + labels: [], + } satisfies RoadmapItemData)); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: PASS (epics + milestones green). + +- [ ] **Step 5: Typecheck and commit** + +```bash +npm run typecheck +git add src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: add fetchRoadmap milestones path" +``` + +--- + +## Task 6: Register the component + +**Files:** +- Modify: `src/remark/registry.ts` + +- [ ] **Step 1: Add the import and registry entry** + +In `src/remark/registry.ts`, add `fetchRoadmap` to the import list from `../gitlab/fetchers.js` and add to `COMPONENT_REGISTRY`: + +```ts + GitlabRoadmap: fetchRoadmap, +``` + +- [ ] **Step 2: Typecheck** + +Run: `npm run typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/remark/registry.ts +git commit -m "feat: register GitlabRoadmap in the component registry" +``` + +--- + +## Task 7: `GitlabRoadmap` dispatcher + color resolver + +**Files:** +- Create: `src/components/roadmapColor.ts` +- Create: `src/components/GitlabRoadmap.tsx` +- Test: `src/components/GitlabRoadmap.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/components/GitlabRoadmap.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabRoadmap } from "./GitlabRoadmap"; +import type { RoadmapData } from "./types"; + +const data: RoadmapData = { + source: "epics", + scale: "months", + rangeStart: "2026-01-01", + rangeEnd: "2026-04-01", + ticks: [{ label: "Jan", offsetPct: 0 }, { label: "Feb", offsetPct: 33 }, { label: "Mar", offsetPct: 66 }], + groups: [ + { + key: "all", title: null, + items: [{ + id: 1, iid: 1, title: "Auth", state: "opened", startDate: "2026-01-01", dueDate: "2026-02-01", + webUrl: "https://x/epics/1", color: "#1f75cb", progress: 60, parentId: null, parentTitle: null, + labels: [{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }], + offsetPct: 0, widthPct: 33, + }], + }, + ], +}; + +describe("GitlabRoadmap", () => { + it("renders the gantt layout by default", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-roadmap-gantt")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Auth" })).toHaveAttribute("href", "https://x/epics/1"); + }); + + it("renders the timeline layout when layout='timeline'", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-roadmap-timeline")).toBeInTheDocument(); + }); + + it("renders the fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("boom"); + }); + + it("renders nothing when there is no data", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/GitlabRoadmap.test.tsx` +Expected: FAIL — cannot resolve `./GitlabRoadmap`. + +- [ ] **Step 3: Implement the color resolver** + +Create `src/components/roadmapColor.ts`: + +```ts +import type { RoadmapPositionedItem } from "./types.js"; + +export type ColorBy = "source" | "label" | "state"; + +const STATE_COLORS: Record = { opened: "#1f75cb", closed: "#6b7280" }; + +/** Resolve the bar/card tint for an item under the chosen colorBy strategy. */ +export function resolveColor(item: RoadmapPositionedItem, colorBy: ColorBy): string { + if (colorBy === "state") return STATE_COLORS[item.state] ?? STATE_COLORS.opened; + if (colorBy === "label") return item.labels[0]?.color ?? STATE_COLORS[item.state]; + return item.color ?? STATE_COLORS[item.state]; // "source" +} +``` + +- [ ] **Step 4: Implement the dispatcher** + +Create `src/components/GitlabRoadmap.tsx`: + +```tsx +import React from "react"; +import { Fallback } from "./Fallback.js"; +import { RoadmapGantt } from "./RoadmapGantt.js"; +import { RoadmapTimeline } from "./RoadmapTimeline.js"; +import type { ColorBy } from "./roadmapColor.js"; +import type { ComponentPayload, RoadmapData } from "./types.js"; + +export interface GitlabRoadmapProps extends ComponentPayload { + layout?: "gantt" | "timeline"; + colorBy?: ColorBy; + showProgress?: boolean; + showLabels?: boolean; +} + +export function GitlabRoadmap({ + data, + error, + layout = "gantt", + colorBy = "source", + showProgress = true, + showLabels = false, +}: GitlabRoadmapProps) { + if (error) return ; + if (!data) return null; + const view = { data, colorBy, showProgress, showLabels }; + return layout === "timeline" ? : ; +} +``` + +> `RoadmapGantt` and `RoadmapTimeline` come in Tasks 8 and 9. This step will not compile until those files exist; you will run the test at the end of Task 9. To keep this task self-contained, proceed to Task 8 before running. + +- [ ] **Step 5: Commit (partial — layout children follow)** + +```bash +git add src/components/roadmapColor.ts src/components/GitlabRoadmap.tsx src/components/GitlabRoadmap.test.tsx +git commit -m "feat: add GitlabRoadmap dispatcher and color resolver" +``` + +--- + +## Task 8: `RoadmapGantt` layout + +**Files:** +- Create: `src/components/RoadmapGantt.tsx` +- Modify: `src/components/styles.module.css` +- Test: `src/components/RoadmapGantt.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/components/RoadmapGantt.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { RoadmapGantt } from "./RoadmapGantt"; +import type { RoadmapData } from "./types"; + +const data: RoadmapData = { + source: "epics", scale: "months", rangeStart: "2026-01-01", rangeEnd: "2026-04-01", + ticks: [{ label: "Jan", offsetPct: 0 }, { label: "Feb", offsetPct: 33 }], + groups: [{ + key: "Platform", title: "Platform", + items: [{ + id: 1, iid: 1, title: "Auth", state: "opened", startDate: "2026-01-01", dueDate: "2026-02-01", + webUrl: "https://x/1", color: "#1f75cb", progress: 60, parentId: null, parentTitle: "Platform", + labels: [{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }], + offsetPct: 10, widthPct: 40, + }], + }], +}; + +describe("RoadmapGantt", () => { + it("renders the scale header, group heading, and a positioned bar", () => { + const { container } = render(); + expect(screen.getByText("Platform")).toBeInTheDocument(); + expect(screen.getByText("Jan")).toBeInTheDocument(); + const bar = container.querySelector(".gitlab-roadmap-bar") as HTMLElement; + expect(bar).toHaveStyle({ left: "10%", width: "40%", backgroundColor: "#1f75cb" }); + expect(screen.getByRole("link", { name: /Auth/ })).toHaveAttribute("href", "https://x/1"); + }); + + it("renders the progress fill only when showProgress is true", () => { + const { container, rerender } = render(); + expect(container.querySelector(".gitlab-roadmap-progress")).toBeNull(); + rerender(); + expect(container.querySelector(".gitlab-roadmap-progress")).toHaveStyle({ width: "60%" }); + }); + + it("renders label chips only when showLabels is true", () => { + const { queryByText, rerender } = render(); + expect(queryByText("backend")).toBeNull(); + rerender(); + expect(queryByText("backend")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/RoadmapGantt.test.tsx` +Expected: FAIL — cannot resolve `./RoadmapGantt`. + +- [ ] **Step 3: Implement the component** + +Create `src/components/RoadmapGantt.tsx`: + +```tsx +import React from "react"; +import { resolveColor, type ColorBy } from "./roadmapColor.js"; +import type { RoadmapData, RoadmapPositionedItem } from "./types.js"; + +export interface RoadmapViewProps { + data: RoadmapData; + colorBy: ColorBy; + showProgress: boolean; + showLabels: boolean; +} + +function LabelChips({ item }: { item: RoadmapPositionedItem }) { + return ( + <> + {item.labels.map((l) => ( + + {l.name} + + ))} + + ); +} + +export function RoadmapGantt({ data, colorBy, showProgress, showLabels }: RoadmapViewProps) { + return ( +
+
+ {data.ticks.map((t) => ( + + {t.label} + + ))} +
+ {data.groups.map((group) => ( +
+ {group.title &&
{group.title}
} + {group.items.map((item) => { + const color = resolveColor(item, colorBy); + return ( +
+
+ {item.title} + {showLabels && } +
+
+
+ {showProgress && item.progress != null && ( +
+ )} +
+
+
+ ); + })} +
+ ))} +
+ ); +} +``` + +- [ ] **Step 4: Add styles** + +Append to `src/components/styles.module.css` (plain class selectors, matching how other components use `gitlab-*` global classes): + +```css +.gitlab-roadmap { font-size: 0.85rem; } +.gitlab-roadmap-gantt { overflow-x: auto; } +.gitlab-roadmap-scale { + position: relative; height: 1.4rem; margin-left: 12rem; + border-bottom: 1px solid var(--ifm-color-emphasis-300); +} +.gitlab-roadmap-tick { position: absolute; transform: translateX(-50%); color: var(--ifm-color-emphasis-600); } +.gitlab-roadmap-group-title { font-weight: 600; margin: 0.6rem 0 0.3rem; } +.gitlab-roadmap-row { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; align-items: center; margin: 0.25rem 0; } +.gitlab-roadmap-label-col { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.gitlab-roadmap-track { position: relative; height: 1.25rem; background: var(--ifm-color-emphasis-100); border-radius: 4px; } +.gitlab-roadmap-bar { position: absolute; top: 2px; height: calc(100% - 4px); border-radius: 4px; overflow: hidden; opacity: 0.85; } +.gitlab-roadmap-progress { height: 100%; background: rgba(0, 0, 0, 0.35); } +.gitlab-roadmap-label { + display: inline-block; margin-left: 0.3rem; padding: 0 0.3rem; + border-radius: 3px; font-size: 0.7rem; +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `npx vitest run src/components/RoadmapGantt.test.tsx` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/RoadmapGantt.tsx src/components/RoadmapGantt.test.tsx src/components/styles.module.css +git commit -m "feat: add RoadmapGantt horizontal layout" +``` + +--- + +## Task 9: `RoadmapTimeline` layout + +**Files:** +- Create: `src/components/RoadmapTimeline.tsx` +- Modify: `src/components/styles.module.css` +- Test: `src/components/RoadmapTimeline.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/components/RoadmapTimeline.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { RoadmapTimeline } from "./RoadmapTimeline"; +import type { RoadmapData } from "./types"; + +const data: RoadmapData = { + source: "epics", scale: "months", rangeStart: "2026-01-01", rangeEnd: "2026-04-01", ticks: [], + groups: [{ + key: "Platform", title: "Platform", + items: [{ + id: 1, iid: 1, title: "Auth", state: "opened", startDate: "2026-01-01", dueDate: "2026-02-01", + webUrl: "https://x/1", color: "#1f75cb", progress: 60, parentId: null, parentTitle: "Platform", + labels: [{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }], + offsetPct: 0, widthPct: 33, + }], + }], +}; + +describe("RoadmapTimeline", () => { + it("renders a vertical spine with group heading, card, and date range", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-roadmap-timeline")).toBeInTheDocument(); + expect(screen.getByText("Platform")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Auth/ })).toHaveAttribute("href", "https://x/1"); + expect(screen.getByText(/2026-01-01/)).toBeInTheDocument(); + expect(container.querySelector(".gitlab-roadmap-meter")).toHaveStyle({ width: "60%" }); + expect(screen.getByText("backend")).toBeInTheDocument(); + }); + + it("omits meter and labels when toggles are off", () => { + const { container, queryByText } = render( + , + ); + expect(container.querySelector(".gitlab-roadmap-meter")).toBeNull(); + expect(queryByText("backend")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/RoadmapTimeline.test.tsx` +Expected: FAIL — cannot resolve `./RoadmapTimeline`. + +- [ ] **Step 3: Implement the component** + +Create `src/components/RoadmapTimeline.tsx`: + +```tsx +import React from "react"; +import { resolveColor } from "./roadmapColor.js"; +import type { RoadmapViewProps } from "./RoadmapGantt.js"; + +function dateRange(start: string | null, due: string | null): string { + if (start && due) return `${start} → ${due}`; + return start ?? due ?? ""; +} + +export function RoadmapTimeline({ data, colorBy, showProgress, showLabels }: RoadmapViewProps) { + return ( +
+ {data.groups.map((group) => ( +
+ {group.title &&
{group.title}
} +
+ {group.items.map((item) => { + const color = resolveColor(item, colorBy); + return ( +
+ +
+ {item.title} +
{dateRange(item.startDate, item.dueDate)}
+ {showProgress && item.progress != null && ( +
+
+
+ )} + {showLabels && + item.labels.map((l) => ( + + {l.name} + + ))} +
+
+ ); + })} +
+
+ ))} +
+ ); +} +``` + +- [ ] **Step 4: Add styles** + +Append to `src/components/styles.module.css`: + +```css +.gitlab-roadmap-spine { border-left: 2px solid var(--ifm-color-emphasis-300); margin-left: 0.5rem; padding-left: 1rem; } +.gitlab-roadmap-node { position: relative; margin: 0.75rem 0; } +.gitlab-roadmap-dot { position: absolute; left: -1.4rem; top: 0.25rem; width: 0.6rem; height: 0.6rem; border-radius: 50%; } +.gitlab-roadmap-card { display: flex; flex-direction: column; gap: 0.25rem; align-items: flex-start; } +.gitlab-roadmap-dates { color: var(--ifm-color-emphasis-600); font-size: 0.75rem; } +.gitlab-roadmap-meter-track { width: 100%; max-width: 12rem; height: 0.4rem; background: var(--ifm-color-emphasis-200); border-radius: 3px; } +.gitlab-roadmap-meter { height: 100%; border-radius: 3px; } +``` + +- [ ] **Step 5: Run the timeline, dispatcher, and gantt tests** + +Run: `npx vitest run src/components/RoadmapTimeline.test.tsx src/components/GitlabRoadmap.test.tsx src/components/RoadmapGantt.test.tsx` +Expected: PASS (all three, including the Task 7 dispatcher test which now resolves its children). + +- [ ] **Step 6: Commit** + +```bash +git add src/components/RoadmapTimeline.tsx src/components/RoadmapTimeline.test.tsx src/components/styles.module.css +git commit -m "feat: add RoadmapTimeline vertical layout" +``` + +--- + +## Task 10: Exports + +**Files:** +- Modify: `src/components/index.ts` +- Modify: `src/index.ts` + +- [ ] **Step 1: Export the component** + +In `src/components/index.ts`, add: + +```ts +export { GitlabRoadmap } from "./GitlabRoadmap.js"; +``` + +And add to the `export type { ... } from "./types.js"` block: `RoadmapData`, `RoadmapItemData`, `LabelRef`. + +- [ ] **Step 2: Export the public types** + +In `src/index.ts`, add to the `export type { ... } from "./gitlab/types.js"` block: `RoadmapData`, `RoadmapItemData`, `LabelRef`. + +- [ ] **Step 3: Typecheck** + +Run: `npm run typecheck` +Expected: PASS. + +- [ ] **Step 4: Run the full suite** + +Run: `npm run test` +Expected: PASS (all existing + new tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/index.ts src/index.ts +git commit -m "feat: export GitlabRoadmap and roadmap types" +``` + +--- + +## Task 11: Documentation + example page + +**Files:** +- Modify: `README.md` +- Create: `examples/site/docs/components/roadmap.md` + +- [ ] **Step 1: Add a README section** + +Add a `### ` section to `README.md` (place it alongside the other component sections). Include the full prop table from the design spec and two examples: + +```md +### `` + +Renders a timeline of GitLab **epics** (Premium/Ultimate, group-level) or +**milestones** (free; project or group). All data is fetched at build time. + + + + + +| Prop | Values | Default | Notes | +|---|---|---|---| +| `source` | `epics` \| `milestones` | `epics` | Fetch path | +| `group` | group path/id | — | Required for epics; one of group/project for milestones | +| `project` | project path/id | — | Milestones only | +| `layout` | `gantt` \| `timeline` | `gantt` | Horizontal bars vs. vertical spine | +| `scale` | `quarters` \| `months` \| `weeks` | auto | Auto from span; prop overrides | +| `state` | `opened` \| `closed` \| `all` | `opened` | | +| `labels` | comma-separated | — | Label filter | +| `from` / `to` | `YYYY-MM-DD` | derived | Explicit window | +| `limit` | number | `50` | Max items (≤ 500) | +| `order` | `start` \| `due` \| `title` | `start` | Sort key | +| `groupBy` | `none` \| `label` \| `parent` | `none` | Section headings | +| `colorBy` | `source` \| `label` \| `state` | `source` | Bar/card tint | +| `showProgress` | boolean | `true` | Epics only | +| `showLabels` | boolean | `false` | Inline label chips | +``` + +- [ ] **Step 2: Create the example page** + +Create `examples/site/docs/components/roadmap.md` following the structure of the sibling pages in that folder (front-matter + a short intro + a couple of live `` embeds pointing at a public GitLab group/project used by the other example pages). + +- [ ] **Step 3: Commit** + +```bash +git add README.md examples/site/docs/components/roadmap.md +git commit -m "docs: document GitlabRoadmap component" +``` + +--- + +## Task 12: Full verification + +- [ ] **Step 1: Typecheck + full test suite** + +Run: `npm run typecheck && npm run test` +Expected: PASS. + +- [ ] **Step 2: Build the package** + +Run: `npm run build` +Expected: PASS — emits `dist/` with `.js` + `.d.ts` for the new files. + +- [ ] **Step 3: e2e build (pipeline touched — run once)** + +Run: `npx vitest run test/e2e/build.test.ts` +Expected: PASS — the example Docusaurus site (including `roadmap.md`) builds. If the example embeds hit a private/unavailable resource, point them at a public group/project or mark the page so the e2e's `strict:false` dev config degrades to `Fallback` rather than aborting. + +- [ ] **Step 4: Verify commits are signed** + +Run: `git log --format="%G? %s" -12` +Expected: every roadmap commit shows `G`. + +--- + +## Self-review notes + +- **Spec coverage:** source prop (Tasks 4–5), both layouts (8–9), scale auto+override (2), state/labels/from/to/limit/order/groupBy/colorBy/showProgress/showLabels (4, 7–9), epics+milestones normalization + label colors (4–5), no "today" marker / title-only / no markdown (components render text + `href` only — no `dangerouslySetInnerHTML`), strict degrade (4), 500-item ceiling (3), caching key excludes presentational props (4), registry + exports + docs (6, 10, 11). +- **Non-strict degrade:** `fetchRoadmap` rethrows on error; the remark layer (`src/remark/index.ts`) already converts a thrown fetch into an `error` prop when `strict` is false, which renders `` — consistent with every other fetcher. No separate empty-roadmap path is introduced. +- **Type consistency:** `RoadmapViewProps` is defined once in `RoadmapGantt.tsx` and reused by `RoadmapTimeline.tsx` and the dispatcher; `resolveColor(item, colorBy)` signature is identical across call sites; `BuildRoadmapOptions` matches the fetcher's construction (optional `scale`/`from`/`to` set conditionally to satisfy `exactOptionalPropertyTypes` if enabled). +``` diff --git a/docs/superpowers/specs/2026-07-10-gitlab-roadmap-component-design.md b/docs/superpowers/specs/2026-07-10-gitlab-roadmap-component-design.md new file mode 100644 index 0000000..2675bb2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-gitlab-roadmap-component-design.md @@ -0,0 +1,220 @@ +# Design: `` component + +**Status:** Approved (design phase) +**Date:** 2026-07-10 +**Pipeline:** 3 — `` JSX components (remark). See [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md). + +## Summary + +A new pipeline-3 component that embeds a GitLab **roadmap** — a timeline of +group **epics** or **milestones** — into a Docusaurus page. Like every other +component in this package, all data is fetched at build time and baked into +static HTML; the browser holds no token and makes no GitLab calls. + +The component renders in one of two layouts: + +- **`gantt`** (default) — horizontal bars on a time axis; scrolls sideways. +- **`timeline`** — a vertical spine of stacked cards; scrolls down only. Suited + to narrow docs columns and mobile. + +Because a static site is built once, there is **no "today" marker** — it would +be frozen at build time and go stale. Rendered content is **title + dates + +labels only**; no markdown descriptions are rendered, so this component never +touches `dangerouslySetInnerHTML` and stays clear of the XSS surface. + +## Data source + +GitLab's real roadmap renders **group epics** (Premium/Ultimate, group-level; +the Epics REST API is deprecated in 17.0 in favor of Work Items but still +functions via gitbeaker). **Milestones** carry the same start/due dates, are +available on the free tier, and exist at both project and group level. + +The component therefore takes a **`source` prop** with two fetch paths: + +- `source="epics"` → `GET /groups/:id/epics` (requires `group`). +- `source="milestones"` → group or project milestones (exactly one of + `group`/`project`). + +## Props API + +All attributes are **static literals** (rejected otherwise in +`src/remark/attributes.ts`), so data is fetched deterministically at build time. + +| Prop | Values | Default | Notes | +|---|---|---|---| +| `source` | `"epics"` \| `"milestones"` | `"epics"` | Chooses the fetch path | +| `group` | group path/id | — | Required for `epics`; one of `group`/`project` for `milestones` | +| `project` | project path/id | — | `milestones` only | +| `layout` | `"gantt"` \| `"timeline"` | `"gantt"` | Horizontal bars vs. vertical spine | +| `scale` | `"quarters"` \| `"months"` \| `"weeks"` | *auto* | Auto-selected from date span; prop overrides | +| `state` | `"opened"` \| `"closed"` \| `"all"` | `"opened"` | Normalized across epics/milestones | +| `labels` | comma-separated | — | Label filter (epics: API-side; milestones: client-side) | +| `from` / `to` | `YYYY-MM-DD` | *derived* | Explicit window; else min/max of item dates | +| `limit` | number | `50` | Capped at the 500 ceiling used by topics/labels | +| `order` | `"start"` \| `"due"` \| `"title"` | `"start"` | Sort key | +| `groupBy` | `"none"` \| `"label"` \| `"parent"` | `"none"` | `"parent"` = epic parent; renders section headings | +| `colorBy` | `"source"` \| `"label"` \| `"state"` | `"source"` | `source` = epic's own `color`; milestones fall back to `state` | +| `showProgress` | boolean | `true` | Progress fill overlay; epics only (milestones ignore) | +| `showLabels` | boolean | `false` | Inline label chips, reusing `src/components/scopedLabel.ts` | + +### Validation rules + +- `epics` without `group` → error. +- `milestones` with neither or both of `group`/`project` → error. +- Non-literal / unknown enum values → error with the same message style as the + existing `readOrder` / `readLayout` helpers in `fetchers.ts`. + +## Visual treatment (confirmed via mockups) + +- **Progress** renders as a darker fill inside a lighter bar (Gantt) / a small + meter on the card (timeline). Not a "60%" text. +- **Labels** render as inline colored chips next to the title when `showLabels`. +- **Grouped sections** render a heading per group; ungrouped output is a single + unnamed group. +- **No "today" marker** (static build; see Summary). + +## Architecture + +Standard pipeline-3 loop (`remark/index.ts` → `registry.ts` → fetcher → +`data` prop → pure component). Three new source areas: + +### 1. Domain types — `src/gitlab/types.ts` + +```ts +interface LabelRef { name: string; color: string; textColor: string; } + +interface RoadmapItemData { + id: number; + iid: number; + title: string; + state: "opened" | "closed"; + startDate: string | null; // YYYY-MM-DD + dueDate: string | null; + webUrl: string; + color?: string; // epic color; absent for milestones + progress?: number | null; // 0..100; epics only + parentId?: number | null; + parentTitle?: string | null; + labels: LabelRef[]; +} + +// Fully positioned model the component renders — no geometry math in React. +interface ScaleTick { label: string; offsetPct: number; } +interface RoadmapPositionedItem extends RoadmapItemData { + offsetPct: number; // bar left edge, 0..100 within [rangeStart,rangeEnd] + widthPct: number; // bar width, >0 +} +interface RoadmapGroup { key: string; title: string | null; items: RoadmapPositionedItem[]; } +interface RoadmapData { + source: "epics" | "milestones"; + scale: "quarters" | "months" | "weeks"; + rangeStart: string; // YYYY-MM-DD + rangeEnd: string; + ticks: ScaleTick[]; + groups: RoadmapGroup[]; +} +``` + +### 2. Geometry — `src/gitlab/roadmap.ts` (new, pure, no network) + +Owns all timeline math so the React components are dumb renderers and the logic +is unit-testable in isolation: + +- **Scale selection:** span ≤ ~3 months → `weeks`; ≤ ~1 year → `months`; else + `quarters`. Overridden by the `scale` prop. +- **Window:** `[from, to]` if given, else `[min(startDate), max(dueDate)]` across + items, snapped outward to the scale boundary. +- **Positioning:** each item → `offsetPct` / `widthPct` within the window. + Items with only one date are clamped to a half-open bar within range; items + with neither date are dropped upstream. +- **Ticks:** boundary ticks + labels for the chosen scale. +- **Grouping:** partition into `RoadmapGroup[]` by `groupBy` (`none` → one + unnamed group; `label` → one group per label; `parent` → by `parentTitle`). + +### 3. Client — `src/gitlab/client.ts` + +New thin gitbeaker wrappers, snake_case passthrough (normalized in the fetcher): + +- `getGroupEpics(group, { state, labels, orderBy, sort, perPage, maxPages })` → `Epics.all` +- `getGroupMilestones(group, opts)` → `GroupMilestones.all` +- `getProjectMilestones(project, opts)` → `ProjectMilestones.all` + +Pagination is bounded by the existing 500-item ceiling (`perPage` 100 × +`maxPages` 5) — do not raise it. + +### 4. Fetcher — `src/gitlab/fetchers.ts` + +`fetchRoadmap(ctx, attrs)`: + +1. Validate scope + enum literals (reuse the `readOrder`/`readLayout` error + style). +2. Fetch raw via the source path; normalize snake_case → `RoadmapItemData`: + - milestone `state` `active`/`closed` → `opened`/`closed`; + - epic `color`, `parent_id` → `parentId`, resolve `parentTitle` from the + fetched set when available; + - label names → `LabelRef` by resolving colors against the group/project + labels (reuse `getGroupLabels`/`getProjectLabels`); unknown labels get a + neutral default color. +3. Drop items with neither `startDate` nor `dueDate`. +4. Call `roadmap.ts` to select scale, compute window, position items, and group. +5. Wrap in `memo(...)`. **Cache key** includes `source`, scope (`group`/ + `project`), `state`, `labels`, `from`, `to`, `scale`, `order`, `groupBy`, + `limit`. `colorBy`, `showLabels`, and `layout` are presentational (read by the + component) and are **not** in the key. + +### 5. Components — `src/components/` + +- `GitlabRoadmap.tsx` — dispatches on `layout` → `RoadmapGantt` / `RoadmapTimeline`. + Both consume the same `RoadmapData`. Shared shape: `error → Fallback; + no data → null; else render`. Pure — no fetching, no hooks, no side effects. +- `RoadmapGantt.tsx` — scale header from `ticks`; per group a heading + rows of + positioned bars; progress fill; inline label chips. +- `RoadmapTimeline.tsx` — vertical spine; per group a heading + stacked cards + (title, date range, progress meter, labels). +- Styles in `src/components/styles.module.css` (typed via `src/css.d.ts`). +- `colorBy` resolves the bar/card tint: `source` → `item.color` (fallback to a + state color when absent), `label` → first label color, `state` → open/closed + palette. + +### 6. Registry & exports + +- `src/remark/registry.ts`: `GitlabRoadmap: fetchRoadmap`. +- `src/components/index.ts`: export `GitlabRoadmap`. +- `src/index.ts`: export the `RoadmapData` type. + +## Error handling + +Honors the `strict` option like every pipeline: a tier `403` (epics on free +tier), a bad scope, or an empty result **throws and aborts the build** in strict +mode, and **degrades to ``** otherwise. No new error channel. + +## Testing (TDD) + +- **`roadmap.test.ts`** (geometry, pure): scale-threshold selection, window + derivation + boundary snapping, positioning math, single-date clamping, + grouping partitions. The bulk of the logic lives here. +- **`fetchers` roadmap tests** (fake client): epics + milestones normalization, + state mapping, label-color resolution, scope validation, `strict` degrade path, + cache-key stability. +- **Component tests** (React Testing Library) for both layouts: queries by + role/text — group headings, item titles/links, progress fill present when + `showProgress`, label chips when `showLabels`, `error → Fallback`, + `no data → null`. +- No new e2e wiring beyond an example page; run `test/e2e/build.test.ts` + explicitly if the pipeline is touched. + +## Docs + +- README: a `` section with prop table + examples. +- `examples/site/docs/components/roadmap.md`: live example pages (both layouts, + both sources). + +## Non-goals (YAGNI) + +- No "today" marker (static build). +- No markdown/description rendering (title-only roadmap). +- No Work Items API migration (Epics API still works via gitbeaker; revisit if + it is removed). +- No interactive expand/collapse of child epics (static HTML; grouping headings + cover the hierarchy need). +- No raising the 500-item fetch ceiling. diff --git a/examples/gitlab/docs/ai.mdx b/examples/gitlab/docs/ai.mdx index e2a163c..84f2b30 100644 --- a/examples/gitlab/docs/ai.mdx +++ b/examples/gitlab/docs/ai.mdx @@ -1,4 +1 @@ -

Red text via HTML

- - {@includeGitlabReadme: gitlab-org/ai/skills} diff --git a/examples/gitlab/docs/road-map.mdx b/examples/gitlab/docs/road-map.mdx new file mode 100644 index 0000000..be67524 --- /dev/null +++ b/examples/gitlab/docs/road-map.mdx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/site/docs/components/roadmap.mdx b/examples/site/docs/components/roadmap.mdx new file mode 100644 index 0000000..0eff69e --- /dev/null +++ b/examples/site/docs/components/roadmap.mdx @@ -0,0 +1,55 @@ +--- +title: GitlabRoadmap +sidebar_position: 9 +--- + +# `` + +Renders a build-time timeline of GitLab **epics** (Premium/Ultimate, group-level) or +**milestones** (free; project or group), as horizontal Gantt bars or a vertical +timeline spine. + +## Usage + +```mdx + + + + + + + +``` + +## Props + +| Prop | Values | Default | Notes | +|---|---|---|---| +| `source` | `epics` \| `milestones` | `epics` | Fetch path | +| `group` | group path/id | — | Required for epics; one of group/project for milestones | +| `project` | project path/id | — | Milestones only | +| `layout` | `gantt` \| `timeline` | `gantt` | Horizontal bars vs. vertical spine | +| `layoutFit` | `page` \| `content` | `page` | Gantt only: `page` pins to the page width (ticks reduced to quarters/years by span, year rules bolded); `content` expands with a horizontal scrollbar | +| `scale` | `quarters` \| `months` \| `weeks` | auto | Auto from span; prop overrides | +| `state` | `opened` \| `closed` \| `all` | `opened` | | +| `labels` | comma-separated | — | Label filter | +| `from` / `to` | `YYYY-MM-DD` | derived | Explicit window | +| `limit` | number | `50` | Max items (≤ 500) | +| `order` | `start` \| `due` \| `title` | `start` | Sort key | +| `groupBy` | `none` \| `label` \| `parent` | `none` | Section headings. In the **timeline** layout, `none` groups items by **year → quarter** | +| `colorBy` | `source` \| `label` \| `state` | `source` | Bar/card tint | +| `showProgress` | boolean | `true` | Epics only | +| `showLabels` | boolean | `false` | Inline label chips | + +## Notes + +- **Epics** require GitLab Premium/Ultimate and are always group-level: pass `group` + (a `project` is ignored). A free/anonymous build against a group without epics + returns nothing (or a fallback in non-strict mode). +- **Milestones** are available on the free tier and can be scoped to either a + `project` or a `group`. +- `scale` defaults to a value derived from the total span of the items; set it + explicitly to force `quarters`, `months`, or `weeks`. +- `showProgress` only affects epics (progress is computed from their child issues). +- The **timeline** layout groups items by **year → quarter** by default; setting + `groupBy` to `label` or `parent` switches it to those flat sections instead. diff --git a/src/components/GitlabRoadmap.test.tsx b/src/components/GitlabRoadmap.test.tsx new file mode 100644 index 0000000..41ccdc6 --- /dev/null +++ b/src/components/GitlabRoadmap.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabRoadmap } from "./GitlabRoadmap"; +import type { RoadmapData } from "./types"; + +const data: RoadmapData = { + source: "epics", + scale: "months", + rangeStart: "2026-01-01", + rangeEnd: "2026-04-01", + ticks: [{ label: "Jan", offsetPct: 0 }, { label: "Feb", offsetPct: 33 }, { label: "Mar", offsetPct: 66 }], + groups: [ + { + key: "all", title: null, + items: [{ + id: 1, iid: 1, title: "Auth", state: "opened", startDate: "2026-01-01", dueDate: "2026-02-01", + webUrl: "https://x/epics/1", color: "#1f75cb", progress: 60, parentId: null, parentTitle: null, + labels: [{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }], + offsetPct: 0, widthPct: 33, + }], + }, + ], +}; + +describe("GitlabRoadmap", () => { + it("renders the gantt layout by default", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-roadmap-gantt")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Auth" })).toHaveAttribute("href", "https://x/epics/1"); + }); + + it("renders the timeline layout when layout='timeline'", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-roadmap-timeline")).toBeInTheDocument(); + }); + + it("renders the fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("boom"); + }); + + it("renders nothing when there is no data", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/GitlabRoadmap.tsx b/src/components/GitlabRoadmap.tsx new file mode 100644 index 0000000..4adb0d7 --- /dev/null +++ b/src/components/GitlabRoadmap.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Fallback } from "./Fallback.js"; +import { RoadmapGantt } from "./RoadmapGantt.js"; +import { RoadmapTimeline } from "./RoadmapTimeline.js"; +import type { ColorBy } from "./roadmapColor.js"; +import type { LayoutFit } from "./roadmapTicks.js"; +import type { ComponentPayload, RoadmapData } from "./types.js"; + +export interface GitlabRoadmapProps extends ComponentPayload { + layout?: "gantt" | "timeline"; + colorBy?: ColorBy; + showProgress?: boolean; + showLabels?: boolean; + /** Gantt only: "page" pins to the page width (thinning ticks), "content" expands + scrolls. */ + layoutFit?: LayoutFit; +} + +export function GitlabRoadmap({ + data, + error, + layout = "gantt", + colorBy = "source", + showProgress = true, + showLabels = false, + layoutFit = "page", +}: GitlabRoadmapProps) { + if (error) return ; + if (!data) return null; + // Normalize any arbitrary MDX attribute string to a known value before it + // becomes a `gitlab-roadmap-fit-${fit}` class name. + const fit: LayoutFit = layoutFit === "content" ? "content" : "page"; + const view = { data, colorBy, showProgress, showLabels, layoutFit: fit }; + return layout === "timeline" ? : ; +} diff --git a/src/components/RoadmapGantt.test.tsx b/src/components/RoadmapGantt.test.tsx new file mode 100644 index 0000000..23b3ab2 --- /dev/null +++ b/src/components/RoadmapGantt.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { RoadmapGantt } from "./RoadmapGantt"; +import type { RoadmapData } from "./types"; + +const data: RoadmapData = { + source: "epics", scale: "months", rangeStart: "2026-01-01", rangeEnd: "2026-04-01", + ticks: [{ label: "Jan", offsetPct: 0 }, { label: "Feb", offsetPct: 33 }], + groups: [{ + key: "Platform", title: "Platform", + items: [{ + id: 1, iid: 1, title: "Auth", state: "opened", startDate: "2026-01-01", dueDate: "2026-02-01", + webUrl: "https://x/1", color: "#1f75cb", progress: 60, parentId: null, parentTitle: "Platform", + labels: [{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }], + offsetPct: 10, widthPct: 40, + }], + }], +}; + +describe("RoadmapGantt", () => { + it("renders the scale header, group heading, and a positioned bar", () => { + const { container } = render(); + expect(screen.getByText("Platform")).toBeInTheDocument(); + // page fit (default) regenerates span-based ticks; a <1y span → a year boundary tick. + expect(screen.getByText("2026")).toBeInTheDocument(); + const bar = container.querySelector(".gitlab-roadmap-bar") as HTMLElement; + expect(bar).toHaveStyle({ left: "10%", width: "40%", backgroundColor: "#1f75cb" }); + expect(screen.getByRole("link", { name: /Auth/ })).toHaveAttribute("href", "https://x/1"); + }); + + it("renders the progress fill only when showProgress is true", () => { + const { container, rerender } = render(); + expect(container.querySelector(".gitlab-roadmap-progress")).toBeNull(); + rerender(); + expect(container.querySelector(".gitlab-roadmap-progress")).toHaveStyle({ width: "60%" }); + }); + + it("renders label chips only when showLabels is true", () => { + const { queryByText, rerender } = render(); + expect(queryByText("backend")).toBeNull(); + rerender(); + expect(queryByText("backend")).toBeInTheDocument(); + }); + + it("defaults to page fit: scroll container capped, no content min-width", () => { + const { container } = render(); + const root = container.querySelector(".gitlab-roadmap-gantt") as HTMLElement; + const inner = container.querySelector(".gitlab-roadmap-gantt-inner") as HTMLElement; + expect(root).toHaveClass("gitlab-roadmap-fit-page"); + expect(inner.style.minWidth).toBe(""); + }); + + it("content fit puts a tick-count min-width on the inner box (root stays the scroll container)", () => { + const { container } = render(); + const root = container.querySelector(".gitlab-roadmap-gantt") as HTMLElement; + const inner = container.querySelector(".gitlab-roadmap-gantt-inner") as HTMLElement; + expect(root).toHaveClass("gitlab-roadmap-fit-content"); + expect(root.style.minWidth).toBe(""); // min-width must NOT be on the scroll container + // 12.5rem label column + 2 ticks × 3rem = 18.5rem (jsdom collapses the calc sum). + expect(inner.style.minWidth).toBe("calc(18.5rem)"); + }); + + it("page fit reduces a long multi-year span to year ticks with vertical rules", () => { + const longSpan: RoadmapData = { ...data, rangeStart: "2026-01-01", rangeEnd: "2033-06-01", ticks: [] }; + const { container } = render(); + const labels = [...container.querySelectorAll(".gitlab-roadmap-tick")].map((n) => n.textContent); + expect(labels).toEqual(["2026", "2027", "2028", "2029", "2030", "2031", "2032", "2033"]); + expect(container.querySelectorAll(".gitlab-roadmap-tick-major")).toHaveLength(8); + }); +}); diff --git a/src/components/RoadmapGantt.tsx b/src/components/RoadmapGantt.tsx new file mode 100644 index 0000000..79a3a2e --- /dev/null +++ b/src/components/RoadmapGantt.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { resolveColor, type ColorBy } from "./roadmapColor.js"; +import { visibleTicks, type LayoutFit } from "./roadmapTicks.js"; +import type { RoadmapData, RoadmapPositionedItem } from "./types.js"; + +export interface RoadmapViewProps { + data: RoadmapData; + colorBy: ColorBy; + showProgress: boolean; + showLabels: boolean; + /** Gantt only: pin to page width (thin ticks) or expand + scroll to fit content. */ + layoutFit?: LayoutFit; +} + +/** Width (rem) each scale unit gets when the gantt expands to fit its content. */ +const CONTENT_UNIT_REM = 3; +/** Width (rem) of the fixed left label column (matches the CSS grid/margin). */ +const LABEL_COL_REM = 12.5; + +function LabelChips({ item }: { item: RoadmapPositionedItem }) { + return ( + <> + {item.labels.map((l) => ( + + {l.name} + + ))} + + ); +} + +export function RoadmapGantt({ data, colorBy, showProgress, showLabels, layoutFit = "page" }: RoadmapViewProps) { + const ticks = visibleTicks(data.ticks, layoutFit, data.rangeStart, data.rangeEnd); + // In content fit the gantt grows past the page and scrolls; size it to the full + // (un-thinned) tick count so bars keep room. Page fit stays at 100% (no min-width). + const minWidth = + layoutFit === "content" ? `calc(${LABEL_COL_REM}rem + ${data.ticks.length * CONTENT_UNIT_REM}rem)` : undefined; + return ( + // Outer box is the scroll container (constrained to the page width); the inner + // box carries the content min-width so content fit scrolls internally instead of + // overflowing the page. +
+
+
+ {ticks.map((t) => ( + + {t.label} + + ))} +
+ {data.groups.map((group) => ( +
+ {group.title &&
{group.title}
} + {group.items.map((item) => { + const color = resolveColor(item, colorBy); + return ( +
+
+ {item.title} + {showLabels && } +
+
+
+ {showProgress && item.progress != null && ( +
+ )} +
+
+
+ ); + })} +
+ ))} +
+
+ ); +} diff --git a/src/components/RoadmapTimeline.test.tsx b/src/components/RoadmapTimeline.test.tsx new file mode 100644 index 0000000..036c60c --- /dev/null +++ b/src/components/RoadmapTimeline.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { RoadmapTimeline } from "./RoadmapTimeline"; +import type { RoadmapData } from "./types"; + +const data: RoadmapData = { + source: "epics", scale: "months", rangeStart: "2026-01-01", rangeEnd: "2026-04-01", ticks: [], + groups: [{ + key: "Platform", title: "Platform", + items: [{ + id: 1, iid: 1, title: "Auth", state: "opened", startDate: "2026-01-01", dueDate: "2026-02-01", + webUrl: "https://x/1", color: "#1f75cb", progress: 60, parentId: null, parentTitle: "Platform", + labels: [{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }], + offsetPct: 0, widthPct: 33, + }], + }], +}; + +describe("RoadmapTimeline", () => { + it("renders a vertical spine with group heading, card, and date range", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-roadmap-timeline")).toBeInTheDocument(); + expect(screen.getByText("Platform")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Auth/ })).toHaveAttribute("href", "https://x/1"); + expect(screen.getByText(/2026-01-01/)).toBeInTheDocument(); + expect(container.querySelector(".gitlab-roadmap-meter")).toHaveStyle({ width: "60%" }); + expect(screen.getByText("backend")).toBeInTheDocument(); + }); + + it("omits meter and labels when toggles are off", () => { + const { container, queryByText } = render( + , + ); + expect(container.querySelector(".gitlab-roadmap-meter")).toBeNull(); + expect(queryByText("backend")).toBeNull(); + }); + + it("date-groups ungrouped data by year then quarter (the default)", () => { + const item = (id: number, title: string, start: string): RoadmapData["groups"][0]["items"][0] => ({ + id, iid: id, title, state: "opened", startDate: start, dueDate: null, + webUrl: `https://x/${id}`, labels: [], offsetPct: 0, widthPct: 10, + }); + const ungrouped: RoadmapData = { + source: "epics", scale: "quarters", rangeStart: "2026-01-01", rangeEnd: "2027-06-01", ticks: [], + groups: [{ key: "all", title: null, items: [ + item(1, "Auth", "2026-01-15"), + item(2, "Billing", "2026-05-01"), + item(3, "Search", "2027-02-01"), + ] }], + }; + const { container } = render(); + // Year headings (group-title) and quarter sub-headings. + expect(screen.getByText("2026")).toBeInTheDocument(); + expect(screen.getByText("2027")).toBeInTheDocument(); + expect(container.querySelectorAll(".gitlab-roadmap-subgroup-title")).toHaveLength(3); // 2026 Q1, 2026 Q2, 2027 Q1 + expect(screen.getByText("Q2")).toBeInTheDocument(); + expect(screen.getAllByText("Q1")).toHaveLength(2); + expect(screen.getByRole("link", { name: "Auth" })).toBeInTheDocument(); + }); +}); diff --git a/src/components/RoadmapTimeline.tsx b/src/components/RoadmapTimeline.tsx new file mode 100644 index 0000000..12210df --- /dev/null +++ b/src/components/RoadmapTimeline.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import type { RoadmapViewProps } from "./RoadmapGantt.js"; +import { resolveColor, type ColorBy } from "./roadmapColor.js"; +import { groupByYearQuarter } from "./roadmapDateGroups.js"; +import type { RoadmapPositionedItem } from "./types.js"; + +function dateRange(start: string | null, due: string | null): string { + if (start && due) return `${start} → ${due}`; + return start ?? due ?? ""; +} + +interface NodeOpts { + colorBy: ColorBy; + showProgress: boolean; + showLabels: boolean; +} + +function TimelineNode({ item, colorBy, showProgress, showLabels }: NodeOpts & { item: RoadmapPositionedItem }) { + const color = resolveColor(item, colorBy); + return ( +
+ +
+ {item.title} +
{dateRange(item.startDate, item.dueDate)}
+ {showProgress && item.progress != null && ( +
+
+
+ )} + {showLabels && + item.labels.map((l) => ( + + {l.name} + + ))} +
+
+ ); +} + +function Spine({ items, ...node }: NodeOpts & { items: RoadmapPositionedItem[] }) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +export function RoadmapTimeline({ data, colorBy, showProgress, showLabels }: RoadmapViewProps) { + const node: NodeOpts = { colorBy, showProgress, showLabels }; + // No explicit groupBy → default to a year → quarter date hierarchy. + const ungrouped = data.groups.length === 1 && data.groups[0].title === null; + + if (ungrouped) { + return ( +
+ {groupByYearQuarter(data.groups[0].items).map((yearGroup) => ( +
+
{yearGroup.year}
+ {yearGroup.quarters.map((q) => ( +
+
{q.label}
+ +
+ ))} +
+ ))} +
+ ); + } + + // Explicit groupBy (label/parent): flat sections, as built at fetch time. + return ( +
+ {data.groups.map((group) => ( +
+ {group.title &&
{group.title}
} + +
+ ))} +
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 9e88a25..a4092ef 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,6 +6,7 @@ export { GitlabFile } from "./GitlabFile.js"; export { GitlabTopics } from "./GitlabTopics.js"; export { GitlabLabels } from "./GitlabLabels.js"; export { GitlabProjectGrid } from "./GitlabProjectGrid.js"; +export { GitlabRoadmap } from "./GitlabRoadmap.js"; export type { ComponentLayout } from "./layout.js"; export type { ProjectInfoData, @@ -17,6 +18,9 @@ export type { TopicData, LabelData, GroupProjectData, + RoadmapData, + RoadmapItemData, + LabelRef, FetchError, ComponentPayload, } from "./types.js"; diff --git a/src/components/roadmapColor.test.ts b/src/components/roadmapColor.test.ts new file mode 100644 index 0000000..f54472f --- /dev/null +++ b/src/components/roadmapColor.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { resolveColor } from "./roadmapColor"; +import type { RoadmapPositionedItem } from "./types"; + +function item(partial: Partial): RoadmapPositionedItem { + return { + id: 1, iid: 1, title: "X", state: "opened", startDate: null, dueDate: null, + webUrl: "https://x", labels: [], offsetPct: 0, widthPct: 10, ...partial, + }; +} + +describe("resolveColor", () => { + it("source: uses the item's own color, falling back to the state color", () => { + expect(resolveColor(item({ color: "#123456" }), "source")).toBe("#123456"); + expect(resolveColor(item({ color: undefined, state: "closed" }), "source")).toBe("#6b7280"); + }); + it("label: uses the first label's color, falling back to the state color", () => { + expect(resolveColor(item({ labels: [{ name: "a", color: "#abcdef", textColor: "#fff" }] }), "label")).toBe("#abcdef"); + expect(resolveColor(item({ labels: [], state: "opened" }), "label")).toBe("#1f75cb"); + }); + it("state: uses the open/closed palette regardless of item color", () => { + expect(resolveColor(item({ color: "#123456", state: "opened" }), "state")).toBe("#1f75cb"); + expect(resolveColor(item({ state: "closed" }), "state")).toBe("#6b7280"); + }); +}); diff --git a/src/components/roadmapColor.ts b/src/components/roadmapColor.ts new file mode 100644 index 0000000..2787144 --- /dev/null +++ b/src/components/roadmapColor.ts @@ -0,0 +1,12 @@ +import type { RoadmapPositionedItem } from "./types.js"; + +export type ColorBy = "source" | "label" | "state"; + +const STATE_COLORS: Record = { opened: "#1f75cb", closed: "#6b7280" }; + +/** Resolve the bar/card tint for an item under the chosen colorBy strategy. */ +export function resolveColor(item: RoadmapPositionedItem, colorBy: ColorBy): string { + if (colorBy === "state") return STATE_COLORS[item.state] ?? STATE_COLORS.opened; + if (colorBy === "label") return item.labels[0]?.color ?? STATE_COLORS[item.state]; + return item.color ?? STATE_COLORS[item.state]; // "source" +} diff --git a/src/components/roadmapDateGroups.test.ts b/src/components/roadmapDateGroups.test.ts new file mode 100644 index 0000000..edcf7b6 --- /dev/null +++ b/src/components/roadmapDateGroups.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { groupByYearQuarter } from "./roadmapDateGroups"; +import type { RoadmapPositionedItem } from "./types"; + +function pos(id: number, title: string, startDate: string | null, dueDate: string | null = null): RoadmapPositionedItem { + return { + id, iid: id, title, state: "opened", startDate, dueDate, webUrl: `https://x/${id}`, + labels: [], offsetPct: 0, widthPct: 10, + }; +} + +describe("groupByYearQuarter", () => { + it("buckets items into year → quarter, both sorted chronologically", () => { + const groups = groupByYearQuarter([ + pos(1, "late", "2027-02-01"), + pos(2, "mid", "2026-05-01"), + pos(3, "early", "2026-01-15"), + ]); + expect(groups.map((g) => g.year)).toEqual(["2026", "2027"]); + expect(groups[0].quarters.map((q) => q.label)).toEqual(["Q1", "Q2"]); + expect(groups[0].quarters[0].items.map((i) => i.title)).toEqual(["early"]); + expect(groups[0].quarters[1].items.map((i) => i.title)).toEqual(["mid"]); + expect(groups[1].quarters.map((q) => q.label)).toEqual(["Q1"]); + }); + + it("files an item with no start date under its due date", () => { + const groups = groupByYearQuarter([pos(1, "dueOnly", null, "2026-11-01")]); + expect(groups[0].year).toBe("2026"); + expect(groups[0].quarters[0].label).toBe("Q4"); + }); + + it("puts several items in the same quarter bucket", () => { + const groups = groupByYearQuarter([ + pos(1, "a", "2026-07-01"), + pos(2, "b", "2026-08-15"), + ]); + expect(groups[0].quarters).toHaveLength(1); + expect(groups[0].quarters[0].label).toBe("Q3"); + expect(groups[0].quarters[0].items.map((i) => i.title)).toEqual(["a", "b"]); + }); +}); diff --git a/src/components/roadmapDateGroups.ts b/src/components/roadmapDateGroups.ts new file mode 100644 index 0000000..c99bc02 --- /dev/null +++ b/src/components/roadmapDateGroups.ts @@ -0,0 +1,48 @@ +import type { RoadmapPositionedItem } from "./types.js"; + +export interface QuarterGroup { + key: string; + /** "Q1".."Q4" */ + label: string; + items: RoadmapPositionedItem[]; +} +export interface YearGroup { + year: string; + quarters: QuarterGroup[]; +} + +/** The date an item is filed under: its start, falling back to its due date. */ +function fileDate(item: RoadmapPositionedItem): string { + return item.startDate ?? item.dueDate ?? ""; +} + +/** + * Bucket positioned items into a year → quarter hierarchy, both levels sorted + * chronologically. Empty quarters/years are omitted. Every item lands in exactly + * one bucket (by its start date, or due date when it has no start). + */ +export function groupByYearQuarter(items: RoadmapPositionedItem[]): YearGroup[] { + const byYear = new Map>(); + for (const item of items) { + const iso = fileDate(item); + if (!iso) continue; + const year = iso.slice(0, 4); + const quarter = Math.floor((Number(iso.slice(5, 7)) - 1) / 3) + 1; + const quarters = byYear.get(year) ?? new Map(); + const bucket = quarters.get(quarter) ?? []; + bucket.push(item); + quarters.set(quarter, bucket); + byYear.set(year, quarters); + } + // Array.from (not spread) so a Babel bundle with loose/array-like spread + // assumptions can't mis-compile these Map-iterator drains; entries() also lets + // each year's quarter map be destructured directly (no re-lookup, no `!`). + return Array.from(byYear.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([year, quarters]) => ({ + year, + quarters: Array.from(quarters.entries()) + .sort(([a], [b]) => a - b) + .map(([q, items]) => ({ key: `${year}-Q${q}`, label: `Q${q}`, items })), + })); +} diff --git a/src/components/roadmapTicks.test.ts b/src/components/roadmapTicks.test.ts new file mode 100644 index 0000000..9100bd2 --- /dev/null +++ b/src/components/roadmapTicks.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { visibleTicks, pageTicks } from "./roadmapTicks"; +import type { ScaleTick } from "./types"; + +describe("visibleTicks", () => { + it("content fit returns the given ticks unchanged", () => { + const ticks: ScaleTick[] = [{ label: "Jan", offsetPct: 0 }, { label: "Feb", offsetPct: 50 }]; + expect(visibleTicks(ticks, "content", "2026-01-01", "2026-03-01")).toBe(ticks); + }); + + it("page fit regenerates span-based ticks, ignoring the fine input ticks", () => { + const fine: ScaleTick[] = [{ label: "Jan", offsetPct: 0 }]; + const out = visibleTicks(fine, "page", "2026-01-01", "2028-01-01"); + expect(out).not.toContain(fine[0]); + expect(out.some((t) => t.label === "2026")).toBe(true); + }); +}); + +describe("pageTicks", () => { + it("span > 6 years: only year labels, all major", () => { + const t = pageTicks("2026-01-01", "2033-06-01"); // ~7.4y + expect(t.every((x) => x.major)).toBe(true); + expect(t.map((x) => x.label)).toEqual(["2026", "2027", "2028", "2029", "2030", "2031", "2032", "2033"]); + }); + + it("span > 3 and <= 6 years: year majors plus unlabelled mid-year minors", () => { + const t = pageTicks("2026-01-01", "2030-06-01"); // ~4.4y + expect(t.filter((x) => x.major).map((x) => x.label)).toEqual(["2026", "2027", "2028", "2029", "2030"]); + const minors = t.filter((x) => !x.major); + expect(minors).toHaveLength(4); // mid-2026..mid-2029 (mid-2030 is past the end) + expect(minors.every((x) => x.label === "")).toBe(true); + }); + + it("span <= 3 years: quarter labels with each year boundary shown as the year (major)", () => { + const t = pageTicks("2026-01-01", "2028-01-01"); // 2y + expect(t.filter((x) => x.major).map((x) => x.label)).toEqual(["2026", "2027"]); + expect(t.map((x) => x.label)).toEqual(["2026", "Q2", "Q3", "Q4", "2027", "Q2", "Q3", "Q4"]); + }); + + it("caps a decades-long span so year labels can't overflow", () => { + const t = pageTicks("2000-01-01", "2050-01-01"); // 50y + expect(t.length).toBeLessThanOrEqual(20); + expect(t.every((x) => x.major)).toBe(true); + }); + + it("returns nothing for a non-positive window", () => { + expect(pageTicks("2026-01-01", "2026-01-01")).toEqual([]); + }); +}); diff --git a/src/components/roadmapTicks.ts b/src/components/roadmapTicks.ts new file mode 100644 index 0000000..4aba827 --- /dev/null +++ b/src/components/roadmapTicks.ts @@ -0,0 +1,81 @@ +import type { ScaleTick } from "./types.js"; + +export type LayoutFit = "page" | "content"; + +/** A scale tick plus a flag marking year boundaries, which render a vertical rule. */ +export interface RenderTick extends ScaleTick { + major?: boolean; +} + +const MS_PER_DAY = 86_400_000; +const MS_PER_YEAR = 365.25 * MS_PER_DAY; +/** Safety cap so a decades-long, year-only axis still can't overlap. */ +const MAX_YEAR_TICKS = 20; + +function parseDay(iso: string): number { + // Tolerate a full datetime ("2026-03-01T00:00:00Z") by taking the date part only. + const [y, m, d] = iso.slice(0, 10).split("-").map(Number); + return Date.UTC(y, m - 1, d); +} +function toISODate(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +/** + * Ticks for the page-fit gantt, chosen by the timeline span so labels never + * overlap (they replace the fine weekly/monthly ticks, which would crowd a + * fixed-width axis): + * - span > 6 years → one label per year (thinned further past ~20 years); + * - span > 3 years → a year label per year + an unlabelled mid-year mark; + * - span ≤ 3 years → quarter labels, each year boundary shown as the year. + * Year boundaries are flagged `major` so the gantt draws a vertical rule there. + */ +export function pageTicks(rangeStart: string, rangeEnd: string): RenderTick[] { + const startMs = parseDay(rangeStart); + const endMs = parseDay(rangeEnd); + const total = endMs - startMs; + if (total <= 0) return []; + + const startYear = new Date(startMs).getUTCFullYear(); + const endYear = new Date(endMs).getUTCFullYear(); + const spanYears = total / MS_PER_YEAR; + const ticks: RenderTick[] = []; + const add = (ms: number, label: string, major: boolean): void => { + if (ms >= startMs && ms < endMs) { + ticks.push({ label, offsetPct: ((ms - startMs) / total) * 100, date: toISODate(ms), major }); + } + }; + + if (spanYears > 6) { + const years: number[] = []; + for (let y = startYear; y <= endYear; y++) years.push(y); + const step = Math.ceil(years.length / MAX_YEAR_TICKS); + years.filter((_, i) => i % step === 0).forEach((y) => add(Date.UTC(y, 0, 1), String(y), true)); + } else if (spanYears > 3) { + for (let y = startYear; y <= endYear; y++) { + add(Date.UTC(y, 0, 1), String(y), true); + add(Date.UTC(y, 6, 1), "", false); // mid-year marker + } + } else { + for (let y = startYear; y <= endYear; y++) { + for (let q = 0; q < 4; q++) { + const major = q === 0; + add(Date.UTC(y, q * 3, 1), major ? String(y) : `Q${q + 1}`, major); + } + } + } + return ticks; +} + +/** + * Choose which ticks the gantt renders. Content fit keeps the full (fine) tick + * set and scrolls; page fit regenerates a span-appropriate, non-overlapping set. + */ +export function visibleTicks( + ticks: ScaleTick[], + fit: LayoutFit, + rangeStart: string, + rangeEnd: string, +): RenderTick[] { + return fit === "content" ? ticks : pageTicks(rangeStart, rangeEnd); +} diff --git a/src/components/types.ts b/src/components/types.ts index 650cd7c..1f341fd 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -10,4 +10,13 @@ export type { GroupProjectData, FetchError, ComponentPayload, + LabelRef, + RoadmapSource, + RoadmapState, + RoadmapScale, + RoadmapItemData, + RoadmapPositionedItem, + ScaleTick, + RoadmapGroup, + RoadmapData, } from "../gitlab/types.js"; diff --git a/src/gitlab/client.test.ts b/src/gitlab/client.test.ts index 2ecc111..8521cf3 100644 --- a/src/gitlab/client.test.ts +++ b/src/gitlab/client.test.ts @@ -11,6 +11,9 @@ const projectLabelsAllMock = vi.fn(); const groupLabelsAllMock = vi.fn(); const groupShowMock = vi.fn(); const contributorsAllMock = vi.fn(); +const epicsAllMock = vi.fn(); +const groupMilestonesAllMock = vi.fn(); +const projectMilestonesAllMock = vi.fn(); vi.mock("@gitbeaker/rest", () => ({ // Vitest 4 invokes the mock implementation as a real constructor under @@ -28,6 +31,9 @@ vi.mock("@gitbeaker/rest", () => ({ GroupLabels: { all: groupLabelsAllMock }, Groups: { show: groupShowMock }, Repositories: { allContributors: contributorsAllMock }, + Epics: { all: epicsAllMock }, + GroupMilestones: { all: groupMilestonesAllMock }, + ProjectMilestones: { all: projectMilestonesAllMock }, }; }), })); @@ -49,6 +55,9 @@ beforeEach(() => { groupLabelsAllMock.mockReset(); groupShowMock.mockReset(); contributorsAllMock.mockReset(); + epicsAllMock.mockReset(); + groupMilestonesAllMock.mockReset(); + projectMilestonesAllMock.mockReset(); }); afterEach(() => { vi.unstubAllGlobals(); @@ -260,3 +269,25 @@ describe("getGroupProjects", () => { }); }); }); + +describe("roadmap sources", () => { + it("getGroupEpics passes filters and bounded pagination", async () => { + epicsAllMock.mockResolvedValue([{ id: 1 }]); + const client = new GitLabClient({ host: "https://gitlab.com", token: "t" }); + const res = await client.getGroupEpics("g", { state: "opened", labels: "a", orderBy: "start_date", sort: "asc" }); + expect(res).toEqual([{ id: 1 }]); + expect(epicsAllMock).toHaveBeenCalledWith("g", { + state: "opened", labels: "a", orderBy: "start_date", sort: "asc", perPage: 100, maxPages: 5, + }); + }); + + it("getGroupMilestones and getProjectMilestones fetch with bounded pagination", async () => { + groupMilestonesAllMock.mockResolvedValue([{ id: 2 }]); + projectMilestonesAllMock.mockResolvedValue([{ id: 3 }]); + const client = new GitLabClient({ host: "https://gitlab.com", token: "t" }); + expect(await client.getGroupMilestones("g")).toEqual([{ id: 2 }]); + expect(await client.getProjectMilestones("p/x")).toEqual([{ id: 3 }]); + expect(groupMilestonesAllMock).toHaveBeenCalledWith("g", { perPage: 100, maxPages: 5 }); + expect(projectMilestonesAllMock).toHaveBeenCalledWith("p/x", { perPage: 100, maxPages: 5 }); + }); +}); diff --git a/src/gitlab/client.ts b/src/gitlab/client.ts index 5ee0352..88be261 100644 --- a/src/gitlab/client.ts +++ b/src/gitlab/client.ts @@ -18,6 +18,13 @@ export interface IssuesQuery { limit: number; } +export interface EpicsQuery { + state?: string; + labels?: string; + orderBy?: string; + sort?: string; +} + /** Pagination for the topic/label list endpoints. Defaults cap the fetch at 500 items. */ export interface PageOptions { perPage?: number; @@ -116,6 +123,25 @@ export class GitLabClient { }); } + async getGroupEpics(group: ProjectRef, opts: EpicsQuery = {}): Promise { + return this.api.Epics.all(group, { + ...(opts.state ? { state: opts.state } : {}), + ...(opts.labels ? { labels: opts.labels } : {}), + ...(opts.orderBy ? { orderBy: opts.orderBy } : {}), + ...(opts.sort ? { sort: opts.sort } : {}), + perPage: DEFAULT_PER_PAGE, + maxPages: DEFAULT_MAX_PAGES, + } as any); + } + + async getGroupMilestones(group: ProjectRef): Promise { + return this.api.GroupMilestones.all(group, { perPage: DEFAULT_PER_PAGE, maxPages: DEFAULT_MAX_PAGES }); + } + + async getProjectMilestones(project: ProjectRef): Promise { + return this.api.ProjectMilestones.all(project, { perPage: DEFAULT_PER_PAGE, maxPages: DEFAULT_MAX_PAGES }); + } + private headers(): Record { const h: Record = { Accept: "application/json" }; if (this.config.token) h["PRIVATE-TOKEN"] = this.config.token; diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index 6ebf236..a221a74 100644 --- a/src/gitlab/fetchers.test.ts +++ b/src/gitlab/fetchers.test.ts @@ -6,7 +6,7 @@ import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { describe, it, expect, vi } from "vitest"; import { FileCache } from "./cache"; -import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels, fetchGroupProjects } from "./fetchers"; +import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels, fetchGroupProjects, fetchRoadmap } from "./fetchers"; function ctx(client: any) { const dir = mkdtempSync(join(tmpdir(), "glfetch-")); @@ -682,3 +682,101 @@ describe("fetchGroupProjects", () => { expect(c.client.getGroupProjects).toHaveBeenCalledTimes(1); }); }); + +describe("fetchRoadmap (epics)", () => { + const epics = [ + { id: 10, iid: 1, title: "Auth", state: "opened", start_date: "2026-01-01", due_date: "2026-03-01", + web_url: "https://gitlab.com/groups/g/-/epics/1", color: "#1f75cb", parent_id: null, labels: ["backend"] }, + { id: 11, iid: 2, title: "Billing", state: "closed", start_date: "2026-02-01", due_date: "2026-05-01", + web_url: "https://gitlab.com/groups/g/-/epics/2", color: "#6666c4", parent_id: 10, labels: [] }, + ]; + + it("normalizes epics into positioned RoadmapData", async () => { + const client = { + getGroupEpics: vi.fn(async () => epics), + getGroupLabels: vi.fn(async () => [{ name: "backend", color: "#dbeafe", text_color: "#1e40af" }]), + }; + const c = ctx(client); + const data = await fetchRoadmap(c, { source: "epics", group: "g" }); + expect(data.source).toBe("epics"); + const items = data.groups.flatMap((g) => g.items); + expect(items.map((i) => i.title).sort()).toEqual(["Auth", "Billing"]); + const auth = items.find((i) => i.title === "Auth")!; + expect(auth.startDate).toBe("2026-01-01"); + expect(auth.color).toBe("#1f75cb"); + expect(auth.labels).toEqual([{ name: "backend", color: "#dbeafe", textColor: "#1e40af" }]); + expect(auth.widthPct).toBeGreaterThan(0); + expect(client.getGroupEpics).toHaveBeenCalled(); + }); + + it("sends only an Epics-API-valid order_by (never start_date/due_date)", async () => { + // The GitLab Epics API rejects order_by=start_date|due_date; start/due ordering + // is done client-side. Guard against re-introducing an invalid API value. + const valid = new Set(["created_at", "updated_at", "title"]); + for (const order of ["start", "due", "title"] as const) { + const client = { getGroupEpics: vi.fn(async () => epics), getGroupLabels: vi.fn(async () => []) }; + const c = ctx(client); + await fetchRoadmap(c, { source: "epics", group: "g", order }); + const passedOrderBy = (client.getGroupEpics.mock.calls[0] as any)[1].orderBy; + expect(valid.has(passedOrderBy)).toBe(true); + } + }); + + it("throws when source is epics but group is missing", async () => { + const c = ctx({}); + await expect(fetchRoadmap(c, { source: "epics" })).rejects.toThrow(/group/); + }); + + it("degrades: rethrows in strict mode", async () => { + const client = { getGroupEpics: vi.fn(async () => { throw new Error("403 tier"); }) }; + const c = ctx(client); + c.options.strict = true; + await expect(fetchRoadmap(c, { source: "epics", group: "g" })).rejects.toThrow("403 tier"); + }); + + it("rejects a non-ISO from/to date", async () => { + const client = { getGroupEpics: vi.fn(async () => []), getGroupLabels: vi.fn(async () => []) }; + const c = ctx(client); + await expect(fetchRoadmap(c, { source: "epics", group: "g", from: "last week" })).rejects.toThrow(/YYYY-MM-DD/); + }); +}); + +describe("fetchRoadmap (milestones)", () => { + const milestones = [ + { id: 1, iid: 5, title: "v1.0", state: "active", start_date: "2026-01-01", due_date: "2026-02-01", + web_url: "https://gitlab.com/g/r/-/milestones/5" }, + { id: 2, iid: 6, title: "v1.1", state: "closed", start_date: "2026-02-01", due_date: "2026-03-01", + web_url: "https://gitlab.com/g/r/-/milestones/6" }, + ]; + + it("normalizes project milestones and maps active→opened (state=all shows both)", async () => { + const client = { + getProjectMilestones: vi.fn(async () => milestones), + getProjectLabels: vi.fn(async () => []), + }; + const c = ctx(client); + const data = await fetchRoadmap(c, { source: "milestones", project: "g/r", state: "all" }); + expect(data.source).toBe("milestones"); + const items = data.groups.flatMap((g) => g.items); + expect(items.find((i) => i.title === "v1.0")!.state).toBe("opened"); + expect(items.find((i) => i.title === "v1.1")!.state).toBe("closed"); + expect(items.every((i) => i.color === undefined)).toBe(true); + expect(client.getProjectMilestones).toHaveBeenCalledWith("g/r"); + }); + + it("fetches group milestones when group is given", async () => { + const client = { getGroupMilestones: vi.fn(async () => milestones), getGroupLabels: vi.fn(async () => []) }; + const c = ctx(client); + const data = await fetchRoadmap(c, { source: "milestones", group: "g", state: "all" }); + expect(data.groups.flatMap((g) => g.items)).toHaveLength(2); + expect(client.getGroupMilestones).toHaveBeenCalledWith("g"); + }); + + it("filters to active milestones by default (state defaults to opened)", async () => { + const client = { getProjectMilestones: vi.fn(async () => milestones), getProjectLabels: vi.fn(async () => []) }; + const c = ctx(client); + const data = await fetchRoadmap(c, { source: "milestones", project: "g/r" }); + const items = data.groups.flatMap((g) => g.items); + expect(items.map((i) => i.title)).toEqual(["v1.0"]); + }); +}); diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index 1c49084..19266cf 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -8,6 +8,7 @@ import type { AssetManager } from "./assets"; import type { FileCache } from "./cache"; import type { GitLabClient, PageOptions } from "./client"; import { renderMarkdown } from "./markdown.js"; +import { buildRoadmap, type BuildRoadmapOptions } from "./roadmap.js"; import type { TocEntry, TocMode } from "./toc.js"; import type { CommitData, @@ -15,9 +16,13 @@ import type { GroupProjectData, IssueData, LabelData, + LabelRef, ProjectInfoData, ReadmeData, ReleaseData, + RoadmapData, + RoadmapItemData, + RoadmapScale, TopicData, } from "./types"; @@ -510,3 +515,187 @@ export async function fetchFileSource( return { raw, ref } satisfies SourceResult; }); } + +function readRoadmapSource(value: unknown): "epics" | "milestones" { + if (value === undefined || value === "epics") return "epics"; + if (value === "milestones") return "milestones"; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "source" must be "epics" or "milestones"; got ${JSON.stringify(value)}.`, + ); +} + +function readRoadmapScale(value: unknown): RoadmapScale | undefined { + if (value === undefined) return undefined; + if (value === "quarters" || value === "months" || value === "weeks") return value; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "scale" must be "quarters", "months", or "weeks"; got ${JSON.stringify(value)}.`, + ); +} + +function readRoadmapOrder(value: unknown): "start" | "due" | "title" { + if (value === undefined || value === "start") return "start"; + if (value === "due" || value === "title") return value; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "order" must be "start", "due", or "title"; got ${JSON.stringify(value)}.`, + ); +} + +function readRoadmapGroupBy(value: unknown): "none" | "label" | "parent" { + if (value === undefined || value === "none") return "none"; + if (value === "label" || value === "parent") return value; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "groupBy" must be "none", "label", or "parent"; got ${JSON.stringify(value)}.`, + ); +} + +function readRoadmapDate(value: unknown, attr: string): string | undefined { + if (value === undefined) return undefined; + const s = String(value); + if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "${attr}" must be an ISO date (YYYY-MM-DD); got ${JSON.stringify(value)}.`, + ); + } + return s; +} + +/** Build a name→LabelRef lookup from group/project labels for color resolution. */ +async function labelIndex( + ctx: GitLabContext, + scope: { group?: string | number; project?: string | number }, +): Promise> { + const raw = + scope.project !== undefined + ? await ctx.client.getProjectLabels(scope.project).catch(() => []) + : scope.group !== undefined + ? await ctx.client.getGroupLabels(scope.group).catch(() => []) + : []; + const idx = new Map(); + for (const l of raw as any[]) { + idx.set(l.name, { name: l.name, color: l.color, textColor: l.text_color }); + } + return idx; +} + +function resolveLabels(names: string[], idx: Map): LabelRef[] { + return names.map( + (name) => idx.get(name) ?? { name, color: "#e5e7eb", textColor: "#1f2937" }, + ); +} + +export async function fetchRoadmap(ctx: GitLabContext, attrs: Attrs): Promise { + const source = readRoadmapSource(attrs.source); + const group = attrs.group as string | number | undefined; + const project = attrs.project as string | number | undefined; + const scale = readRoadmapScale(attrs.scale); + const order = readRoadmapOrder(attrs.order); + const groupBy = readRoadmapGroupBy(attrs.groupBy); + const state = (attrs.state as string) ?? "opened"; + const labels = attrs.labels as string | undefined; + const from = readRoadmapDate(attrs.from, "from"); + const to = readRoadmapDate(attrs.to, "to"); + const limit = typeof attrs.limit === "number" ? attrs.limit : 50; + + if (source === "epics" && group === undefined) { + throw new Error(`@ebuildy/docusaurus-plugin-gitlab: requires a "group".`); + } + if (source === "milestones" && (group === undefined) === (project === undefined)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: requires exactly one of "project" or "group".`, + ); + } + + const scopeKey = project !== undefined ? `p:${String(project)}` : `g:${String(group)}`; + const key = `roadmap:${source}:${scopeKey}:${state}:${labels ?? ""}:${from ?? ""}:${to ?? ""}:${scale ?? "auto"}:${order}:${groupBy}:${limit}`; + + // On failure this simply throws; the remark layer (src/remark/index.ts) turns a + // thrown fetch into an `error` prop (Fallback) when `strict` is false, and + // aborts the build when true — identical to every other fetcher. + return memo(ctx, key, async () => { + const buildOpts: BuildRoadmapOptions = { source, order, groupBy }; + if (scale) buildOpts.scale = scale; + if (from) buildOpts.from = from; + if (to) buildOpts.to = to; + + const items = + source === "epics" + ? await fetchEpicItems(ctx, group!, { state, labels, order }, limit) + : await fetchMilestoneItems(ctx, { group, project }, state, limit); + + return buildRoadmap(items, buildOpts); + }); +} + +async function fetchEpicItems( + ctx: GitLabContext, + group: string | number, + q: { state: string; labels?: string; order: "start" | "due" | "title" }, + limit: number, +): Promise { + // The Epics API's order_by only accepts created_at | updated_at | title — NOT + // start_date/due_date. start/due ordering is applied client-side in buildRoadmap; + // here we pass a valid API value so GitLab doesn't reject the request. + const orderBy = q.order === "title" ? "title" : "created_at"; + const raw = await ctx.client.getGroupEpics(group, { + state: q.state, + ...(q.labels ? { labels: q.labels } : {}), + orderBy, + sort: "asc", + }); + const idx = await labelIndex(ctx, { group }); + const byId = new Map(raw.map((e: any) => [e.id, e])); + return raw.slice(0, limit).map((e: any) => ({ + id: e.id, + iid: e.iid, + title: e.title, + state: e.state === "closed" ? "closed" : "opened", + startDate: e.start_date ?? null, + dueDate: e.due_date ?? null, + webUrl: e.web_url, + color: e.color, + progress: computeProgress(e.descendant_counts), + parentId: e.parent_id ?? null, + // parentTitle resolves only within the fetched page; a parent outside the + // result set (filtered out or beyond the page ceiling) yields null → grouped under "(no parent)". + parentTitle: e.parent_id != null ? byId.get(e.parent_id)?.title ?? null : null, + labels: resolveLabels(Array.isArray(e.labels) ? e.labels : [], idx), + } satisfies RoadmapItemData)); +} + +async function fetchMilestoneItems( + ctx: GitLabContext, + scope: { group?: string | number; project?: string | number }, + state: string, + limit: number, +): Promise { + // Milestone API state vocabulary is active/closed; map our opened→active. + const apiState = state === "opened" ? "active" : state; // "closed" and "all" pass through + const raw = + scope.project !== undefined + ? await ctx.client.getProjectMilestones(scope.project) + : await ctx.client.getGroupMilestones(scope.group!); + const filtered = + apiState === "all" ? raw : raw.filter((m: any) => m.state === apiState); + return filtered.slice(0, limit).map((m: any) => ({ + id: m.id, + iid: m.iid, + title: m.title, + state: m.state === "closed" ? "closed" : "opened", + startDate: m.start_date ?? null, + dueDate: m.due_date ?? null, + webUrl: m.web_url, + progress: null, + parentId: null, + parentTitle: null, + labels: [], + } satisfies RoadmapItemData)); +} + +/** GitLab epic list payloads may include descendant issue counts; derive % from them. */ +function computeProgress(counts: any): number | null { + if (!counts) return null; + const opened = Number(counts.opened_issues ?? 0); + const closed = Number(counts.closed_issues ?? 0); + const total = opened + closed; + return total > 0 ? Math.round((closed / total) * 100) : null; +} diff --git a/src/gitlab/roadmap.test.ts b/src/gitlab/roadmap.test.ts new file mode 100644 index 0000000..c7bc9ba --- /dev/null +++ b/src/gitlab/roadmap.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { + selectScale, + positionItem, + buildTicks, + groupItems, + buildRoadmap, +} from "./roadmap"; +import type { RoadmapItemData, RoadmapPositionedItem } from "./types"; + +function item(partial: Partial): RoadmapItemData { + return { + id: 1, iid: 1, title: "X", state: "opened", + startDate: null, dueDate: null, webUrl: "https://x", labels: [], + ...partial, + }; +} + +describe("selectScale", () => { + it("picks weeks for a short span (<= 92 days)", () => { + expect(selectScale("2026-01-01", "2026-02-01")).toBe("weeks"); + }); + it("picks months for a mid span (<= 366 days)", () => { + expect(selectScale("2026-01-01", "2026-07-01")).toBe("months"); + }); + it("picks quarters for a long span (> 366 days)", () => { + expect(selectScale("2026-01-01", "2028-06-01")).toBe("quarters"); + }); +}); + +describe("positionItem", () => { + it("positions a bar as a percentage of the window", () => { + const p = positionItem( + item({ startDate: "2026-01-01", dueDate: "2026-01-06" }), + "2026-01-01", + "2026-01-11", + ); + expect(p.offsetPct).toBe(0); + expect(p.widthPct).toBe(50); + }); + it("clamps a bar that starts before the window and keeps a minimum width", () => { + const p = positionItem( + item({ dueDate: "2026-01-02" }), // start falls back to due → zero-length + "2026-01-01", + "2026-01-11", + ); + expect(p.offsetPct).toBeGreaterThanOrEqual(0); + expect(p.widthPct).toBeGreaterThan(0); + expect(p.offsetPct + p.widthPct).toBeLessThanOrEqual(100); + }); +}); + +describe("buildTicks", () => { + it("emits one tick per month across the window", () => { + const ticks = buildTicks("2026-01-01", "2026-04-01", "months"); + expect(ticks.map((t) => t.label)).toEqual(["Jan", "Feb", "Mar"]); + expect(ticks[0].offsetPct).toBe(0); + }); + it("tags each tick with its ISO boundary date", () => { + const ticks = buildTicks("2026-01-01", "2026-04-01", "months"); + expect(ticks.map((t) => t.date)).toEqual(["2026-01-01", "2026-02-01", "2026-03-01"]); + }); +}); + +describe("groupItems", () => { + const positioned = (name: string, parent: string | null): RoadmapPositionedItem => ({ + ...item({ title: name, parentTitle: parent, labels: [] }), + offsetPct: 0, widthPct: 10, + }); + it("returns a single unnamed group when groupBy is none", () => { + const groups = groupItems([positioned("a", null)], "none"); + expect(groups).toHaveLength(1); + expect(groups[0].title).toBeNull(); + }); + it("splits into one section per parent title when groupBy is parent", () => { + const groups = groupItems( + [positioned("a", "Platform"), positioned("b", "Growth"), positioned("c", null)], + "parent", + ); + expect(groups.map((g) => g.title).sort()).toEqual(["(no parent)", "Growth", "Platform"]); + }); +}); + +describe("buildRoadmap", () => { + it("drops undated items, sorts, positions, and wraps in RoadmapData", () => { + const data = buildRoadmap( + [ + item({ id: 1, title: "late", startDate: "2026-06-01", dueDate: "2026-08-01" }), + item({ id: 2, title: "early", startDate: "2026-01-01", dueDate: "2026-03-01" }), + item({ id: 3, title: "undated" }), + ], + { source: "epics", order: "start", groupBy: "none" }, + ); + expect(data.source).toBe("epics"); + expect(data.groups[0].items.map((i) => i.title)).toEqual(["early", "late"]); + expect(data.groups[0].items).toHaveLength(2); // undated dropped + expect(data.rangeStart <= "2026-01-01").toBe(true); + expect(data.ticks.length).toBeGreaterThan(0); + }); + + it("tolerates date values that carry a time component without crashing", () => { + const data = buildRoadmap( + [item({ id: 1, title: "dt", startDate: "2026-03-01T00:00:00.000Z", dueDate: "2026-06-01T12:00:00Z" })], + { source: "epics", order: "start", groupBy: "none" }, + ); + // No RangeError, and the ISO date part is parsed cleanly (not NaN). + expect(data.rangeStart).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(data.rangeEnd).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(data.groups[0].items).toHaveLength(1); + }); + + it("honors an explicit scale override and window", () => { + const data = buildRoadmap( + [item({ id: 1, title: "a", startDate: "2026-02-01", dueDate: "2026-03-01" })], + { source: "epics", order: "start", groupBy: "none", scale: "weeks", from: "2026-01-01", to: "2026-04-01" }, + ); + expect(data.scale).toBe("weeks"); + expect(data.rangeStart).toBe("2026-01-01"); + expect(data.rangeEnd).toBe("2026-04-01"); + }); + + it("returns an empty roadmap instead of throwing when no items have dates", () => { + const data = buildRoadmap([item({ title: "undated" })], { source: "epics", order: "start", groupBy: "none" }); + expect(data.groups).toEqual([]); + expect(data.ticks).toEqual([]); + }); + + it("does not throw on an empty item array", () => { + expect(() => buildRoadmap([], { source: "milestones", order: "start", groupBy: "none" })).not.toThrow(); + }); +}); diff --git a/src/gitlab/roadmap.ts b/src/gitlab/roadmap.ts new file mode 100644 index 0000000..f1300e9 --- /dev/null +++ b/src/gitlab/roadmap.ts @@ -0,0 +1,190 @@ +import type { + RoadmapData, + RoadmapGroup, + RoadmapItemData, + RoadmapPositionedItem, + RoadmapScale, + RoadmapSource, + ScaleTick, +} from "./types.js"; + +const MS_PER_DAY = 86_400_000; +const MIN_WIDTH_PCT = 1; // keep zero-length/point items visible +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +function parseDay(iso: string): number { + // Tolerate a full datetime ("2026-03-01T00:00:00Z") by taking the date part only. + const [y, m, d] = iso.slice(0, 10).split("-").map(Number); + return Date.UTC(y, m - 1, d); +} +function toISODate(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} +function spanDays(startISO: string, endISO: string): number { + return (parseDay(endISO) - parseDay(startISO)) / MS_PER_DAY; +} + +/** Round a timestamp down to the start of its scale unit (Monday / 1st / quarter). */ +function snapDown(ms: number, scale: RoadmapScale): number { + const d = new Date(ms); + const y = d.getUTCFullYear(); + const m = d.getUTCMonth(); + if (scale === "weeks") { + const day = d.getUTCDay(); // 0=Sun..6=Sat + const backToMonday = day === 0 ? 6 : day - 1; + return ms - backToMonday * MS_PER_DAY; + } + if (scale === "months") return Date.UTC(y, m, 1); + return Date.UTC(y, Math.floor(m / 3) * 3, 1); // quarters +} + +/** Advance a boundary timestamp by exactly one scale unit. */ +function advance(ms: number, scale: RoadmapScale): number { + const d = new Date(ms); + const y = d.getUTCFullYear(); + const m = d.getUTCMonth(); + if (scale === "weeks") return ms + 7 * MS_PER_DAY; + if (scale === "months") return Date.UTC(y, m + 1, 1); + return Date.UTC(y, m + 3, 1); // quarters +} + +/** Round a timestamp up to the next scale boundary (unchanged if already on one). */ +function snapUp(ms: number, scale: RoadmapScale): number { + const down = snapDown(ms, scale); + return down === ms ? ms : advance(down, scale); +} + +export function selectScale(startISO: string, endISO: string): RoadmapScale { + const days = spanDays(startISO, endISO); + // ~a quarter (92d) → weeks; ~a year (366d) → months; longer → quarters + if (days <= 92) return "weeks"; + if (days <= 366) return "months"; + return "quarters"; +} + +/** Raw min-start / max-due across items (falling back to the other date). */ +function rawBounds(items: RoadmapItemData[]): { startISO: string; endISO: string } { + const starts = items.map((i) => i.startDate ?? i.dueDate!).filter(Boolean); + const ends = items.map((i) => i.dueDate ?? i.startDate!).filter(Boolean); + const startMs = Math.min(...starts.map(parseDay)); + const endMs = Math.max(...ends.map(parseDay)); + return { startISO: toISODate(startMs), endISO: toISODate(endMs) }; +} + +function deriveWindow( + items: RoadmapItemData[], + scale: RoadmapScale, + from: string | undefined, + to: string | undefined, +): { rangeStart: string; rangeEnd: string } { + const raw = rawBounds(items); + const startMs = from ? parseDay(from) : snapDown(parseDay(raw.startISO), scale); + let endMs = to ? parseDay(to) : snapUp(parseDay(raw.endISO), scale); + if (endMs <= startMs) endMs = advance(startMs, scale); // guarantee a positive window + return { rangeStart: toISODate(startMs), rangeEnd: toISODate(endMs) }; +} + +export function positionItem( + it: RoadmapItemData, + rangeStart: string, + rangeEnd: string, +): { offsetPct: number; widthPct: number } { + const s = parseDay(it.startDate ?? it.dueDate!); + const e = parseDay(it.dueDate ?? it.startDate!); + const total = parseDay(rangeEnd) - parseDay(rangeStart); + const offsetPct = Math.min(Math.max(((s - parseDay(rangeStart)) / total) * 100, 0), 100); + const rawWidth = ((e - s) / total) * 100; + const widthPct = Math.max(Math.min(rawWidth, 100 - offsetPct), MIN_WIDTH_PCT); + return { offsetPct, widthPct }; +} + +function tickLabel(ms: number, scale: RoadmapScale): string { + const d = new Date(ms); + if (scale === "quarters") return `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`; + if (scale === "weeks") return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; + return MONTHS[d.getUTCMonth()]; +} + +export function buildTicks(rangeStart: string, rangeEnd: string, scale: RoadmapScale): ScaleTick[] { + const startMs = parseDay(rangeStart); + const endMs = parseDay(rangeEnd); + const total = endMs - startMs; + const ticks: ScaleTick[] = []; + for (let cur = startMs; cur < endMs; cur = advance(cur, scale)) { + ticks.push({ + label: tickLabel(cur, scale), + offsetPct: ((cur - startMs) / total) * 100, + date: toISODate(cur), + }); + } + return ticks; +} + +export function groupItems( + items: RoadmapPositionedItem[], + groupBy: "none" | "label" | "parent", +): RoadmapGroup[] { + if (groupBy === "none") return [{ key: "all", title: null, items }]; + const map = new Map(); + for (const it of items) { + const keys = + groupBy === "label" + ? it.labels.length + ? it.labels.map((l) => l.name) + : ["(no label)"] + : [it.parentTitle ?? "(no parent)"]; + for (const k of keys) { + const g = map.get(k) ?? { key: k, title: k, items: [] }; + g.items.push(it); + map.set(k, g); + } + } + // Array.from (not spread) so a bundler with loose/array-like spread assumptions + // can't mis-compile this Map-iterator drain — see roadmapDateGroups.ts. + return Array.from(map.values()); +} + +function sortItems(items: RoadmapItemData[], order: "start" | "due" | "title"): RoadmapItemData[] { + const key = (i: RoadmapItemData): string => + order === "title" ? i.title : order === "due" ? i.dueDate ?? i.startDate ?? "" : i.startDate ?? i.dueDate ?? ""; + return [...items].sort((a, b) => key(a).localeCompare(key(b))); +} + +export interface BuildRoadmapOptions { + source: RoadmapSource; + order: "start" | "due" | "title"; + groupBy: "none" | "label" | "parent"; + scale?: RoadmapScale; + from?: string; + to?: string; +} + +export function buildRoadmap(items: RoadmapItemData[], opts: BuildRoadmapOptions): RoadmapData { + const dated = items.filter((i) => i.startDate || i.dueDate); + if (dated.length === 0) { + return { + source: opts.source, + scale: opts.scale ?? "months", + rangeStart: opts.from ?? "", + rangeEnd: opts.to ?? "", + ticks: [], + groups: [], + }; + } + const sorted = sortItems(dated, opts.order); + const raw = rawBounds(sorted); + const scale = opts.scale ?? selectScale(raw.startISO, raw.endISO); + const { rangeStart, rangeEnd } = deriveWindow(sorted, scale, opts.from, opts.to); + const positioned: RoadmapPositionedItem[] = sorted.map((i) => ({ + ...i, + ...positionItem(i, rangeStart, rangeEnd), + })); + return { + source: opts.source, + scale, + rangeStart, + rangeEnd, + ticks: buildTicks(rangeStart, rangeEnd, scale), + groups: groupItems(positioned, opts.groupBy), + }; +} diff --git a/src/gitlab/types.ts b/src/gitlab/types.ts index 047b942..4c5e9c0 100644 --- a/src/gitlab/types.ts +++ b/src/gitlab/types.ts @@ -123,3 +123,66 @@ export interface GroupProjectData { defaultBranch: string | null; topics: string[]; } + +/** A GitLab label reduced to what the roadmap renders. */ +export interface LabelRef { + name: string; + color: string; + textColor: string; +} + +export type RoadmapSource = "epics" | "milestones"; +export type RoadmapState = "opened" | "closed"; +export type RoadmapScale = "quarters" | "months" | "weeks"; + +/** One epic/milestone normalized from the GitLab API. */ +export interface RoadmapItemData { + id: number; + iid: number; + title: string; + state: RoadmapState; + /** ISO `YYYY-MM-DD`, or null when the source has no such date. */ + startDate: string | null; + dueDate: string | null; + webUrl: string; + /** Epic color (e.g. `#1f75cb`); absent for milestones. */ + color?: string; + /** Completion 0..100; epics only, null when not derivable. */ + progress?: number | null; + parentId?: number | null; + parentTitle?: string | null; + labels: LabelRef[]; +} + +/** An item after geometry: same fields plus its bar placement. */ +export interface RoadmapPositionedItem extends RoadmapItemData { + /** Bar left edge as a percentage of the timeline window (0..100). */ + offsetPct: number; + /** Bar width as a percentage of the window (>0). */ + widthPct: number; +} + +export interface ScaleTick { + label: string; + /** Tick position as a percentage of the window (0..100). */ + offsetPct: number; + /** ISO `YYYY-MM-DD` of the tick boundary; lets a layout relabel/coarsen ticks. */ + date?: string; +} + +export interface RoadmapGroup { + key: string; + /** Section heading; null for the single ungrouped bucket. */ + title: string | null; + items: RoadmapPositionedItem[]; +} + +/** The fully positioned model the component renders — no math in React. */ +export interface RoadmapData { + source: RoadmapSource; + scale: RoadmapScale; + rangeStart: string; // ISO YYYY-MM-DD + rangeEnd: string; + ticks: ScaleTick[]; + groups: RoadmapGroup[]; +} diff --git a/src/index.ts b/src/index.ts index 83ee861..c76ec3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,5 +20,8 @@ export type { TopicData, LabelData, GroupProjectData, + RoadmapData, + RoadmapItemData, + LabelRef, FetchError, } from "./gitlab/types.js"; diff --git a/src/remark/index.test.ts b/src/remark/index.test.ts index c3e6b5a..6ce18fc 100644 --- a/src/remark/index.test.ts +++ b/src/remark/index.test.ts @@ -16,6 +16,7 @@ vi.mock("../gitlab/fetchers.js", () => ({ fetchTopics: vi.fn(), fetchLabels: vi.fn(), fetchGroupProjects: vi.fn(), + fetchRoadmap: vi.fn(), })); function processor(opts: any) { diff --git a/src/remark/registry.ts b/src/remark/registry.ts index b222cf8..112a4f9 100644 --- a/src/remark/registry.ts +++ b/src/remark/registry.ts @@ -7,6 +7,7 @@ import { fetchTopics, fetchLabels, fetchGroupProjects, + fetchRoadmap, type GitLabContext, } from "../gitlab/fetchers.js"; @@ -21,4 +22,5 @@ export const COMPONENT_REGISTRY: Record = { GitlabTopics: fetchTopics, GitlabLabels: fetchLabels, GitlabProjectGrid: fetchGroupProjects, + GitlabRoadmap: fetchRoadmap, }; diff --git a/theme.css b/theme.css index 3ffd08e..ba6b813 100644 --- a/theme.css +++ b/theme.css @@ -407,3 +407,67 @@ content: "★ "; color: var(--ifm-color-warning); } + +/* Roadmap (Gantt / timeline) */ +.gitlab-roadmap { font-size: 0.85rem; } +/* layoutFit: the gantt root is the scroll container, capped at the page width; + the inner box carries the content min-width. "page" clips overflow (ticks are + thinned to fit); "content" scrolls sideways to the inner min-width. + NB: page fit uses `clip`, not `hidden` — `overflow-x: hidden` would coerce + `overflow-y` to `auto`, making the gantt a scroll container and breaking the + sticky scale header below; `clip` leaves `overflow-y: visible`. */ +.gitlab-roadmap-gantt { max-width: 100%; } +.gitlab-roadmap-fit-page { overflow-x: clip; } +.gitlab-roadmap-fit-content { overflow-x: auto; } +/* The ticks bar sticks to the top (below the Docusaurus navbar) as rows scroll. + Sticks against the page in page fit; in content fit the scroll container + scopes it (no page-scroll stickiness there). */ +.gitlab-roadmap-scale { + position: sticky; top: var(--ifm-navbar-height, 0); z-index: 2; + height: 1.4rem; margin-left: calc(12rem + 0.5rem); + background: var(--ifm-background-color); + border-bottom: 1px solid var(--ifm-color-emphasis-300); +} +/* Extend the sticky header's background left over the row-label gutter so + scrolling rows don't peek above it. */ +.gitlab-roadmap-scale::before { + content: ""; position: absolute; top: 0; bottom: -1px; + left: calc(-12rem - 0.5rem); width: calc(12rem + 0.5rem); + background: var(--ifm-background-color); + border-bottom: 1px solid var(--ifm-color-emphasis-300); +} +.gitlab-roadmap-tick { + position: absolute; transform: translateX(-50%); white-space: nowrap; + color: var(--ifm-color-emphasis-600); +} +/* Year boundaries: bolder label + a vertical rule dropping to the scale baseline. */ +.gitlab-roadmap-tick-major { font-weight: 600; color: var(--ifm-color-emphasis-800); } +.gitlab-roadmap-tick-major::after { + content: ""; position: absolute; left: 50%; top: 100%; + width: 1px; height: 0.45rem; background: var(--ifm-color-emphasis-500); + transform: translateX(-50%); +} +.gitlab-roadmap-group-title { font-weight: 600; margin: 0.6rem 0 0.3rem; } +/* Timeline date grouping: year uses the group title above; quarter is a smaller, + indented sub-heading. */ +.gitlab-roadmap-subgroup { margin-left: 0.5rem; } +.gitlab-roadmap-subgroup-title { + font-weight: 600; font-size: 0.8rem; color: var(--ifm-color-emphasis-700); + margin: 0.4rem 0 0.2rem; +} +.gitlab-roadmap-row { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; align-items: center; margin: 0.25rem 0; } +.gitlab-roadmap-label-col { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.gitlab-roadmap-track { position: relative; height: 1.25rem; background: var(--ifm-color-emphasis-100); border-radius: 4px; } +.gitlab-roadmap-bar { position: absolute; top: 2px; height: calc(100% - 4px); border-radius: 4px; overflow: hidden; opacity: 0.85; } +.gitlab-roadmap-progress { height: 100%; background: rgba(0, 0, 0, 0.35); } +.gitlab-roadmap-label { + display: inline-block; margin-left: 0.3rem; padding: 0 0.3rem; + border-radius: 3px; font-size: 0.7rem; +} +.gitlab-roadmap-spine { border-left: 2px solid var(--ifm-color-emphasis-300); margin-left: 0.5rem; padding-left: 1rem; } +.gitlab-roadmap-node { position: relative; margin: 0.75rem 0; } +.gitlab-roadmap-dot { position: absolute; left: -1.4rem; top: 0.25rem; width: 0.6rem; height: 0.6rem; border-radius: 50%; } +.gitlab-roadmap-card { display: flex; flex-direction: column; gap: 0.25rem; align-items: flex-start; } +.gitlab-roadmap-dates { color: var(--ifm-color-emphasis-600); font-size: 0.75rem; } +.gitlab-roadmap-meter-track { width: 100%; max-width: 12rem; height: 0.4rem; background: var(--ifm-color-emphasis-200); border-radius: 3px; } +.gitlab-roadmap-meter { height: 100%; border-radius: 3px; }