Goal: one workbench for a project that groups, filters, and saves views over its issues — agnostic to taxonomy (gates, phases, sprints, workstreams), without hardcoding any of them. Tabs exist, but they are user-defined saved views, not a fixed gate strip.
All five frontend slices and their backing storage + REST surfaces have
landed on main. A post-design amendment (per-view issue membership)
shipped on top.
| Slice | Status | Evidence |
|---|---|---|
| 1 | ✅ done | frontend/src/projects/milestones-strip.tsx; crates/dp-store-pg/migrations/dp/0030_milestones.sql |
| 2 | ✅ done | frontend/src/projects/project-workbench.tsx; §7.2 group_by wired in crates/dp-rest/src/project_issues.rs |
| 3 | ✅ done | frontend/src/projects/project-filter-chips.tsx; filter param on the same handler |
| 4 | ✅ done | crates/dp-store-pg/migrations/dp/0033_project_views.sql; crates/dp-rest/src/project_views.rs; frontend/src/projects/views-tab-strip.tsx |
| 5 | ✅ done | crates/dp-store-pg/migrations/dp/0035_project_primary_milestone.sql; POST /projects/{id}/adopt-milestone in project_milestones.rs |
| 4.1 | ✅ done | Amendment — per-view issue membership: crates/dp-store-pg/migrations/dp/0036_project_view_issues.sql. Makes tabs behave as containers rather than pure saved searches; the implicit All tab is unaffected. |
- Open question 1 — ordinal taxonomy config location. Still hardcoded
for
gate/priority; nodp_taxonomy_orderstable yet. Revisit when a tenant needs custom gates. - Open question 2 — shared view auth.
visibilitycolumn exists but is locked to'private'; no(project_views, *)permission tuple yet. Defer until shared views are scoped. - Open question 3 — high-cardinality
tag:<key>bucket cap. No server-side cap orOtherbucket has been added. Revisit on real data. - Open question 4 — sticky-key cleanup. No "tag-key with no data" health badge ships today.
- §9 non-goals still hold: no drag-between-buckets, no Board (Kanban) mode, no cross-project views, no nested group-by.
- Slice 6+ (drag → tag mutation): not started. Blocked on tagging.md §5.2 push pipeline as originally scoped.
Don't rebuild:
dp_projects+dp_project_issues(§17) — the project ↔ issue link is already present; the detail page loadsuseProjectIssues(projectId)and renders a flat list (project-detail-page.tsx).dp_tags+dp_tag_links(§16, tagging.md §1, §4) — kv tags (gate:g3-mvp-build,category:firmware) are a first-class concept withkind,key,valuederived columns and per-target reverse indexes.dp_milestones,dp_issue_types(tagging.md §9) — repo milestones and org issue types mirrored read-only, withdue_on DATEandgithub_node_id TEXT.dp_issues.labelsJSONB — already populated by the fetcher; every GitHub label string is observable per issue.dp_users— exists since migration0001_init.sql; safe toREFERENCES dp_users(id)for view ownership (§6.1).- Project detail header + Meta strip + Issues card are already wired (project-detail-page.tsx).
What's missing for this proposal:
- A grouping engine for the issues list (today: flat list).
- A filter chip surface above the list.
- Saved Views — named, persisted bundles of
(group_by, filter, sort)shown as a horizontal tab strip. - A Milestones strip rendering active milestones with progress + adopt.
One project = one workbench. Everything orthogonal — group, filter, sort, save — is a control on that workbench, not a separate screen. The project detail page therefore has three zones, top to bottom:
- Header — identity (name, status, repo tags, org).
- Strips — Meta (dates, counts) and Milestones (time-bearing progress).
- Workbench — Views tab strip → Toolbar (Group · Filter · Sort) → Sectioned issues list.
The whole design is two orthogonal primitives wired together:
- Group-by — choose one key; the list renders as collapsible sections bucketed by that key's values.
- Filter — additive
key:valuechips, AND-combined; narrows what group-by then buckets.
Saved Views are just (group_by, filter, sort) triples with a name.
The user's first sketch was [ALL │ Gate 1 │ Gate 2]. We rejected it.
| Concern | Fixed gate tabs | This design |
|---|---|---|
| Different taxonomies (Phase/Sprint/Workstream) | Rebuild the tab strip | Free — group-by reads tag keys live |
| 8 gates per tagging.md gate scheme | Tab strip overflows | Sections scroll; counts visible per bucket |
| "Firmware in G3" cross-cut | Second filter UI on top of tabs | One control, composable |
| Milestone progress | No place to live | Dedicated strip — time + % |
| Pinned personal views | Not supported | Saved Views = user-defined tabs |
| Future Kanban / drag | Tabs ≠ columns | Add View mode: List │ Board; Board reuses group-by as columns |
The user's "tag a tab with the parent tag id" intuition is also rejected:
hierarchy already lives in the key of a kv tag. Every tag with key
gate is implicitly grouped under "gate" — group-by is the join. Adding
parent IDs to tags couples grouping (a view concern) to identity (a
tag concern) and forecloses regrouping by another key.
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ [≡] Projects › aaa ●1 identity ◀ │
├──────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ aaa ●Active [ACX-hardware] [@NubeDev's untitled project] [⚙ Settings] │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Meta │ │
│ │ START DUE ISSUES LINKED BOARDS │ │
│ │ 01/05/2026 30/05/2026 12/47 closed (26%) 2 │ │
│ └────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ Milestones ▸ │
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌────────────┐ │
│ │ v0.3 Beta │ │ Hardware spin 2 │ │ Compliance pass │ │ + Adopt │ │
│ │ due in 6d ░░▓▓▓▓▓░ │ │ due in 21d ░▓▓░░░░░ │ │ overdue 3d ▓▓▓▓░░░░ │ │ │ │
│ │ 8/14 ★ primary │ │ 3/11 │ │ 5/12 │ │ │ │
│ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ └────────────┘ │
│ │
│ ╭─ Views ─────────────────────────────────────────────────────────────────────────────╮ │
│ │ [● All] [ Gates ] [ Firmware in G3 ] [ Blocked ] [ My queue ] [+ New] │ │
│ ╰─────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ Group by: ▾ Gate Filter: [+ Add] Sort: ▾ Updated ↓ [+ Add issues] │
│ │
│ ▼ G3 · MVP build 6 open · 2 ✓ │
│ ┌────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ OPEN NubeIO/ACX-firmware#42 bootloader OTA retry loop │ │
│ │ [category:firmware] [type:bug] [priority:high] updated 2h ago │ │
│ │ OPEN NubeIO/ACX-firmware#39 sensor calibration drift │ │
│ │ [category:firmware] [type:bug] updated 5h ago │ │
│ │ OPEN NubeIO/ACX-hardware#1 client feedback │ │
│ │ [category:hardware] [type:feature] updated 1d ago │ │
│ │ ✓ NubeIO/ACX-backend#88 expose /telemetry endpoint │ │
│ │ [category:backend] [type:feature] closed 3d ago │ │
│ └────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ▶ G4 · Client acceptance 3 open · 1 ✓ │
│ ▶ G2 · PoC 2 open · 4 ✓ │
│ ▼ No gate 1 open · 0 ✓ │
│ ┌────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ OPEN NubeIO/ACX-app#7 login screen polish │ │
│ │ [category:app] updated 4d ago │ │
│ └────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────┘
╭─ Views ─────────────────────────────────────────────────────────────────────────────╮
│ [ All ] [ Gates ] [● Firmware in G3 ] [ Blocked ] [ My queue ] [+ New] │
╰─────────────────────────────────────────────────────────────────────────────────────╯
Group by: ▾ None Filter: [gate:g3-mvp-build ×] [category:firmware ×] [+ Add] Sort: ▾ Updated ↓
Issues (2 open · 0 closed)
┌────────────────────────────────────────────────────────────────────────────────────┐
│ OPEN NubeIO/ACX-firmware#42 bootloader OTA retry loop │
│ [category:firmware] [type:bug] [priority:high] updated 2h ago │
│ OPEN NubeIO/ACX-firmware#39 sensor calibration drift │
│ [category:firmware] [type:bug] updated 5h ago │
└────────────────────────────────────────────────────────────────────────────────────┘
Answers the "all hardware blockers before G6" query from the gate scheme:
Group by: ▾ Category Filter: [gate:g6-production-ready ×] [+ Add] Sort: ▾ Updated ↓
▼ Hardware 4 open · 1 ✓
▼ Firmware 2 open · 0 ✓
▶ Backend 1 open · 3 ✓
▶ No category 0 open · 0 ✓
Same engine, different lens. No code, schema, or report changes.
Options sourced dynamically from data observable on the project's issues, so new taxonomies appear automatically:
None— flat list (current behaviour).Milestone— buckets bydp_milestones.idjoined viadp_issues.milestone_id(added in tagging.md §9).Issue type— buckets bydp_issue_types.id.Status—open/closed.- Every distinct
keyfromdp_tags WHERE kind='kv'that has at least onedp_tag_linksrow pointing at one of this project's issues → one option per key (gate,category,priority,team, …). - Sticky keys from saved views. Any
tag:<key>referenced by an active saved view (§6.1) for this project is also appended to the dropdown, even if no current issue carries that tag. Avoids the dead-end where a saved view showsGroup by: Gatebut the user can't re-pickGateafter switching away. Sticky entries are markedGate · (no current data)and resolve to an empty list with the banner described in §6.1.
Repo grouping is deferred — today a project's issue set is
effectively single-repo per SCOPE-PROJECTS.md, so
the bucket would always be a single section. Re-evaluate when
cross-repo projects become common.
Default: count desc (largest bucket first).
Exception — known ordinal taxonomies. A small config list per deployment maps a tag-key to an explicit value order. The launch list:
gate: [g1-executive-summary, g2-poc, g3-mvp-build, g4-client-acceptance,
g5-product-refinement, g6-production-ready, g7-go-to-market,
g8-scale-support]
priority: [p0, p1, p2, p3]
When group-by matches an ordinal key, buckets render in declared order regardless of count. Unknown values within an ordinal key sort after the declared ones, count-desc. This unblocks the demo screenshot (§4.1) which shows G3, G4, G2 in gate order, not count order.
A synthetic No <key> bucket is pinned last and only rendered when
non-empty after filters apply (see §5.4 below).
Filter: [+ Add] opens a two-step typeahead:
- Pick a dimension:
tag-key,milestone,issue-type,status,repo,assignee. - Pick a value from the values present on this project's issues.
Multiple chips AND-combine. Chips show × to remove.
Bucket counts are post-filter. When group_by=tag:gate and
filter=category:firmware are both set, the count next to G3 is the
number of firmware-tagged issues in G3, not the total in G3. The pre-
filter count would make the visible totals next to a collapsed section
lie about what's inside it, which is the worst failure mode for a
triage surface.
Consequences:
- Buckets with
open + closed == 0after filtering are hidden — including the syntheticNo <key>bucket. The §5.1 rule "pinned last when non-empty" already covers this; the count it checks is the post-filter count. - Sticky-from-saved-view bucket keys (§5.1) that match zero issues post-filter still surface as a single empty section with the recovery banner, so the user can see why the view is empty rather than a blank page.
Updated ↓ (default), Created ↓, Title A→Z. Priority sort is
deferred until a priority:* tag-key is established.
The toolbar state serialises to the route hash so refresh and copy-paste preserve the view. There are three shapes; precedence between them is strict:
A. clean saved view: #/projects/{id}?view={viewId}
B. dirty saved view: #/projects/{id}?view={viewId}&group=...&filter=...&sort=...
C. ad-hoc: #/projects/{id}?group=...&filter=...&sort=...
Precedence on load. If view=<id> is present:
- Hydrate the saved view's
(group, filter, sort). - If any of
group/filter/sortis also present, treat them as overrides on top of the saved view. The Views strip shows● <name> *(dirty marker), with[Save changes]/[Discard]follow-up. - If none of the override params are present, the strip shows the
clean state
● <name>.
If view=<id> is absent, the URL is interpreted as ad-hoc; the strip
shows All selected.
A view referencing a deleted/inaccessible id falls back to ad-hoc
with any present override params, plus a one-shot toast
"This view no longer exists."
Separator. Filter chips use ; as the chip separator and
: as the dim/value separator, e.g.
filter=tag:gate:g3-mvp-build;tag:category:firmware;milestone:<uuid>.
, is unsafe — tagging.md §3 grammar permits commas
inside tag values (area:auth,oauth is a legal tag name once split on
the first colon). ; is not legal in tag values nor in milestone UUIDs.
Alternative considered: repeating &filter= params. Rejected because
the whole hash is consumed by one router and repeated keys make the
dirty-vs-clean comparison in (B) harder.
One card per active milestone (state=open) on any linked repo,
sorted by due_on ASC NULLS LAST. Each card:
- name, due-relative (
due in 6d,overdue 3d,no due date) - progress bar
closed / total ★ primarychip ifdp_projects.primary_milestone_idmatches- overflow
⋯:Adopt as primary(tagging.md §9.5),Filter to milestone,Open on GitHub
Clicking the card body adds milestone:<dp_milestones.id> (the
internal UUID, not the GitHub name) to Filter and scrolls to the list.
Milestone names are not unique — the same "v0.3 Beta" can exist
in two linked repos with different due_on. The chip's rendered
label is the milestone name (plus repo short-name when ambiguous);
the chip's value is always a UUID. Closed milestones live behind a
▸ Show closed toggle at the end of the strip (matches
tagging.md §9 closed-milestone treatment).
CREATE TABLE dp_project_views (
id UUID PRIMARY KEY,
project_id UUID NOT NULL
REFERENCES dp_projects(id) ON DELETE CASCADE,
owner_user_id UUID NOT NULL
REFERENCES dp_users(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60),
-- group_by: NULL | 'milestone' | 'issue_type' | 'status'
-- | 'tag:<key>' where <key> matches dp_tags.key grammar
group_by TEXT,
-- filter_json: array of canonical filter clauses. Exactly one shape per dim:
-- {"dim":"tag", "key":"<tag-key>", "value":"<tag-value>"}
-- {"dim":"milestone", "value":"<dp_milestones.id UUID>"}
-- {"dim":"issue_type", "value":"<dp_issue_types.id UUID>"}
-- {"dim":"status", "value":"open"|"closed"}
-- {"dim":"assignee", "value":"<github-login>"}
-- Enforced by trigger fn dp_project_views_filter_check() (raises on
-- unknown dim, missing required keys, or wrong jsonb_typeof).
filter_json JSONB NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(filter_json) = 'array'),
sort TEXT NOT NULL DEFAULT 'updated_desc',
position INT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'private'
CHECK (visibility IN ('private', 'project')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (project_id, owner_user_id, name)
);
CREATE INDEX dp_project_views_project_idx
ON dp_project_views (project_id, owner_user_id, position);Notes:
visibility='project'is the upgrade path — a project-wide shared view visible to every project member. v1 ships private only; the enum value reserves the slot so we don't need a migration when shared views land. The CHECK still passes for'private'rows.positionis per(project_id, owner_user_id); reorder is a client-driven PATCH that rewrites positions in a tx.- No CASCADE on tag key changes — group-by
tag:<key>is a soft reference. If the key disappears from the project's issues, the view renders as a flat list with the (now unmatched) filter chips intact and aThis view's group key has no matching tagsbanner.
Buckets are derived at query time from dp_tags / dp_tag_links /
dp_milestones / dp_issue_types. There is no dp_groups or
dp_project_tabs — that would be the parent-tag-id mistake.
ALTER TABLE dp_projects
ADD COLUMN primary_milestone_id UUID
REFERENCES dp_milestones(id) ON DELETE SET NULL;Set/cleared by the Adopt as primary action (tagging.md
§9.5). Read-only field on ProjectDto — surfaced in the Milestones
strip's ★ primary chip and the Meta strip if non-null.
GET /projects/{id}/views -> ProjectView[] (owner_user_id = me)
POST /projects/{id}/views -> ProjectView
GET /projects/{id}/views/{view_id} -> ProjectView
PATCH /projects/{id}/views/{view_id} -> ProjectView (rename, group/filter/sort, position)
DELETE /projects/{id}/views/{view_id} -> 204
POST /projects/{id}/views/reorder -> ProjectView[] (atomic position rewrite)
reorder body is the full ordered id list for the caller's views
on this project:
{ "ordered_ids": ["<uuid>", "<uuid>", "<uuid>"] }Server validates the set equals the caller's existing view ids on this
project (no adds/removes via reorder) and rewrites position = 0..N-1
in one transaction. Returns the updated list in the new order.
POST and PATCH validate group_by against the dynamic option set
(§5.1) on the server: a tag:<key> is accepted iff <key> parses per
tagging.md §3 grammar; existence of matching tag links
is not required (views can pre-exist their data).
Existing GET /projects/{id}/issues (used by useProjectIssues) gains
optional query params:
?group_by=tag:gate
?filter=tag:gate:g3-mvp-build;tag:category:firmware;milestone:<uuid>;status:open
?sort=updated_desc
Separator is ; (see §5.4 for rationale). Each clause matches one
filter_json entry in §6.1.
Response shape: when group_by is set, the response is a flat issue
array plus a buckets sidecar:
{
"issues": [ ... ],
"buckets": [
{ "key": "g3-mvp-build", "label": "G3 · MVP build", "open": 6, "closed": 2 },
{ "key": "g4-client-acceptance", "label": "G4 · Client acceptance", "open": 3, "closed": 1 },
{ "key": null, "label": "No gate", "open": 1, "closed": 0 }
]
}The client never re-buckets — the server's count is authoritative so collapsed sections show truthful counts without paging the whole list.
GET /projects/{id}/group-by-options -> { dims: [
{ id: "milestone", label: "Milestone" },
{ id: "issue_type", label: "Issue type" },
{ id: "status", label: "Status" },
{ id: "tag:gate", label: "Gate" },
{ id: "tag:category",label: "Category" },
...
] }
Computed from the project's observable tag-keys, milestones, types,
plus sticky keys from this user's saved views (§5.1). Cached in-process
with a 60-second TTL keyed by project_id, invalidated eagerly by
tag-link writes touching this project's issues and by view
create/update/delete. The distinct-tag-keys query is non-trivial at
scale (full scan of dp_tag_links for the project's issue set), so
the request-lifetime cache from the previous draft is insufficient —
the toolbar re-renders on every filter change and would re-hit the DB
within one user session.
Land in this order; each is shippable on its own.
- New component
frontend/src/projects/milestones-strip.tsx. - New hooks:
useProjectMilestones(projectId),useAdoptMilestone(projectId). - Reads
dp_milestonesjoined to the project's linked repos. - No click behaviour. Cards render name, due-relative, progress,
primary chip. The
Filter to milestoneoverflow item is disabled until Slice 3. Avoids shipping a throwaway pseudo-filter that Slice 3 immediately replaces.
- New component
frontend/src/projects/project-workbench.tsxwraps the current Issues card. - Toolbar:
Group bydropdown only.FilterandSortare stubbed out as disabled controls so the visual shape lands first. useProjectIssuesextended withgroup_byparam; renders one<CollapsibleSection>per server bucket.- URL hash:
?group=<dim>.
<FilterChipBar>component with two-step typeahead.useProjectIssuesextended withfilterparam.- URL hash:
?filter=k:v;k:v(see §5.4). - Wires up Slice 1's
Filter to milestoneoverflow item and makes the milestone card body clickable.
dp_project_viewsmigration + REST + client hooks.- Amendment shipped: per-view issue membership via
dp_project_view_issues(migration 0036) so tabs behave as containers rather than pure saved searches. The implicitAlltab keeps the pre-existing project-level semantics. <ViewsTabStrip>component above the toolbar.- Dirty-state
*marker +[Save changes]/[Discard]follow-up. - Reorder via drag-to-reorder on the tab strip.
dp_projects.primary_milestone_idmigration + DTO field.★ primarychip +Adopt as primaryoverflow action wired to a newPOST /projects/{id}/adopt-milestoneroute.
Slices 1+2 together already deliver the screenshot the user sketched
(ALL │ Gate 1 │ Gate 2) — via Group-by Gate, not hardcoded tabs.
Slice 4 is what turns those buckets into pinnable tabs.
- No drag-between-buckets in v1. Group-by is a view operation; the underlying tag/milestone change is a separate write. Slice 6+ may add drag → tag mutation, but only after the tagging.md §5.2 push pipeline is proven.
- No Board (Kanban) mode in v1. The data model supports it (same group-by key drives columns), but the UI is deferred.
- No cross-project views.
dp_project_views.project_idis NOT NULL. A "my work across all projects" surface is a separate feature on the Workflow page, not this design. - No shared (project-wide) views in v1. Reserved by the
visibilityenum but always'private'until project membership semantics for view ownership are settled. - No nested group-by. One key at a time. Two-level grouping (Gate → Category) is not equivalent to Group=Gate + Filter=Category=X: the filter collapses the data to one category, losing the "all categories side-by-side within each gate" view that a true nested grouping would give. We accept this loss — the user can switch the outer dimension by changing Group-by, and a Board mode (deferred) will eventually give two axes (group-by = columns, sub-group = row bands). Saved Views make the swap cheap.
- Ordinal taxonomy config location. §5.1 hardcodes
gateandpriorityin a deployment config. Open: does this live inconfig.toml, adp_taxonomy_orderstable, or compiled intodp-domain? Compiled is simplest for v1; a table is needed once tenants can define their own gates. - Shared view auth.
visibility='project'needs "project member" semantics that we don't have yet — does view edit require(projects, write)or a new(project_views, *)permission? Defer until shared views are scoped. - Server vs client bucketing for
tag:<key>with very high cardinality. Ateam:<name>key with 200 distinct values produces 200 sections. Cap at 50 buckets server-side with anOtherbucket? Likely yes; revisit once real data exists. - Sticky-key cleanup. §5.1 keeps tag-keys referenced by saved views in the dropdown even when no current data matches. Open: do we surface a "View references a tag-key with no data" health badge on the Views strip, or wait for user reports?