- Add "Logged" tag to manual entries in recent-activity feed
- Add "Logged" tag to manual entries in card commit history
- Hide raw technical detail block for manual entries (no diff exists)
- Replace short_sha code block with "Logged" tag in unassigned linking tray
- Fix screen-reader label to say "this logged entry" instead of "manual"
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Consolidate requireOwnerPb to ensure getAdminPb unreachable without auth check
- Both actions now never throw; all failures return { ok: false, message }
- Verify cardId belongs to projectId before creating commit
- link_source always "manual" (never "none") to prevent auto-attachment
- Gracefully handle recomputeMetrics failures (entry saved, only KPIs stale)
- Revalidate card page + unassigned tray in addition to dashboard
- Fix hours validation message to clarify 0 is not valid
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
getCurrentUser() only decoded the pb_auth cookie locally and trusted its
embedded role/client/active fields directly — the PocketBase JS SDK holds
no secret to check a JWT signature against, so a completely forged cookie
(garbage signature, role: "owner", any email) was accepted as a real
session. Verified exploitable end-to-end: a raw Cookie header against the
live app, no browser and no password, rendered the owner's home page.
This mattered most for actions writing through the superuser connection
(log-work.ts, summarise.ts, ingest.ts, and critically users.ts for account
creation) — those never route the caller's token through PocketBase's own
rule engine at all, so the app-level role check was the ONLY gate, and it
was reading an unverified claim. Actions using the caller's own token
(cards.ts, projects.ts) still made the wrong authorization DECISION from
the forged role, even though PocketBase's rule engine would have rejected
the actual data write.
Fixed by having getCurrentUser() call authRefresh(), which asks PocketBase
to confirm the token is genuine; a forged or tampered token is refused
there and treated as signed-out. Wrapped in React's cache() so the extra
round-trip happens once per request, not once per call site (several pages
call this more than once). Re-fetching the live record also means a
deactivated account (`active: false`) stops working on its very next
request instead of only being blocked at a new login.
Regression test builds the exact forged cookie used to verify this,
confirmed red against the vulnerable code and green against the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Extract prompt text into buildPolishPrompt() in polish-prompt.ts (no server-only),
matching the pattern used by batch.ts/buildBatchPrompt and narrative.ts/buildNarrativePrompt
so the prompt itself is unit testable.
- Add MAX_NOTE_LENGTH guard to truncate excessively long notes before polishing.
- Add early-return guard against empty input (non-retryable error).
- Run getProvider() and getSystemPrompt() concurrently via Promise.all().
- Document that polishNote() throws (foreground user-initiated action).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Fix KPI row "Hours invested" caption to honestly show both session-derived and manually logged hours
- Add JSDoc warnings about manualMinutes being a discriminator (do not pass 0 for git commits)
- Extract isManual predicate to single source of truth, avoid triple derivation
- Add tests for mixed session+manual in same month and manual-only shape
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Design for letting the owner record off-repo work as a commits row
tagged source=manual, reusing every existing commit render path
(recent work, card history, activity/monthly aggregation) instead of
a parallel system, with a declared-hours figure added to the total
rather than session-clustered, and a small "Logged" tag distinguishing
it from a real commit everywhere it appears.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Added .superpowers directory to .gitignore to prevent tracking of specific Pocketbase data. Updated Next.js configuration to enable standalone output for optimized Docker image builds, reducing the need for the full node_modules tree.
Uncommitted tweaks from the dashboard review pass: ROW_HEIGHT 36→40
gives row content a touch more headroom for two-line titles, BAR_HEIGHT
22→18 lets priority-coloured stripes feel deliberate rather than heavy,
MIN_WIDTH 0.06→0.10 widens the unscheduled-on-the-end fallback so a
card with only a target_date reads as a clear mark. Percent column
widened to fit three-digit figures.
parseCommitMessage emits type 'other' for unparseable subjects, so
metrics.byType can legitimately contain a row whose key is 'other'.
The fold-into-'other' code in BarList used the same string for the
synthesised bucket — two <li key="other"> siblings, React warns:
Encountered two children with the same key, 'other'.
Reserve a key '__fold__' that cannot collide with raw input and display
'other' only at render time. The visible label stays the same.
The single Recompute button conflated two ideas: re-aggregating the cached
metrics (instant, costs nothing) and pulling new commits from Forgejo
(slow, hits the external API). A user who clicked Recompute expecting
Recent Work to update was waiting on a metric that only depends on what
is already in the database.
Split the two actions:
* Refresh commits — wraps ingestProject(stopAfterKnownPages: 1), the
same path as the hourly cron, scoped to one project. Snappy because
it stops once a full page is already known.
* Recompute — unchanged behaviour. Re-aggregates the cached commits
into the project_metrics row.
Both actions revalidate the dashboard, so Recent Work, head-line KPIs
and the 'Updated just now' indicator all stay consistent within a
single click.
Replaces the row-packed bar layout with the slide-style Gantt the user
asked for: rows grouped by client, full date-range bars (start → target),
priority-coloured fills, a today marker, percent indicators, and
quarter/month header ticks. Single-day cards (no start_date set, which is
the entire existing dataset) get a MIN_WIDTH floor so they read as
substantial bars instead of zero-pixel dots.
Tray entry point and drop-zone wiring are unchanged: an empty tray
shows a quiet 'Every schedulable card is already on the timeline'
placeholder; dropping a block from the tray still calls the same
rescheduleCardAction.
When cards start carrying start_date as well as target_date the bars
will widen to the full date range automatically — the layout supports
real duration without further code.
The Timeline tab was filtering cards to !targetDate before handing
them to RoadmapBuilder. The builder then computed its dated subset
from that pre-filtered list, so the timeline always received zero
dated cards regardless of what was in the database — the timeline
always rendered the empty-state hint even when the user had cards
dated weeks ago.
The bands view (Now/Next/Later) still does its own per-view filter
above the builder, so the builder receives the full set. Pass cards
through unfiltered and let the builder split them internally — the
tray gets the !targetDate subset, the timeline gets the rest.
The chunk.reason.enqueueModel crash tracked in vercel/next.js#92362 is
Turbopack-specific — the React flight client in react-server-dom-turbopack
exposes an enqueueModel method that react-server-dom-webpack does not. Run
> client-board@0.1.0 dev:webpack
> next dev --webpack
▲ Next.js 16.2.6 (webpack)
- Local: http://localhost:3000
- Network: http://192.168.1.162:3000
- Environments: .env.local
✓ Ready in 554ms
[?25h while the upstream issue is open if the overlay
keeps catching the crash in spite of the ThemeScript / hydration fixes.
Replaces the static Timeline tab with a builder layout: an
Unscheduled tray on the left (grouped Inbox/Now/Next/Later) and the
existing timeline on the right. Drag a block from the tray onto the
timeline at the date you want it scheduled; drag a bar horizontally to
reschedule a card already on the timeline. Both paths route through
the same rescheduleCardAction so the activity log only ever shows one
date_changed entry.
TimelineView gains a drop zone (custom MIME so tray drops do not
collide with other text drags) and a vertical drop indicator that
follows the pointer while dragging. The empty-state hint replaces
the previous early return so users with only undated cards still see a
target to drag onto. The dated-bar drag-to-reschedule behaviour is
unchanged.
The previous handler called router.refresh() after every successful move
and the Board effect then adopted the new initialCards prop. Because
router.refresh() schedules the server re-render asynchronously, the new
initialCards could land AFTER setSaving(false) had already cleared busy.
The effect then ran with !busy and setCards(initialCards) reverted the
optimistic move — the card visibly snapped back to its origin column
on every single drag, even though the database had the move. The user
had to drag the card a second time to land it in the destination.
Trust the optimistic state for the lifetime of the Board instance.
The first mount still adopts the server snapshot via the useState
initializer. Concurrent moves from other clients appear on the next
page visit, which is acceptable for an owner-only kanban.
The useRefreshOnFocus hook still keeps a focus listener registered
while not busy, so a real cross-tab refresh still fires.
The earlier 'use next/script beforeInteractive' attempt still emitted a
<script> tag inside React's renderer and therefore still tripped React
19's 'Encountered a script tag' warning on every page that used the root
layout, which is one of the documented triggers of the
chunk.reason.enqueueModel flight-client crash in
vercel/next.js#92362.
ThemeScript now returns null. The first paint picks the OS preference via
a @media (prefers-color-scheme: dark) block in globals.css so no flash
occurs for users on the default. An explicit stored choice is applied
synchronously by ThemeSync (a small client component) on mount — a
trade-off the shadcn dark-mode guide accepts too.
Also fixes the .light class selector the new media-query rule introduces.
React 19's HTML serializer drops mixed text/expression children inside
<title> elements when emitting SVG markup, so the server rendered
<title></title> while the client rendered <title>2026-05-24: 0 commits</title>.
That mismatch was one of the documented triggers of the
chunk.reason.enqueueModel flight-client crash in
vercel/next.js#92362.
Pre-formatting the tooltip into a single string before passing it as the
<title>'s only child keeps server and client output identical.
Next.js 16 + React 19 reject <script> tags rendered from React
components: 'Encountered a script tag while rendering React component.
Scripts inside React components are never executed when rendering on the
client.' The error fires during RSC rendering for every page that uses
the root layout, which corrupts Turbopack's flight stream and surfaces
as 'chunk.reason.enqueueModel is not a function' on every navigation
(vercel/next.js#92362).
The dev-overlay recommendation to use <template> is misleading — that
won't execute a script. The supported App Router pattern is
<Script strategy="beforeInteractive"> from next/script, which inlines
the script in <head> before hydration. Theme behaviour is unchanged.
A click outside the Kanban sidebar flyout called setOpen(false) even when
the click was on a Link, so the deferred state update landed during the
Link navigation's RSC stream and corrupted Turbopack's flight client
(vercel/next.js#92362).
Skip the close when the click target is inside an <a> and flush the close
synchronously from the Link's own onClick so the state update completes
before navigation, not during RSC processing.
Design for grouping projects by client on the home page and in a new
sidebar Kanban flyout tree, making the dashboard the project landing
page with the board one click away, and a prototype/project kind flag
so unsolicited client-facing prototypes can live alongside committed
projects with the same permissions, just tagged differently.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MiniMax M2.x reasons inside <think> tags in the normal content field, and
that reasoning is billed against max_tokens. At 4096 the heavier batches
spent the entire budget thinking, were truncated before writing a single
answer, and stripThinking correctly removed the unclosed block — leaving an
empty response.
The signature was distinctive: batches returned 25/25 or 0/25, never
anything partial. 193 of 779 commits went unsummarised with no error
anywhere. At 16384 the same 193 completed 8/8 batches with zero failures.
The interview's question call had the same bug at 1024, where it would have
returned an empty question and silently ended the interview on the first
turn. Now 8192.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dnd-kit derives the id of its accessibility description element from an
internal counter that starts fresh on the server and is already advanced on
the client. Every sortable card rendered aria-describedby="DndDescribedBy-0"
server-side and "DndDescribedBy-3" client-side, and React reported a
hydration mismatch across the whole board.
A stable id makes both sides agree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test overwrote ai_api_key with a marker and then restored it to ""
rather than to its previous value, so every run of the integration suite
silently wiped the AI provider key. Symptom appears much later and looks
nothing like a test problem: AI features quietly stop working.
Captures the existing value first and restores it in a finally block, so a
failed assertion cannot leave the marker in place either. Deletes the
record instead when the test created it.
The suite runs against the shared instance — there is no separate test
database — so anything it touches, it must put back exactly.
feat(board): give the owner both a quick box and Plan with AI
The Phase 4 spec made the intake wizard client-facing only, reasoning that
the developer creates cards directly. That was wrong about how he wants to
work. The quick box stays the default for lines he already understands;
Plan with AI runs the same interview for work that is genuinely unformed.
Hidden entirely when no provider is configured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The effect that syncs server state re-ran whenever `busy` flipped, which it
does at the end of every drag. It then called setCards(initialCards) with
the snapshot it already had — the server had not re-rendered — discarding
the optimistic move. The card visibly returned to its original column while
the database held the correct value.
Tracks the last adopted snapshot in a ref so the effect only acts on a
genuinely new one, and calls router.refresh() after a successful move so a
new snapshot actually arrives.
Also hardens the timeline drag: the pointerup handler compared against
`dragging` state, which updates asynchronously, so it could read the
pre-drag value and return early with the drag never committing and no error
anywhere. Reads a ref instead, and clears on pointercancel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two views rather than one chart, because the data forced it: measured on
the live database, 1 of 5 live cards has a target date and none has both
dates. target_date is client-owned and optional; start_date is only set on
Inbox acceptance. A conventional Gantt would render almost empty.
So Now/Next/Later is the default and works with zero dates, and the
timeline earns its place as clients set dates. From Dave's Gantt reference
it takes the quarter header, the Today marker, project grouping with the
client's colour, and the per-row completion percentage — which maps onto
checklist progress, the one honest source for it. The faint full-width
track is load-bearing rather than decorative: it makes a sparse chart read
as unscheduled instead of broken.
Drag-to-reschedule always writes an activity row naming the owner, because
target_date belongs to the client. A developer silently moving a date the
client chose is the failure the activity log exists to prevent.
Completes the plan set for phases 1 through 5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>