kanban-dev-webapp/docs/superpowers/plans/2026-09-19-phase-7-logged-work-entries.md
Dave 990de29683 docs(plan): Phase 7 — logged work entries implementation plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 13:47:59 +02:00

40 KiB
Raw Blame History

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 manualMinutes from 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

  1. npm run test:all passes, including the 4 new unit tests and 2 new integration tests.
  2. npm run build succeeds; the colour check introduces no new matches.
  3. The owner can log a dated, hours-declared note, optionally linked to a card, optionally polished by AI.
  4. It appears in "Recent work" and (if linked) a card's "Work done", tagged "Logged", counted toward the dashboard's hours and activity totals.
  5. Its raw-detail toggle never appears — there is nothing real to show.
  6. The methodology panel states how many hours were logged directly.
  7. A logged entry is never picked up by the AI batch summarizer.
  8. 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.