feat: Home becomes the workspace dashboard - #556
Merged
Conversation
A read service whose scope is the whole workspace rather than one project: totals, the batch to carry on with, what is waiting, and a feed derived from the timestamps that already exist. The resume target is ranked by progress rather than by recency, because there is no timestamp column on batch, annotation or annotation_job_asset — nothing records when a batch changed state or when a label was drawn. Ranking by how far through a batch is needs no schema change and claims only what the rows can support. UnitOfWork.annotation_totals is the narrow port method ProjectService.stats and JobService.project_progress both already named as the remedy for counting a project's labels one query per asset.
One read-only projection rather than a resource: no path parameters, no verbs but GET, and no allowed_actions of its own — every row points at a resource whose own wire shape already declares what may be done to it. The two feed shapes are flat rows carrying a kind rather than discriminated unions; they differ in which optional fields they fill, not in shape. openapi.json and the generated client are regenerated, not hand-edited.
The rail's Home entry stops redirecting to the project list and lands on a page that answers what is waiting across every project: the batch to carry on with, batches holding frames for review, background work that failed or is running, four workspace totals and a derived activity feed. A workspace with no projects gets an invitation instead, naming the cycle. Exactly one filled button in every state, which is how DESIGN.md states the rule and how the test asserts it — swept over the whole document, so zero filled buttons fails as loudly as two. CreateProjectDialog is exported so the first-run CTA performs the action its label promises rather than navigating to the screen that carries it.
Three scenarios asserted the redirect that no longer exists. Two invert — signing in at / stays at /, and the rail's active entry is Home rather than Projects — and the NotFound link now keeps the promise its label always made. The 'end' prop's claim is checked from a page that is not Home, which is where it can actually fail. The cycle suite creates its project from Home's first-run invitation. That is the honest route on a workspace seconds old, and it opens the same dialog the project list's button does.
DESIGN.md gains a Home section: the two states, the one-filled-button rule as it applies in all three, the section-omission rule, and why the resume CTA's label changes. The information-architecture sitemap records the route and its deep-link edges, which its own process rule requires in the same PR. docs/ui.md and docs/api.md describe GET /home as a read-only projection, and say plainly that the resume ranking is progress rather than recency and that an ingest activity row is an arrival rather than a run.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Home stops redirecting to the project list and becomes the workspace dashboard. The route
table's own comment had named the condition — there is nothing else a workspace's front page
could honestly be until a dashboard has numbers to show — and this supplies the numbers.
What shipped
Kernel. A new
SummaryServicewhose scope is the whole workspace, composing a projectionout of reads that already existed: totals, the batch to carry on with, what is waiting, and a
feed derived from timestamps that are already stored. It writes nothing and caches nothing, on
the terms
ProjectStatsandDatasetStatsalready set. One narrow port method,UnitOfWork.annotation_totals, is the methodProjectService.statsandJobService.project_progresshad both already named in their docstrings as the remedy forcounting a project's labels one query per asset — taken now because a caller finally appeared
that walks every project at once.
Wire.
GET /homereturns the page in one response. It is a projection rather than aresource: no path parameters, no verb but
GET, and noallowed_actions— every row pointsat a resource whose own shape declares what may be done to it, and a second copy here would be
the hand-mirrored table the capabilities contract forbids one layer up.
openapi.jsonand thegenerated TypeScript client are regenerated, never hand-edited.
Screen.
HomeScreeninui-core, data-only, navigation as optional callbacks. Two columnsat
1fr / 320px: the resume card, what needs attention and recent projects on the left; fourstat cards and the activity feed in the 320px aside.
The populated state
The resume card leads — a thumbnail, the project and batch,
148 / 200 annotatedin tabularfigures, and the view's one filled button. Beneath it, Needs your attention: one row per
batch holding frames awaiting review, per background job that failed, and per background job
still running. Then Recent projects, five dense rows with
assets · % annotatedand an"All projects →" link in the header. The aside carries Projects / Images / Annotations /
Releases as locale-separated tabular figures, then Activity — icon, one line, relative time,
newest first.
A section with nothing in it is not rendered. Not a placeholder, not a zero, not "nothing
here yet". The stat cards are the deliberate exception, because a count of zero is a
measurement and four cards that came and went would make the aside jump on every visit.
The first-run state
A workspace with no projects gets the whole page replaced by one invitation: "Start your first
project", a line of body, the filled Create project, and three quiet cards naming the cycle
— Ingest, Annotate, Release — with no controls on them. It is recognised by
totals.projects === 0rather than by a flag, which would be a second spelling of a fact theresponse already carries.
That CTA opens the same dialog the project list's button opens;
CreateProjectDialogisexported for it. A filled button labelled Create project that only navigated to the screen
carrying the real one would promise an action it does not perform.
ProjectsScreen's behaviouris otherwise untouched.
The finding, and the decision it forced
The dispatch specified the resume target as the most recently active
in_annotationbatch.That is not computable. There is no timestamp column on
batch, onannotation, or onannotation_job_asset— nothing records when a batch was created, when it changed state, when alabel was drawn, or when an asset's progress last moved. Deriving recency would mean a
migration, which the design exists to avoid.
Settled with Armando before implementing, and recorded on the issue: rank by progress
instead. Home offers the
in_annotationbatch with the most settled assets that still has atleast one unannotated frame — the batch you are part-way through. It needs no schema change, it
degrades correctly (one open batch is offered whatever its progress), and it claims only what
the rows can support. The accepted limit is that two batches part-way through resolve to the
further-along one rather than the more recent.
Its one visible consequence is the CTA label. With a frame left the control reads Continue
annotating and opens the editor there; with none left it reads Open batch and goes to the
gallery, because there is no frame to open and a button claiming otherwise would land somewhere
empty.
Worth noting: the project header's Annotate CTA hit the same wall from the other side a day
earlier —
BatchOutcarries no timestamp either, so "most recent" had no wire source there andthat work asked which batch instead (#544, merged as #545).
Declared deviations from the dispatch
IngestJobrecords no times at all, so the rowis the newest
Asset.ingested_atin a project and reads as the last data that arrived ratherthan as one run finishing. The endpoint, the model and
docs/api.mdall say so; the UI copydoes not overstate it.
is the highest — so there is no activation event to date.
There is no background-job detail screen in this build, and a job's payload names an ingest run
or a release, never a project — so resolving one would need a per-job-type dispatch table that
goes stale, pointing at a screen that does not show the job. The rows state what happened and
are rendered as text rather than as dead links, which is
DESIGN.md's rule for a section whoseconsuming surface does not exist yet.
ResumeTarget.job_idis non-null, after checking: approving an empty batch raisesEmptyBatch, so anin_annotationbatch always has at least one job. A nullable field that cannever be null costs every consumer a check for a case that cannot happen, so the guard lives in
the service — a job-less batch is not offered — and the wire stays total.
Verification
Every new rule was mutation-verified after committing the implementation, per the protocol: one
unconditional step per case, anchor asserted before and after, revert by recorded diff, clean
tree checked between cases. Eleven of twelve mutations turned a named test red, and all
twelve reverts landed:
>=→>)test_a_tie_goes_to_the_batch_created_latertest_a_batch_with_labeling_left_beats_a_finished_one_however_far_ahead..._furthest_through_wins,..._wins_across_projects_tooannotatedtest_a_skipped_frame_counts_as_dealt_with..._first_unannotated_one_in_batch_ordertest_a_batch_holding_frames_for_review_asks_for_attentiontest_a_queued_job_is_not_newstest_activity_is_newest_first_and_cappedtest_the_project_shortcut_is_cappedThe twelfth aborted on a harness artifact rather than running —
grep -Fccounts lines, so atwo-line anchor never reports exactly 1.
tier-invertedcovers that same site and reddenedcorrectly, so the tier is verified; the harness lesson is that a multi-line anchor needs a
different count.
One defect in my own test was caught this way and is worth recording: a setup step used
POSTwhere the progress route is
PUT, so it 405'd silently and the assertion below it was measuringa frame nobody had sent back. The status is asserted now, with the reason in a comment.
The gate, staged
Run in stages because the harness kills at ~10 minutes. Exit codes verbatim:
openapi.jsonre-exported after the fact leaves no diff, so the committed spec matches the app.The one red step, and why it is not this change
ui-core's vitest suite fails a varying handful of tests under machine load, alwaysError: Test timed out in 5000ms. Reproduced on unmodifiedmainat the merge-base(
49a3765), by me, in this environment:against this branch's worst observed run of 20 and best of 5. Four runs as the machine's load
average fell from ~90 to ~7 produced 41, 19, 11 and 5 failures — the same files each time,
fewer of their tests as the box freed up. Running those files alone passes all 75. The load was
another session, an unrelated
ctestrun and Spotlight indexing; nothing this task spawned, andno orphaned process of mine (checked by
PPID == 1).The failures are in
schemaDraft.test.tsx,screens.test.tsx,inference.test.tsx,suggestFlow.test.tsx,drawingClass.test.tsxandaddClassProvenance.test.tsx— none of themfiles this branch touches.
home.test.tsx's 17 tests have passed on every run. Filed as#555 with the measurements and two candidate fixes; it is a sibling of the Playwright flakes
#511 and #550, one suite over. CI is the arbiter here and it is green on clean runners.
Found, not fixed
docs/api.mderror-code table is behindERROR_RULES, which docs/api.md's error-code table is 19 codes behind ERROR_RULES: sync it and gate it #524 already tracks./homeadds no codes, so the table is untouched.
Flags for Armando
run offers Create project and a resumable workspace offers Continue annotating; a workspace
with projects but nothing open for annotation offers New project, on the reasoning that when
every batch is finished, starting the next piece of work is what comes next. It is the weakest
of the three and the one to revisit if it reads wrong in use — the rule is a count tested in
both directions, so zero filled buttons fails as loudly as two, which is why the slot could not
simply be left empty.
between this and the dispatch's original rule. One nullable
annotation_job_asset.touched_atwritten by the narrow progress write would give true recency without changing the endpoint or
the card. Deliberately not taken here.
IngestJobcarries times. Same shape ofgap, smaller consequence.
Closes #552