Adds the three proposal fields (proposed_target_date, proposed_by,
proposed_at) to the cards collection with a partial index that backs
the 'pending proposals' query, plus two new ActivityAction values
(date_proposed, date_proposal_resolved) and column_changed for
return-to-scheduled.
New server actions under src/app/actions/:
- client-proposal.ts: client-manager drag creates a proposal (not
a target_date write); 404 (not 403) on cross-tenant failures and
Done-column cards, matching the rest of /api/client/*.
- owner-plan.ts: accept and dismiss clear the three proposal
fields together and emit the audit rows; returnToScheduledAction
moves a Done card back to the first working column.
Server-side Done-column guards added to:
- src/app/actions/reschedule.ts (was UI-only)
- src/app/api/client/cards/[id]/route.ts (was UI-only)
Both pass through the new writeCardWithAudit helper at
src/lib/cards/write-with-audit.ts — single source of truth for
'patch card + emit activity rows', replacing the inline copies in
the PATCH route and rescheduleCardAction.
Adds src/lib/roadmap/slot-date.ts (slot <-> target_date mapping)
consumed by Pixel's puzzle UI; named SlotId API rather than the
plan's numeric indices for self-documenting callers.
CLIENT_EDITABLE_CARD_FIELDS stays at priority + target_date per
the brief; proposals go through the dedicated action so they get
their own audit row type.
167 lines
No EOL
5.3 KiB
TypeScript
167 lines
No EOL
5.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { describeActivity } from "@/lib/activity/format";
|
|
import type { ActivityRecord } from "@/lib/pb/types";
|
|
|
|
function row(overrides: Partial<ActivityRecord>): ActivityRecord {
|
|
return {
|
|
id: "a1",
|
|
created: "2026-09-16 10:00:00.000Z",
|
|
project: "p1",
|
|
card: "c1",
|
|
actor: "u1",
|
|
action: "card_updated",
|
|
field: "",
|
|
old_value: "",
|
|
new_value: "",
|
|
...overrides,
|
|
} as ActivityRecord;
|
|
}
|
|
|
|
describe("describeActivity", () => {
|
|
it("describes a priority change in words, not codes", () => {
|
|
expect(
|
|
describeActivity(
|
|
row({ action: "priority_changed", field: "priority", old_value: "medium", new_value: "high" })
|
|
)
|
|
).toBe("changed priority from Medium to High");
|
|
});
|
|
|
|
it("describes setting a target date that had none", () => {
|
|
expect(
|
|
describeActivity(
|
|
row({ action: "date_changed", field: "target_date", old_value: "", new_value: "2026-10-01" })
|
|
)
|
|
).toBe("set the target date to 1 Oct 2026");
|
|
});
|
|
|
|
it("describes moving a target date", () => {
|
|
expect(
|
|
describeActivity(
|
|
row({
|
|
action: "date_changed",
|
|
field: "target_date",
|
|
old_value: "2026-10-01 00:00:00.000Z",
|
|
new_value: "2026-11-15",
|
|
})
|
|
)
|
|
).toBe("moved the target date from 1 Oct 2026 to 15 Nov 2026");
|
|
});
|
|
|
|
it("describes clearing a target date", () => {
|
|
expect(
|
|
describeActivity(
|
|
row({ action: "date_changed", field: "target_date", old_value: "2026-10-01", new_value: "" })
|
|
)
|
|
).toBe("cleared the target date");
|
|
});
|
|
|
|
it("describes card creation, acceptance and archiving", () => {
|
|
expect(describeActivity(row({ action: "card_created", new_value: "Add a POS system" })))
|
|
.toBe("added this card");
|
|
expect(describeActivity(row({ action: "card_accepted" }))).toBe("accepted this request");
|
|
expect(describeActivity(row({ action: "card_archived" }))).toBe("archived this card");
|
|
});
|
|
|
|
it("names the destination when a card moves", () => {
|
|
expect(describeActivity(row({ action: "card_moved", new_value: "In Progress" })))
|
|
.toBe("moved this card to In Progress");
|
|
});
|
|
|
|
it("falls back gracefully when a move has no destination recorded", () => {
|
|
expect(describeActivity(row({ action: "card_moved", new_value: "" })))
|
|
.toBe("moved this card");
|
|
});
|
|
|
|
it("describes comments and subtasks", () => {
|
|
expect(describeActivity(row({ action: "comment_added" }))).toBe("commented");
|
|
expect(
|
|
describeActivity(row({ action: "subtask_toggled", new_value: "true", field: "Wire up the till" }))
|
|
).toBe("completed “Wire up the till”");
|
|
expect(
|
|
describeActivity(row({ action: "subtask_toggled", new_value: "false", field: "Wire up the till" }))
|
|
).toBe("reopened “Wire up the till”");
|
|
});
|
|
|
|
it("describes a column move with the new column name", () => {
|
|
expect(
|
|
describeActivity(
|
|
row({ action: "column_changed", field: "column", old_value: "col_done", new_value: "Backlog" })
|
|
)
|
|
).toBe("moved this card to Backlog");
|
|
});
|
|
|
|
it("describes a column move out of a column when only the old name is known", () => {
|
|
expect(
|
|
describeActivity(
|
|
row({ action: "column_changed", field: "column", old_value: "Done", new_value: "" })
|
|
)
|
|
).toBe("moved this card out of Done");
|
|
});
|
|
|
|
it("describes a client proposal in human terms", () => {
|
|
expect(
|
|
describeActivity(
|
|
row({
|
|
action: "date_proposed",
|
|
field: "proposed_target_date",
|
|
old_value: "",
|
|
new_value: "2026-11-15",
|
|
})
|
|
)
|
|
).toBe("proposed moving this card to 15 Nov 2026");
|
|
});
|
|
|
|
it("describes an accepted proposal with its date", () => {
|
|
expect(
|
|
describeActivity(
|
|
row({
|
|
action: "date_proposal_resolved",
|
|
field: "proposed_target_date",
|
|
old_value: "2026-11-15",
|
|
new_value: "accepted",
|
|
})
|
|
)
|
|
).toBe("accepted the proposal for 15 Nov 2026");
|
|
});
|
|
|
|
it("describes a dismissed proposal with its date", () => {
|
|
expect(
|
|
describeActivity(
|
|
row({
|
|
action: "date_proposal_resolved",
|
|
field: "proposed_target_date",
|
|
old_value: "2026-11-15",
|
|
new_value: "dismissed",
|
|
})
|
|
)
|
|
).toBe("dismissed the proposal for 15 Nov 2026");
|
|
});
|
|
|
|
it("falls back gracefully when a resolved proposal has no date", () => {
|
|
expect(
|
|
describeActivity(
|
|
row({
|
|
action: "date_proposal_resolved",
|
|
field: "proposed_target_date",
|
|
old_value: "",
|
|
new_value: "accepted",
|
|
})
|
|
)
|
|
).toBe("accepted a proposal");
|
|
});
|
|
|
|
it("describes a generic update", () => {
|
|
expect(describeActivity(row({ action: "card_updated", field: "description" })))
|
|
.toBe("updated the description");
|
|
expect(describeActivity(row({ action: "card_updated", field: "title" })))
|
|
.toBe("renamed this card");
|
|
});
|
|
|
|
it("never returns an empty string for an unknown action", () => {
|
|
// Double assertion on purpose: the point is to simulate an action value
|
|
// added by a later phase that this switch has not learned yet.
|
|
const unknown = "something_new" as unknown as ActivityRecord["action"];
|
|
const result = describeActivity(row({ action: unknown }));
|
|
expect(result.length).toBeGreaterThan(0);
|
|
});
|
|
}); |