40 KiB
Phase 7 — Logged Work Entries 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: Let the owner log a block of off-repo work (a date, a declared number of hours, and a message) as a row in the commits collection tagged source: "manual", so it flows through every existing commit render path and adds honestly to the dashboard's hours — distinguished from a real commit everywhere it appears by a small "Logged" tag.
Architecture: No new collection. commits gains source and manual_minutes; computeProjectMetrics stays pure and gets one more optional input field (manualMinutes), added to the total directly instead of being session-clustered. A new owner-only server action writes the row (via the superuser connection, since commits.createRule is null — this is the one collection in the app that has never had a direct user-facing write path). Rendering reuses RecentActivity and CommitHistory unmodified except for one small conditional each.
Tech Stack: Next.js 16 Server Actions · PocketBase 0.30 · Tailwind 4 · vitest
Spec: docs/superpowers/specs/2026-09-19-phase-7-logged-work-entries-design.md
Prerequisite: Phases 1–3 complete (commits collection, AI provider settings). Phase 6 is not required.
File structure
scripts/setup-remote.ts # + ensureManualEntryFields
src/
├── lib/
│ ├── pb/types.ts # + CommitSource, source, manual_minutes
│ ├── metrics/
│ │ ├── compute.ts # + manualMinutes handling
│ │ ├── compute.test.ts # + manual-entry tests
│ │ └── store.ts # + source/manual_minutes in the query + mapping
│ └── ai/
│ ├── summarise.ts # excludes source="manual" from the batch queue
│ └── polish.ts # NEW — single-shot note polishing
├── app/
│ └── actions/
│ └── log-work.ts # NEW — logWorkAction, polishNoteAction
└── components/
└── dashboard/
├── recent-activity.tsx # + "Logged" tag
├── methodology.tsx # + manual-hours line
└── log-work-panel.tsx # NEW — button + form
src/components/card/commit-history.tsx # + "Logged" tag, hides raw-detail for manual rows
src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx # renders LogWorkPanel
tests/integration/log-work.test.ts # NEW
Task 1: The source and manual_minutes fields
Files:
-
Modify:
scripts/setup-remote.ts -
Modify:
src/lib/pb/types.ts -
Step 1: Add the type
In src/lib/pb/types.ts, add beside CommitLinkSource:
export type CommitSource = "git" | "manual";
Add source and manual_minutes to CommitRecord:
export interface CommitRecord extends BaseRecord {
project: string;
sha: string;
short_sha: string;
message: string;
subject: string;
type: string;
scope: string;
author_name: string;
author_email: string;
/** Authoring time, not commit or ingest time. See the API notes. */
authored_at: string;
is_merge: boolean;
additions: number;
deletions: number;
card: string;
link_source: CommitLinkSource;
url: string;
/** Plain-English one-liner. Empty when not yet generated or generation failed. */
summary: string;
/** PROMPT_VERSION this summary was written under. */
summary_version: number;
/** Consecutive failed attempts. Capped so a poison message stops retrying. */
summary_attempts: number;
/** "git" for every ingested commit, "manual" for a logged entry. */
source: CommitSource;
/** Only meaningful when source is "manual" — the owner's declared minutes. */
manual_minutes: number;
}
- Step 2: Add the migration helper
In scripts/setup-remote.ts, add a new function beside ensureCommitSummaryFields:
/**
* Add the `source` and `manual_minutes` fields to commits, for Phase 7's
* logged work entries.
*
* Backfilled to "git" for every existing row — every commit ingested before
* this phase came from the repository, by definition.
*
* Re-runs are safe: fields are not duplicated, and backfill only touches rows
* where source is still unset.
*/
async function ensureManualEntryFields(pb: PocketBase): Promise<void> {
const commits = await pb.collections.getOne("commits");
const have = new Set(commits.fields.map((f: { name: string }) => f.name));
const additions = [
{
name: "source",
type: "select",
required: false,
maxSelect: 1,
values: ["git", "manual"],
},
{ name: "manual_minutes", type: "number", required: false, onlyInt: true },
];
const fields = [...(commits.fields as unknown[])];
let changed = false;
for (const field of additions) {
if (!have.has(field.name)) {
fields.push({ ...field, presentable: false, hidden: false, system: false });
changed = true;
}
}
if (changed) {
await pb.collections.update(commits.id, { fields });
console.log(" + commits fields: source, manual_minutes");
} else {
console.log(" = manual entry fields already present");
}
const unset = await pb.collection("commits").getFullList({
filter: 'source = ""',
fields: "id",
});
for (const commit of unset) {
await pb.collection("commits").update(commit.id, { source: "git" });
}
console.log(` = commit source backfilled (${unset.length} row(s))`);
}
- Step 3: Call it from
main()
Directly after await ensureCommitSummaryFields(pb);:
// Add the per-commit summary fields used by Phase 3b's AI one-liner job.
await ensureCommitSummaryFields(pb);
// Add the source/manual_minutes fields for Phase 7's logged work entries.
await ensureManualEntryFields(pb);
- Step 4: Run it against the live PocketBase
set -a && source .env.local && set +a && npm run setup-remote
Expected: prints + commits fields: source, manual_minutes (or = manual entry fields already present on a re-run) followed by = commit source backfilled (N row(s)), where N is the total number of existing commits.
- Step 5: Verify the build
npm run build
Expected: build succeeds.
- Step 6: Commit
git add scripts/setup-remote.ts src/lib/pb/types.ts
git commit -m "feat(commits): add source and manual_minutes fields"
Task 2: Manual minutes in computeProjectMetrics
Pure, test-first. Implements the spec's "Metrics: where the hours go" section.
Files:
-
Modify:
src/lib/metrics/compute.ts -
Modify:
src/lib/metrics/compute.test.ts -
Step 1: Write the failing tests
Append to src/lib/metrics/compute.test.ts (the file already has a commit()
helper and imports computeProjectMetrics, MetricCommit,
DEFAULT_SESSION_CONSTANTS — do not duplicate those):
describe("computeProjectMetrics — manual entries", () => {
it("adds manual minutes to the total without clustering them", () => {
// One real commit alone forms one session: 0 span + 45 pre + 20 post = 65.
// The manual entry's 180 is added flat, regardless of how close its own
// timestamp sits to the real commit's — it is never fed to the clusterer.
const m = computeProjectMetrics(
[
commit({ authoredAt: "2026-09-01T10:00:00Z" }),
commit({ manualMinutes: 180, authoredAt: "2026-09-01T10:05:00Z" }),
],
DEFAULT_SESSION_CONSTANTS
);
expect(m.estimatedMinutes).toBe(65 + 180);
expect(m.manualMinutes).toBe(180);
});
it("still counts a manual entry toward totals, activity and type", () => {
const m = computeProjectMetrics(
[commit({ manualMinutes: 60, type: "chore", scope: "", authoredAt: "2026-09-01T10:00:00Z" })],
DEFAULT_SESSION_CONSTANTS
);
expect(m.totalCommits).toBe(1);
expect(m.activeDays).toBe(1);
expect(m.byType.find((t) => t.key === "chore")?.count).toBe(1);
});
it("folds a manual entry's minutes into its own month", () => {
const m = computeProjectMetrics(
[commit({ manualMinutes: 90, authoredAt: "2026-03-15T10:00:00Z" })],
DEFAULT_SESSION_CONSTANTS
);
expect(m.monthly).toEqual([{ month: "2026-03", minutes: 90 }]);
});
it("computes identically to before when nothing is manual", () => {
const withoutField = computeProjectMetrics([commit({}), commit({})], DEFAULT_SESSION_CONSTANTS);
const withUndefined = computeProjectMetrics(
[commit({ manualMinutes: undefined }), commit({ manualMinutes: undefined })],
DEFAULT_SESSION_CONSTANTS
);
expect(withUndefined).toEqual(withoutField);
});
});
- Step 2: Run the tests to verify they fail
npm test
Expected: FAIL — m.manualMinutes is undefined (property does not exist
yet), and the monthly test fails because manual entries are not folded in.
- Step 3: Update the implementation
In src/lib/metrics/compute.ts, widen MetricCommit:
export interface MetricCommit {
type: string;
scope: string;
authoredAt: string;
isMerge: boolean;
/** Present only for a manually logged entry; minutes, never clustered. */
manualMinutes?: number;
}
Add manualMinutes: 0 to the empty object (in the ProjectMetrics shape,
alongside estimatedMinutes: 0,), and add the field to ProjectMetrics:
export interface ProjectMetrics {
totalCommits: number;
mergeCommits: number;
features: number;
fixes: number;
byType: Tally[];
byScope: Tally[];
firstCommit: string;
lastCommit: string;
calendarDays: number;
workdays: number;
activeDays: number;
activity: Record<string, number>;
estimatedMinutes: number;
/** The portion of estimatedMinutes logged directly rather than estimated
* from commit timing. */
manualMinutes: number;
sessionCount: number;
longestSessionMinutes: number;
longestSessionCommits: number;
longestSessionDay: string;
monthly: MonthlyEffort[];
constants: SessionConstants;
}
Replace the session-clustering block:
const sessions = clusterSessions(
real.map((c) => ({ authoredAt: c.authoredAt })),
constants
);
const summary = summariseSessions(sessions);
const monthlyMap = new Map<string, number>();
for (const session of sessions) {
const month = session.day.slice(0, 7);
monthlyMap.set(month, (monthlyMap.get(month) ?? 0) + session.minutes);
}
with:
// A manually logged entry is a single declared block of time, not a series
// of commits with gaps to cluster — it is excluded from the clusterer and
// its minutes are added directly instead.
const clusterable = real.filter((c) => c.manualMinutes === undefined);
const sessions = clusterSessions(
clusterable.map((c) => ({ authoredAt: c.authoredAt })),
constants
);
const summary = summariseSessions(sessions);
const manualMinutes = real.reduce((sum, c) => sum + (c.manualMinutes ?? 0), 0);
const monthlyMap = new Map<string, number>();
for (const session of sessions) {
const month = session.day.slice(0, 7);
monthlyMap.set(month, (monthlyMap.get(month) ?? 0) + session.minutes);
}
// A manual entry's minutes belong in its own month too, or the monthly
// chart would under-report a month relative to the KPI total that includes it.
for (const c of real) {
if (c.manualMinutes === undefined) continue;
const month = day(c.authoredAt).slice(0, 7);
monthlyMap.set(month, (monthlyMap.get(month) ?? 0) + c.manualMinutes);
}
Finally, in the return statement, change estimatedMinutes: summary.totalMinutes, to:
estimatedMinutes: summary.totalMinutes + manualMinutes,
manualMinutes,
(add manualMinutes, as a new line right after estimatedMinutes).
- Step 4: Run the tests to verify they pass
npm test
Expected: PASS — all compute.test.ts tests, including the 4 new ones.
- Step 5: Commit
git add src/lib/metrics/compute.ts src/lib/metrics/compute.test.ts
git commit -m "feat(metrics): fold manually logged minutes into project totals"
Task 3: Wire it into recomputeMetrics, and the methodology panel
Files:
-
Modify:
src/lib/metrics/store.ts -
Modify:
src/components/dashboard/methodology.tsx -
Step 1: Feed
manualMinutesfrom the database
In src/lib/metrics/store.ts, change the commits query and mapping inside
recomputeMetrics:
const commits = await pb.collection("commits").getFullList<CommitRecord>({
filter: `project = "${projectId}"`,
fields: "type,scope,authored_at,is_merge,source,manual_minutes",
});
const metrics = computeProjectMetrics(
commits.map((c) => ({
type: c.type ?? "other",
scope: c.scope ?? "",
authoredAt: c.authored_at,
isMerge: Boolean(c.is_merge),
manualMinutes: c.source === "manual" ? (c.manual_minutes ?? 0) : undefined,
})),
constantsFor(project)
);
- Step 2: State it in the methodology panel
In src/components/dashboard/methodology.tsx, add a paragraph after the
"Merge commits are excluded" one:
<p>
Merge commits are excluded — a merge records when a branch was
integrated, not work done.
{metrics.mergeCommits > 0 && (
<>
{" "}
<span className="tabular">{metrics.mergeCommits}</span>{" "}
{metrics.mergeCommits === 1 ? "merge was" : "merges were"} left out.
</>
)}
</p>
{metrics.manualMinutes > 0 && (
<p>
<span className="tabular">{Math.round(metrics.manualMinutes / 60)}</span>{" "}
{Math.round(metrics.manualMinutes / 60) === 1 ? "hour was" : "hours were"}{" "}
logged directly by the developer rather than estimated from commit
timing.
</p>
)}
- Step 3: Verify the build
npm run build
Expected: build succeeds.
- Step 4: Commit
git add src/lib/metrics/store.ts src/components/dashboard/methodology.tsx
git commit -m "feat(metrics): recompute reads manual entries, methodology states them"
Task 4: Keep logged entries out of the AI batch summarizer
A logged entry's message is written once, by the owner, at creation time. It
must never be picked up by the same pending-commits queue that re-summarizes
stale AI one-liners — that queue would treat summary_version: 0 as stale and
overwrite the owner's own text.
Files:
-
Modify:
src/lib/ai/summarise.ts -
Step 1: Exclude manual entries from
pendingCommits
Change the filter in pendingCommits:
return pb.collection("commits").getFullList<CommitRecord>({
filter: [
`project = "${projectId}"`,
"is_merge = false",
`source != "manual"`,
`(summary = "" || summary_version < ${PROMPT_VERSION})`,
`summary_attempts < ${MAX_ATTEMPTS}`,
].join(" && "),
sort: "-authored_at",
fields: "id,subject,type,scope,summary_attempts",
});
- Step 2: Exclude manual entries from
summaryStatus's counts
Change base inside summaryStatus:
const base = `project = "${projectId}" && is_merge = false && source != "manual"`;
- Step 3: Verify the build
npm run build
Expected: build succeeds.
- Step 4: Commit
git add src/lib/ai/summarise.ts
git commit -m "fix(ai): never queue a logged entry for AI re-summarization"
Task 5: A single-shot AI polish helper
Files:
-
Create:
src/lib/ai/polish.ts -
Step 1: Write the helper
src/lib/ai/polish.ts:
import "server-only";
import { getProvider, getSystemPrompt, AiError } from "@/lib/ai";
/** Longer than this and the model has ignored the "one or two sentences" instruction. */
const MAX_POLISHED_LENGTH = 600;
/**
* Rewrite a single owner-typed note in the same voice as a commit summary.
*
* Unlike summarise.ts's summariseProject, this is not batch- or DB-coupled —
* it is one piece of text being composed interactively in a form, so it is
* one direct provider call rather than the pending-rows pipeline built for
* re-summarizing hundreds of ingested commits at once.
*/
export async function polishNote(text: string): Promise<string> {
const provider = await getProvider();
const system = await getSystemPrompt();
const result = await provider.complete({
system,
user: `Rewrite the note below as one or two plain-English sentences for the client, in the same voice you use for commit summaries. Stay grounded in exactly what is written — do not invent detail, dates, or claims of completion beyond what the note itself says. Reply with only the rewritten text, nothing else.
Note:
${text.trim()}`,
// 16k, not a number that looks sufficient for two sentences — see the
// note in summarise.ts about MiniMax's inline reasoning being billed
// against max_tokens. A too-low cap here would silently return an
// empty string with no error, exactly as it did for commit summaries.
maxTokens: 16384,
});
const polished = result.text.trim();
if (!polished) {
throw new AiError("The assistant returned nothing usable", true);
}
return polished.slice(0, MAX_POLISHED_LENGTH);
}
- Step 2: Verify the build
npm run build
Expected: build succeeds.
- Step 3: Commit
git add src/lib/ai/polish.ts
git commit -m "feat(ai): single-shot note polishing for logged work entries"
Task 6: The server actions
Files:
-
Create:
src/app/actions/log-work.ts -
Step 1: Write the actions
src/app/actions/log-work.ts:
"use server";
import { randomUUID } from "node:crypto";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/auth/session";
import { getAdminPb } from "@/lib/pb/admin";
import { recomputeMetrics } from "@/lib/metrics/store";
import { aiConfigured } from "@/lib/ai/settings";
import { polishNote } from "@/lib/ai/polish";
import { AiError } from "@/lib/ai";
import type { CommitRecord } from "@/lib/pb/types";
const MAX_MESSAGE_LENGTH = 2000;
const MAX_HOURS = 24;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
/** See the security note at the top of src/app/actions/cards.ts. */
async function requireOwner() {
const session = await requireSession();
if (session.role !== "owner") throw new Error("Not found");
return session;
}
/**
* Log a block of off-repo work as a commits row.
*
* Writes through the superuser connection, not the owner's own token —
* `commits.createRule` is `null` (superuser-only) because every OTHER row in
* this collection comes from the ingestion pipeline, which already writes
* this way. The owner-only check above is what stands in for a collection
* rule here, exactly as it does for every client-manager write in this app.
*/
export async function logWorkAction(input: {
projectId: string;
clientSlug: string;
projectSlug: string;
date: string;
hours: number;
message: string;
cardId?: string;
}): Promise<{ ok: boolean; message?: string }> {
await requireOwner();
const message = input.message.trim().slice(0, MAX_MESSAGE_LENGTH);
if (!message) return { ok: false, message: "A message is required" };
if (!Number.isFinite(input.hours) || input.hours <= 0 || input.hours > MAX_HOURS) {
return { ok: false, message: `Hours must be between 0 and ${MAX_HOURS}` };
}
if (!ISO_DATE.test(input.date)) {
return { ok: false, message: "Invalid date" };
}
const pb = await getAdminPb();
const cardId = input.cardId ?? "";
await pb.collection("commits").create<CommitRecord>({
project: input.projectId,
// Guaranteed not to collide with a real 40-character git sha, and unique
// enough to satisfy the (project, sha) index every other commit relies on.
sha: `manual-${randomUUID()}`,
short_sha: "manual",
message,
subject: message,
type: "",
scope: "",
author_name: "",
author_email: "",
authored_at: `${input.date} 12:00:00.000Z`,
is_merge: false,
additions: 0,
deletions: 0,
card: cardId,
// Reuses the existing meaning of "manual" on link_source: a card link the
// owner chose by hand, not one the linking rules derived.
link_source: cardId ? "manual" : "none",
url: "",
summary: message,
summary_version: 0,
summary_attempts: 0,
source: "manual",
manual_minutes: Math.round(input.hours * 60),
});
// So the dashboard's KPIs reflect it on the very next load, not only after
// the owner separately clicks Recompute.
await recomputeMetrics(input.projectId);
revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/dashboard`);
return { ok: true };
}
export async function polishNoteAction(input: {
text: string;
}): Promise<{ ok: boolean; text?: string; message?: string }> {
await requireOwner();
const text = input.text.trim().slice(0, MAX_MESSAGE_LENGTH);
if (!text) return { ok: false, message: "Nothing to polish yet" };
if (!(await aiConfigured())) {
return { ok: false, message: "No AI provider is configured" };
}
try {
const polished = await polishNote(text);
return { ok: true, text: polished };
} catch (error) {
return {
ok: false,
message: error instanceof AiError ? error.message : "The assistant is unavailable",
};
}
}
- Step 2: Verify the build
npm run build
Expected: build succeeds.
- Step 3: Commit
git add src/app/actions/log-work.ts
git commit -m "feat(dashboard): logWorkAction and polishNoteAction"
Task 7: The "Logged" tag in the two commit render paths
Files:
-
Modify:
src/components/dashboard/recent-activity.tsx -
Modify:
src/components/card/commit-history.tsx -
Step 1: Tag it in the dashboard's recent-work feed
Replace src/components/dashboard/recent-activity.tsx:
import { relativeTime } from "@/lib/time/relative";
import type { CommitRecord } from "@/lib/pb/types";
export function RecentActivity({ commits }: { commits: CommitRecord[] }) {
if (commits.length === 0) return null;
return (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Recent work
</h2>
<ol className="space-y-2">
{commits.map((commit) => (
<li
key={commit.id}
className="flex items-baseline gap-3 border-b border-border-subtle pb-2 last:border-0"
>
<p className="min-w-0 flex-1 text-sm text-text-secondary">
{commit.summary || commit.subject}
</p>
{commit.source === "manual" && (
<span className="shrink-0 text-[10px] font-medium uppercase tracking-wide text-priority-medium">
Logged
</span>
)}
<span className="shrink-0 text-[11px] text-text-muted">
{relativeTime(commit.authored_at)}
</span>
</li>
))}
</ol>
</section>
);
}
- Step 2: Tag it in a card's commit history, and hide the raw-detail toggle for a manual row
In src/components/card/commit-history.tsx, change the row rendering inside
the <ol>:
<ol className="space-y-2.5">
{commits.map((commit) => (
<li key={commit.id} className="flex gap-2.5">
<span
aria-hidden
className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-border-strong"
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<p className="text-sm text-text-secondary">
{/* Fall back to the raw subject — never render nothing. */}
{commit.summary || commit.subject}
</p>
{commit.source === "manual" && (
<span className="shrink-0 text-[10px] font-medium uppercase tracking-wide text-priority-medium">
Logged
</span>
)}
</div>
<p className="mt-0.5 text-[11px] text-text-muted">
{relativeTime(commit.authored_at)}
</p>
{/* No real diff or hash sits behind a logged entry — showing one
anyway, even honestly labelled, would be the fabrication this
feature deliberately avoids. */}
{showRaw && commit.source !== "manual" && (
<p className="mt-1.5 rounded bg-surface px-2 py-1.5 font-mono text-[11px] text-text-muted">
{commit.url ? (
<a
href={commit.url}
target="_blank"
rel="noreferrer"
className="hover:text-brand"
>
{commit.short_sha}
</a>
) : (
commit.short_sha
)}{" "}
{commit.subject}
</p>
)}
</div>
</li>
))}
</ol>
The rest of the file (the showRaw state, the header with the change count
and toggle button) is unchanged.
- Step 3: Verify the build
npm run build
Expected: build succeeds.
- Step 4: Commit
git add src/components/dashboard/recent-activity.tsx src/components/card/commit-history.tsx
git commit -m "feat(dashboard): tag logged entries, hide fabricated raw detail"
Task 8: The "Log work" button and form
Files:
-
Create:
src/components/dashboard/log-work-panel.tsx -
Modify:
src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx -
Step 1: Write the panel
src/components/dashboard/log-work-panel.tsx:
"use client";
import { useState, useTransition } from "react";
import { Plus, Sparkles } from "lucide-react";
import { logWorkAction, polishNoteAction } from "@/app/actions/log-work";
import type { CardRecord } from "@/lib/pb/types";
/**
* The owner's way to put off-repo work onto the dashboard.
*
* Follows the same inline-expand pattern as NewProjectForm and OwnerAdd
* elsewhere in this app, rather than introducing a modal/dialog component
* that does not otherwise exist in the codebase.
*/
export function LogWorkPanel({
projectId,
clientSlug,
projectSlug,
cards,
assistantAvailable,
}: {
projectId: string;
clientSlug: string;
projectSlug: string;
cards: CardRecord[];
assistantAvailable: boolean;
}) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [hours, setHours] = useState("");
const [message, setMessage] = useState("");
const [cardId, setCardId] = useState("");
const [pending, startTransition] = useTransition();
const [polishing, setPolishing] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-1.5 text-xs text-text-secondary hover:bg-surface-raised"
>
<Plus size={13} strokeWidth={2} />
Log work
</button>
);
}
const field =
"w-full rounded-lg border border-border-subtle bg-panel px-3 py-2 text-sm text-text-primary outline-none focus:border-brand";
function submit(event: React.FormEvent) {
event.preventDefault();
const parsedHours = Number(hours);
if (!message.trim() || !Number.isFinite(parsedHours) || parsedHours <= 0) return;
setError(null);
startTransition(async () => {
const result = await logWorkAction({
projectId,
clientSlug,
projectSlug,
date,
hours: parsedHours,
message,
cardId: cardId || undefined,
});
if (!result.ok) {
setError(result.message ?? "Could not log that work");
return;
}
setOpen(false);
setHours("");
setMessage("");
setCardId("");
});
}
async function polish() {
if (!message.trim()) return;
setPolishing(true);
setError(null);
try {
const result = await polishNoteAction({ text: message });
if (result.ok && result.text) setMessage(result.text);
else setError(result.message ?? "Could not polish that note");
} finally {
setPolishing(false);
}
}
return (
<form
onSubmit={submit}
className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4"
>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-text-muted">Date</span>
<input
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
className={field}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted">Hours</span>
<input
type="number"
step="0.1"
min="0.1"
value={hours}
onChange={(event) => setHours(event.target.value)}
placeholder="3.5"
className={field}
/>
</label>
</div>
<label className="block space-y-1">
<span className="text-xs text-text-muted">What was worked on</span>
<textarea
rows={3}
value={message}
onChange={(event) => setMessage(event.target.value)}
className={field}
/>
</label>
{cards.length > 0 && (
<label className="block space-y-1">
<span className="text-xs text-text-muted">Link to a card (optional)</span>
<select
value={cardId}
onChange={(event) => setCardId(event.target.value)}
className={field}
>
<option value="">Not linked to a card</option>
{cards.map((card) => (
<option key={card.id} value={card.id}>
#{card.number} {card.title}
</option>
))}
</select>
</label>
)}
{error && (
<p role="alert" className="text-xs text-accent">
{error}
</p>
)}
<div className="flex items-center gap-2">
<button
type="submit"
disabled={pending || !message.trim() || !hours}
className="rounded-lg bg-brand px-3 py-2 text-sm font-medium text-brand-fg disabled:opacity-50"
>
{pending ? "Logging…" : "Log work"}
</button>
{assistantAvailable && (
<button
type="button"
onClick={polish}
disabled={polishing || !message.trim()}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-2 text-xs text-text-secondary hover:bg-surface-raised disabled:opacity-50"
>
<Sparkles size={13} strokeWidth={2} />
{polishing ? "Polishing…" : "Polish with AI"}
</button>
)}
<button
type="button"
onClick={() => setOpen(false)}
className="px-2 py-2 text-sm text-text-muted hover:text-text-secondary"
>
Cancel
</button>
</div>
</form>
);
}
- Step 2: Render it on the dashboard, owner-only
In src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx, add two imports:
import { aiConfigured } from "@/lib/ai/settings";
import { LogWorkPanel } from "@/components/dashboard/log-work-panel";
Then, right after the existing "Back to board" row (the <div className="flex items-center gap-3">...</div> block) and before the
{!metrics || metrics.totalCommits === 0 ? ( check, add:
{session.role === "owner" && (
<LogWorkPanel
projectId={board.project.id}
clientSlug={clientSlug}
projectSlug={projectSlug}
cards={board.cards}
assistantAvailable={await aiConfigured()}
/>
)}
- Step 3: Verify the build
npm run build
Expected: build succeeds.
- Step 4: Commit
git add src/components/dashboard/log-work-panel.tsx "src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx"
git commit -m "feat(dashboard): Log work button and form"
Task 9: Integration tests
Files:
-
Create:
tests/integration/log-work.test.ts -
Step 1: Write the tests
tests/integration/log-work.test.ts:
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import PocketBase from "pocketbase";
import { DEMO, adminPb, requireServices } from "./helpers";
let admin: PocketBase;
let projectId: string;
beforeAll(async () => {
await requireServices();
admin = await adminPb();
const project = await admin
.collection("projects")
.getFirstListItem(`slug = "${DEMO.projectSlug}"`);
projectId = project.id;
});
describe("logWorkAction", () => {
const created: string[] = [];
afterAll(async () => {
for (const id of created) {
await admin.collection("commits").delete(id).catch(() => {});
}
});
it("refuses a client manager", async () => {
// The action reads the session cookie via next/headers, which is not
// available outside a request in this harness — it must throw rather
// than silently succeed with no session.
const { logWorkAction } = await import("@/app/actions/log-work");
await expect(
logWorkAction({
projectId,
clientSlug: DEMO.clientSlug,
projectSlug: DEMO.projectSlug,
date: "2026-09-19",
hours: 2,
message: "Should never be written",
})
).rejects.toThrow();
const rows = await admin.collection("commits").getFullList({
filter: `project = "${projectId}" && subject = "Should never be written"`,
});
expect(rows).toHaveLength(0);
});
});
describe("computeProjectMetrics via recomputeMetrics", () => {
let commitId: string;
afterAll(async () => {
if (commitId) await admin.collection("commits").delete(commitId).catch(() => {});
});
it("adds a manual entry's declared minutes to estimatedMinutes", async () => {
const before = await admin
.collection("project_metrics")
.getFirstListItem(`project = "${projectId}"`)
.catch(() => null);
const beforeMinutes = (before?.payload as { estimatedMinutes?: number } | undefined)
?.estimatedMinutes ?? 0;
const commit = await admin.collection("commits").create({
project: projectId,
sha: `manual-test-${Date.now()}`,
short_sha: "manual",
message: "Integration test entry",
subject: "Integration test entry",
type: "",
scope: "",
authored_at: "2026-09-19 12:00:00.000Z",
is_merge: false,
additions: 0,
deletions: 0,
card: "",
link_source: "none",
url: "",
summary: "Integration test entry",
summary_version: 0,
summary_attempts: 0,
source: "manual",
manual_minutes: 90,
});
commitId = commit.id;
const { recomputeMetrics } = await import("@/lib/metrics/store");
const metrics = await recomputeMetrics(projectId);
expect(metrics.manualMinutes).toBeGreaterThanOrEqual(90);
expect(metrics.estimatedMinutes).toBeGreaterThanOrEqual(beforeMinutes + 90);
});
});
- Step 2: Run the integration suite
With npm run dev running:
set -a && source .env.local && set +a && npm run test:integration
Expected: PASS — all prior integration tests plus these 2.
- Step 3: Commit
git add tests/integration/log-work.test.ts
git commit -m "test(dashboard): logWorkAction auth and manual-minutes recompute"
Task 10: Full verification
This task is work, not a summary. Run every command and act on what it prints.
- Step 1: Run every test
set -a && source .env.local && set +a && npm run test:all
Expected: PASS. Unit tests include the 4 new compute.test.ts cases from
Task 2. Integration tests include the 2 new ones from Task 9.
- Step 2: Verify the build and the colour check
npm run build
grep -rnE "bg-(neutral|gray|slate|zinc|stone)-|text-(neutral|gray|slate|zinc|stone)-|#[0-9a-fA-F]{6}" --include="*.tsx" src/components src/app
Expected: build succeeds. The grep must print no new matches introduced by
this phase — log-work-panel.tsx, the changed lines in
recent-activity.tsx, and commit-history.tsx must not appear in its
output. (Pre-existing matches from before this phase, if any, are not this
phase's problem.)
- Step 3: Log a real entry as the owner and confirm it looks right
Sign in as the owner (owner@example.com), open a project's dashboard, click
"Log work". Fill in a date, hours (e.g. 3.5), and a message. Submit.
Expected: the KPI hours figure increases by the entry's hours (converted to minutes) without a separate click on "Recompute". The entry appears at the top of "Recent work" with a small "Logged" tag next to it. The methodology panel now states how many hours were logged directly.
- Step 4: Confirm the AI-polish path, if a provider is configured
Click "Polish with AI" after typing a rough note. Expected: the textarea's
text is replaced with a rewritten version. If no provider is configured, the
button must not appear at all (check assistantAvailable is actually
false in that case — do not just trust the code, open the page and look).
- Step 5: Confirm the card link, and the hidden raw detail
Log an entry linked to a specific card. Open that card. Expected: the entry appears in "Work done" with the same "Logged" tag, counted in the change count. Click "Show technical detail". Expected: every real commit's raw block appears as before; the logged entry's row shows no raw block at all — not even one showing "manual" as if it were a hash.
- Step 6: Confirm the client manager's view is unchanged in every way except seeing the entry
Sign in as the client manager for that project. Expected: the logged entry is visible on the dashboard and the card, tagged "Logged", with no "Log work" button visible anywhere (owner-only). Confirm they cannot call the action directly:
set -a && source .env.local && set +a && npm run test:integration -- log-work
Expected: PASS (this re-runs the auth-refusal test from Task 9).
- Step 7: Confirm a manual entry never gets AI-summarized or re-summarized
On a project with AI configured, click "Refresh commits" or trigger the AI summarization job. Expected: the logged entry's message is untouched afterward — it must still read exactly what was typed or polished at creation, not something newly generated from its (blank) type/scope.
- Step 8: Check both themes
Open a dashboard and a card with a logged entry in both light and dark. The
"Logged" tag (using text-priority-medium) must be legible against
bg-surface in both.
- Step 9: Commit any fixes
git add -A
git commit -m "fix: issues found during Phase 7 verification"
Definition of done
npm run test:allpasses, including the 4 new unit tests and 2 new integration tests.npm run buildsucceeds; the colour check introduces no new matches.- The owner can log a dated, hours-declared note, optionally linked to a card, optionally polished by AI.
- It appears in "Recent work" and (if linked) a card's "Work done", tagged "Logged", counted toward the dashboard's hours and activity totals.
- Its raw-detail toggle never appears — there is nothing real to show.
- The methodology panel states how many hours were logged directly.
- A logged entry is never picked up by the AI batch summarizer.
- A client manager can see a logged entry exactly like a real commit, and cannot create one — enforced server-side, not just hidden in the UI.
Deliberately not in this plan
- Editing or deleting a logged entry. Per the spec's non-goals — a wrong one is corrected directly in the database for now.
- A start/end time input. Explicitly decided against — the owner types a plain number of hours.
- Any mechanism making a logged entry indistinguishable from a real commit. This is the one requirement that shaped the whole design; nothing in this plan should be "simplified" away from it during implementation.