Compare commits

...

2 Commits

Author SHA1 Message Date
04366c64bc fix: restore overview workstream mode reactivity 2026-06-07 15:20:40 +02:00
911871d1f6 feat: use hub-core domains router 2026-06-07 15:17:41 +02:00
3 changed files with 50 additions and 110 deletions

View File

@@ -1,8 +1,5 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import noload
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
@@ -12,90 +9,46 @@ from api.models.managed_repo import ManagedRepo
from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic
from api.models.workstream import Workstream
from api.schemas.domain import DomainCreate, DomainDetail, DomainRead, DomainRename, DomainUpdate, RepoStub
router = APIRouter(prefix="/domains", tags=["domains"])
from api.schemas.domain import (
DomainCreate,
DomainDetail,
DomainRead,
DomainRename,
DomainUpdate,
RepoStub,
)
from hub_core.routers.domains import create_domains_router
@router.get("/", response_model=list[DomainRead])
async def list_domains(
response: Response,
status: str | None = Query(None, description="active | archived | all"),
session: AsyncSession = Depends(get_session),
) -> list[Domain]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(Domain).options(
noload(Domain.topics),
noload(Domain.repos),
noload(Domain.goals),
).order_by(Domain.name)
if status and status != "all":
q = q.where(Domain.status == status)
elif status is None:
q = q.where(Domain.status == "active")
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=DomainRead, status_code=status.HTTP_201_CREATED)
async def create_domain(
body: DomainCreate,
session: AsyncSession = Depends(get_session),
) -> Domain:
existing = await session.execute(select(Domain).where(Domain.slug == body.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Domain slug '{body.slug}' already exists")
domain = Domain(slug=body.slug, name=body.name, description=body.description)
session.add(domain)
await session.commit()
await session.refresh(domain)
return domain
@router.get("/{slug}", response_model=DomainDetail)
async def get_domain(
slug: str,
session: AsyncSession = Depends(get_session),
) -> DomainDetail:
domain = await _get_domain_by_slug(slug, session)
# Count topics
async def _build_domain_detail(domain: Domain, session: AsyncSession) -> DomainDetail:
topic_count_row = await session.execute(
select(func.count()).select_from(Topic).where(Topic.domain_id == domain.id)
)
topic_count = topic_count_row.scalar_one()
# Count active workstreams (via topics)
topic_ids_row = await session.execute(
select(Topic.id).where(Topic.domain_id == domain.id)
)
topic_ids = [r[0] for r in topic_ids_row.all()]
topic_ids_row = await session.execute(select(Topic.id).where(Topic.domain_id == domain.id))
topic_ids = [row[0] for row in topic_ids_row.all()]
ws_count = 0
workstream_count = 0
if topic_ids:
ws_count_row = await session.execute(
workstream_count_row = await session.execute(
select(func.count()).select_from(Workstream)
.where(Workstream.topic_id.in_(topic_ids))
.where(Workstream.status == "active")
)
ws_count = ws_count_row.scalar_one()
workstream_count = workstream_count_row.scalar_one()
# Count EPs and TDs
ep_count_row = await session.execute(
select(func.count()).select_from(ExtensionPoint)
.where(ExtensionPoint.domain_id == domain.id)
)
ep_count = ep_count_row.scalar_one()
td_count_row = await session.execute(
select(func.count()).select_from(TechnicalDebt)
.where(TechnicalDebt.domain_id == domain.id)
)
td_count = td_count_row.scalar_one()
# Repos
repos_row = await session.execute(
select(ManagedRepo).where(ManagedRepo.domain_id == domain.id)
select(ManagedRepo)
.where(ManagedRepo.domain_id == domain.id)
.where(ManagedRepo.status == "active")
.order_by(ManagedRepo.name)
)
@@ -110,43 +63,14 @@ async def get_domain(
created_at=domain.created_at,
updated_at=domain.updated_at,
topic_count=topic_count,
workstream_count=ws_count,
ep_count=ep_count,
td_count=td_count,
repos=[RepoStub.model_validate(r) for r in repos],
workstream_count=workstream_count,
ep_count=ep_count_row.scalar_one(),
td_count=td_count_row.scalar_one(),
repos=[RepoStub.model_validate(repo) for repo in repos],
)
@router.patch("/{slug}/rename", response_model=DomainRead)
async def rename_domain(
slug: str,
body: DomainRename,
session: AsyncSession = Depends(get_session),
) -> Domain:
domain = await _get_domain_by_slug(slug, session)
if body.new_slug != slug:
conflict = await session.execute(select(Domain).where(Domain.slug == body.new_slug))
if conflict.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Slug '{body.new_slug}' already taken")
old_slug = domain.slug
domain.slug = body.new_slug
domain.name = body.new_name
await session.commit()
await session.refresh(domain)
return domain
@router.patch("/{slug}/archive", response_model=DomainRead)
async def archive_domain(
slug: str,
session: AsyncSession = Depends(get_session),
) -> Domain:
domain = await _get_domain_by_slug(slug, session)
# Reject if any active topics exist for this domain
async def _reject_archive_with_active_topics(domain: Domain, session: AsyncSession) -> None:
active_topics = await session.execute(
select(func.count()).select_from(Topic)
.where(Topic.domain_id == domain.id)
@@ -158,15 +82,21 @@ async def archive_domain(
detail="Cannot archive domain with active topics. Archive or reassign topics first.",
)
domain.status = "archived"
await session.commit()
await session.refresh(domain)
return domain
router = create_domains_router(
get_session,
domain_model=Domain,
repo_model=ManagedRepo,
domain_create_schema=DomainCreate,
domain_detail_schema=DomainDetail,
domain_read_schema=DomainRead,
domain_rename_schema=DomainRename,
domain_update_schema=DomainUpdate,
repo_stub_schema=RepoStub,
detail_builder=_build_domain_detail,
before_archive=_reject_archive_with_active_topics,
list_noload_fields=("topics", "repos", "goals"),
include_update_route=False,
)
async def _get_domain_by_slug(slug: str, session: AsyncSession) -> Domain:
result = await session.execute(select(Domain).where(Domain.slug == slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain
__all__ = ["router"]

View File

@@ -252,16 +252,20 @@ _modeSelect.value = _savedChartMode;
_modeSelect.addEventListener("input", () => {
globalThis.__stateHubOverviewChartMode = _modeSelect.value;
});
_modeSelect.addEventListener("change", () => {
globalThis.__stateHubOverviewChartMode = _modeSelect.value;
});
// view() is the idiomatic Observable Framework reactive input:
// it displays the element AND returns a reactive value that re-runs dependent blocks.
const _chartMode = _modeValue(view(_modeSelect));
const _chartWsFiltered = _workstreamsForMode(_chartMode, wsAll);
```
```js
import * as Plot from "npm:@observablehq/plot";
const _chartWsFiltered = _workstreamsForMode(_chartMode, wsAll);
// Sort by domain, then repository, then most recently updated workstream.
// The axis labels show each domain/repo group once.
const chartWs = [..._chartWsFiltered].sort((a, b) => {

View File

@@ -239,6 +239,12 @@ accurate workstream counts for all mode groups.
- `npm run test` in `dashboard/` passed 11 tests.
- `npm run build` in `dashboard/` passed: Observable built 61 pages and
validated 49 links.
- 2026-06-07 follow-up: Fixed a selector reactivity regression where
`_chartWsFiltered` was computed in the same cell as `view(_modeSelect)`.
Moving `_chartWsFiltered = _workstreamsForMode(_chartMode, wsAll)` into the
downstream chart cell restored filtering when operators choose a mode other
than `active`. Added a `change` listener alongside `input` for persisted mode
selection. `npm run test` and `npm run build` both passed after the fix.
- Browser click-through remains pending because the Codex in-app browser bridge
failed to start in this session with a Windows sandbox setup failure, and no
local Playwright/Puppeteer package is installed for a headless fallback.