Q160: a goal's pace is measured against its target date, and claimed only when it has one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
459dcf0ea2
commit
c9117fe46d
18 files changed
+172
-24
No files matched your search
@@ -54,7 +54,8 @@ export const organizationContracts = {
|
||||
]),
|
||||
updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType<C.UpdateTaskStatusInput>,
|
||||
runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.RunTaskInput>,
|
||||
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType<C.CreateGoalInput>,
|
||||
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId, dueAt: z.string().nullable().optional() }) satisfies z.ZodType<C.CreateGoalInput>,
|
||||
setGoalDueDate: z.strictObject({ orgId: id, goalId: id, dueAt: z.string().nullable() }) satisfies z.ZodType<C.SetGoalDueDateInput>,
|
||||
resumeGoal: z.strictObject({ orgId: id, goalId: id }) satisfies z.ZodType<C.ResumeGoalInput>,
|
||||
createProject: z.strictObject({
|
||||
orgId: id, goalId: nullableId, name: z.string(), description: z.string(), leadAgentId: nullableId,
|
||||
|
||||
@@ -183,6 +183,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
}
|
||||
});
|
||||
handle("createGoal", (input) => mutate(() => organization.createGoal(input)));
|
||||
handle("setGoalDueDate", (input) => mutate(() => organization.setGoalDueDate(input)));
|
||||
handle("createProject", (input) => mutate(() => organization.createProject(input)));
|
||||
handle("createWorkspace", (input) => mutate(() => organization.createWorkspace(input)));
|
||||
handle("createDocument", (input) => mutate(() => documents.create(input)));
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// When a goal is planned to be reached; without it, progress is reported but no pace is claimed.
|
||||
export const goalDueDateSchema = `
|
||||
alter table goals add column due_at text;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (102, 'goal_due_date', datetime('now'));
|
||||
`;
|
||||
@@ -100,6 +100,7 @@ import { dropAuditReplaysSchema } from "./migrations/098-drop-audit-replays";
|
||||
import { dropRollbackRecordsSchema } from "./migrations/099-drop-rollback-records";
|
||||
import { chatReplyRequestsSchema } from "./migrations/100-chat-reply-requests";
|
||||
import { taskBlockedSchema } from "./migrations/101-task-blocked";
|
||||
import { goalDueDateSchema } from "./migrations/102-goal-due-date";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -209,6 +210,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 99, name: "drop_rollback_records", sql: dropRollbackRecordsSchema },
|
||||
{ version: 100, name: "chat_reply_requests", sql: chatReplyRequestsSchema },
|
||||
{ version: 101, name: "task_blocked", sql: taskBlockedSchema },
|
||||
{ version: 102, name: "goal_due_date", sql: goalDueDateSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { TaskDeleteImpact } from "../../shared/domain";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, SetTaskBlockedInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
|
||||
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, SetGoalDueDateInput, SetTaskBlockedInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff";
|
||||
import { PolicyService } from "./policy-service";
|
||||
@@ -359,9 +359,9 @@ export class OrganizationService {
|
||||
const now = new Date().toISOString();
|
||||
if (input.ownerAgentId) this.verifyOptionalOrgEntity("agents", input.ownerAgentId, input.orgId, "Owner agent");
|
||||
this.db.prepare(`
|
||||
insert into goals(id, org_id, title, description, status, owner_agent_id, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, now, now);
|
||||
insert into goals(id, org_id, title, description, status, owner_agent_id, due_at, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, validDueAt(input.dueAt ?? null), now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "goal",
|
||||
@@ -477,6 +477,14 @@ export class OrganizationService {
|
||||
return this.tasks.deleteImpact(orgId, taskId);
|
||||
}
|
||||
|
||||
/** Sets or clears when a goal is planned to be reached. */
|
||||
setGoalDueDate(input: SetGoalDueDateInput): void {
|
||||
const result = this.db.prepare("update goals set due_at = ?, updated_at = ? where id = ? and org_id = ?")
|
||||
.run(validDueAt(input.dueAt), new Date().toISOString(), input.goalId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Goal was not found.");
|
||||
this.audit.record({ orgId: input.orgId, entityType: "goal", entityId: input.goalId, actorType: "employee", actorId: null, action: "due_date_set", payload: { dueAt: input.dueAt } });
|
||||
}
|
||||
|
||||
setTaskBlocked(input: SetTaskBlockedInput): void {
|
||||
this.tasks.setBlocked(input);
|
||||
}
|
||||
@@ -493,3 +501,13 @@ export class OrganizationService {
|
||||
return this.tasks.agentSetStatus(input);
|
||||
}
|
||||
}
|
||||
|
||||
/** A target date is an exact ISO UTC time on a real calendar day; anything else is refused rather than stored shifted. */
|
||||
function validDueAt(dueAt: string | null): string | null {
|
||||
if (dueAt === null) return null;
|
||||
const exact = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(dueAt);
|
||||
const time = new Date(dueAt);
|
||||
const normalized = dueAt.length === 20 ? dueAt.replace("Z", ".000Z") : dueAt;
|
||||
if (!exact || Number.isNaN(time.getTime()) || time.toISOString() !== normalized) throw new Error(`"${dueAt}" is not a date.`);
|
||||
return normalized;
|
||||
}
|
||||
@@ -94,6 +94,7 @@ const api: DesktopApi = {
|
||||
taskDeleteImpact: (input) => invoke(IPC_CHANNELS.taskDeleteImpact, input),
|
||||
cancelQueuedRun: (input) => invoke(IPC_CHANNELS.cancelQueuedRun, input),
|
||||
runHistory: (input) => invoke(IPC_CHANNELS.runHistory, input),
|
||||
setGoalDueDate: (input) => invoke(IPC_CHANNELS.setGoalDueDate, input),
|
||||
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
|
||||
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
|
||||
search: (input) => invoke(IPC_CHANNELS.search, input),
|
||||
|
||||
@@ -53,6 +53,9 @@ export const zh: Record<string, string> = {
|
||||
"Show earlier decisions": "显示更早的决定",
|
||||
"Cancel run": "取消运行",
|
||||
"Show older runs": "显示更早的运行",
|
||||
"Target date": "目标日期",
|
||||
"no target date": "未设目标日期",
|
||||
"overdue": "已逾期",
|
||||
"These are all the runs.": "已显示全部运行。",
|
||||
"Running…": "运行中…",
|
||||
"A cancelled session can start again on the next heartbeat; block the task to keep it stopped.": "取消的会话可能在下一次心跳时再次开始;如需保持停止,请阻塞该任务。",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { goalProgress, type GoalPace } from "./home/goal-progress";
|
||||
import { newestRuns } from "./runs/newest-runs";
|
||||
import { isBusinessTask } from "./work/task-helpers";
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
@@ -47,7 +48,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
||||
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
|
||||
const totalBudgetCents = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
|
||||
const primaryGoal = goals.find((goal) => goal.status === "active") ?? goals[0] ?? null;
|
||||
const goalProgress = computeGoalProgress(primaryGoal, businessTasks);
|
||||
const progress = primaryGoal ? goalProgress(primaryGoal, businessTasks, new Date()) : null;
|
||||
const firstName = employee.displayName.split(/\s+/)[0] ?? employee.displayName;
|
||||
const greeting = greetingFor(new Date().getHours());
|
||||
|
||||
@@ -60,7 +61,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
||||
<p className="page-sub">{renderSummary(pendingApprovals.length, agents.length)}</p>
|
||||
|
||||
<div style={STRIP}>
|
||||
<GoalCard goal={primaryGoal} progress={goalProgress} organizationName={organization.name} actions={actions} />
|
||||
<GoalCard goal={primaryGoal} progress={progress} organizationName={organization.name} actions={actions} />
|
||||
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
|
||||
<WaitingCard
|
||||
approvals={pendingApprovals}
|
||||
@@ -95,7 +96,7 @@ function GoalCard({
|
||||
actions,
|
||||
}: {
|
||||
goal: GoalRecord | null;
|
||||
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
|
||||
progress: { percent: number; pace: GoalPace | null } | null;
|
||||
organizationName: string;
|
||||
actions: PageProps["actions"];
|
||||
}) {
|
||||
@@ -114,7 +115,8 @@ function GoalCard({
|
||||
</div>
|
||||
<div className="row" style={{ alignItems: "baseline", gap: 8, marginTop: 16 }}>
|
||||
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{progress ? `${percent}%` : "—"}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress ? statusLabel(progress.status) : t("no work yet")}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress?.pace ? statusLabel(progress.pace) : t("no target date")}</span>
|
||||
{goal && <TargetDate goal={goal} actions={actions} />}
|
||||
</div>
|
||||
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
|
||||
<div style={{ width: `${percent}%`, height: "100%", background: "var(--text-1)", transition: "width var(--motion-slow) var(--ease-out)" }} />
|
||||
@@ -299,20 +301,10 @@ function RecentMessagesCard({
|
||||
);
|
||||
}
|
||||
|
||||
function computeGoalProgress(goal: GoalRecord | null, tasks: TaskRecord[]): { percent: number; status: "ahead" | "on_track" | "behind" } | null {
|
||||
if (!goal) return null;
|
||||
const scoped = tasks.filter((task) => task.goalId === goal.id);
|
||||
if (scoped.length === 0) return { percent: 0, status: "on_track" };
|
||||
const done = scoped.filter((task) => task.status === "done").length;
|
||||
const percent = Math.round((done / scoped.length) * 100);
|
||||
if (percent >= 75) return { percent, status: "ahead" };
|
||||
if (percent >= 30) return { percent, status: "on_track" };
|
||||
return { percent, status: "behind" };
|
||||
}
|
||||
|
||||
function statusLabel(status: "ahead" | "on_track" | "behind"): string {
|
||||
if (status === "ahead") return t("ahead");
|
||||
if (status === "behind") return t("behind");
|
||||
function statusLabel(pace: GoalPace): string {
|
||||
if (pace === "ahead") return t("ahead");
|
||||
if (pace === "behind") return t("behind");
|
||||
if (pace === "overdue") return t("overdue");
|
||||
return t("on track");
|
||||
}
|
||||
|
||||
@@ -367,3 +359,28 @@ function runStateLabel(state: string): string {
|
||||
if (state === "stopped") return t("Stopped");
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Sets or clears when the goal should be reached, which is what its pace is measured against. */
|
||||
function TargetDate({ goal, actions }: { goal: GoalRecord; actions: PageProps["actions"] }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const value = goal.dueAt ? localDate(new Date(goal.dueAt)) : "";
|
||||
const change = (date: string) => {
|
||||
setError(null);
|
||||
// A date means the end of that day where the operator is.
|
||||
const dueAt = date ? new Date(`${date}T23:59:59`).toISOString() : null;
|
||||
actions.run(() => window.agentCompany.setGoalDueDate({ orgId: goal.orgId, goalId: goal.id, dueAt }))
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)));
|
||||
};
|
||||
return (
|
||||
<span style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<input className="input" type="date" aria-label={t("Target date")} value={value} onChange={(event) => change(event.target.value)} style={{ height: 26, padding: "0 6px", fontSize: 12 }} />
|
||||
{error && <span className="form-error" role="alert">{error}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The calendar day of a time where the operator is, as a date input expects it. */
|
||||
function localDate(time: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())}`;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { GoalRecord, TaskRecord } from "../../../../shared/domain";
|
||||
|
||||
/** How completion compares with the time elapsed toward the goal's target date. */
|
||||
export type GoalPace = "ahead" | "on_track" | "behind" | "overdue";
|
||||
|
||||
/** Within this share of the plan either way, a goal is on track. */
|
||||
const ON_TRACK_BAND = 0.1;
|
||||
|
||||
/** A goal's completion, and its pace only when it has a target date to measure against. */
|
||||
export function goalProgress(goal: GoalRecord, tasks: TaskRecord[], now: Date): { percent: number; pace: GoalPace | null } {
|
||||
const scoped = tasks.filter((task) => task.goalId === goal.id);
|
||||
const done = scoped.filter((task) => task.status === "done").length;
|
||||
const percent = scoped.length === 0 ? 0 : Math.round((done / scoped.length) * 100);
|
||||
if (!goal.dueAt) return { percent, pace: null };
|
||||
const start = new Date(goal.createdAt).getTime();
|
||||
const due = new Date(goal.dueAt).getTime();
|
||||
// Counted, not rounded: 200 of 201 done is still unfinished past the date.
|
||||
if (now.getTime() > due && (scoped.length === 0 || done < scoped.length)) return { percent, pace: "overdue" };
|
||||
const expected = due <= start ? 1 : Math.min(1, Math.max(0, (now.getTime() - start) / (due - start)));
|
||||
const lead = percent / 100 - expected;
|
||||
return { percent, pace: lead >= ON_TRACK_BAND ? "ahead" : lead >= -ON_TRACK_BAND ? "on_track" : "behind" };
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export function GoalDialog({
|
||||
title: text(form, "title"),
|
||||
description: text(form, "description"),
|
||||
ownerAgentId: optional(form, "ownerAgentId"),
|
||||
dueAt: text(form, "dueDate") ? new Date(`${text(form, "dueDate")}T23:59:59`).toISOString() : null,
|
||||
})).then((result) => { if (result) onClose(); });
|
||||
};
|
||||
|
||||
@@ -41,6 +42,7 @@ export function GoalDialog({
|
||||
<Dialog title={t("New goal")} open={open} submitLabel={t("Create")} onClose={onClose} onSubmit={submit}>
|
||||
<Field label={t("Title")}><input className="input" name="title" required /></Field>
|
||||
<Field label={t("Description")}><textarea className="input" name="description" required /></Field>
|
||||
<Field label={t("Target date")}><input className="input" type="date" name="dueDate" /></Field>
|
||||
<Field label={t("Owner agent")}>
|
||||
<select className="input" name="ownerAgentId" defaultValue="">
|
||||
<option value="">{t("Unassigned")}</option>
|
||||
|
||||
@@ -140,6 +140,15 @@ export interface CreateGoalInput {
|
||||
title: string;
|
||||
description: string;
|
||||
ownerAgentId: string | null;
|
||||
/** When the goal is planned to be reached; omitted means no target date. */
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
export interface SetGoalDueDateInput {
|
||||
orgId: string;
|
||||
goalId: string;
|
||||
/** ISO time the goal is planned to be reached, or null to clear it. */
|
||||
dueAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreateProjectInput {
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
DeleteAutomationRuleInput,
|
||||
DeleteDocumentInput,
|
||||
DeleteTaskInput,
|
||||
SetGoalDueDateInput,
|
||||
SetTaskBlockedInput,
|
||||
DeleteKnowledgeEntryInput,
|
||||
ResumeGoalInput,
|
||||
@@ -206,6 +207,7 @@ export interface DesktopApi {
|
||||
/** What deleting a task would remove, keep and close. */
|
||||
taskDeleteImpact(input: { orgId: string; taskId: string }): Promise<DesktopResult<TaskDeleteImpact>>;
|
||||
/** Cancels a run that has not started yet. */
|
||||
setGoalDueDate(input: SetGoalDueDateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
cancelQueuedRun(input: { orgId: string; runId: string }): Promise<DesktopResult<AppSnapshot>>;
|
||||
/** Runs older than a loaded one, for history beyond the snapshot's recent runs. */
|
||||
runHistory(input: { orgId: string; beforeRunId: string | null } & ({ limit: number } | { from: RunCursor })): Promise<DesktopResult<RunHistoryPage>>;
|
||||
|
||||
@@ -172,6 +172,8 @@ export interface GoalRecord {
|
||||
ownerAgentId: string | null;
|
||||
/** Set when the goal's review loop stopped without a decision. */
|
||||
stalledReason: string | null;
|
||||
/** When the goal is planned to be reached; null when no target date was set. */
|
||||
dueAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export const IPC_CHANNELS = {
|
||||
taskDeleteImpact: "agentcompany:task-delete-impact",
|
||||
cancelQueuedRun: "agentcompany:cancel-queued-run",
|
||||
runHistory: "agentcompany:run-history",
|
||||
setGoalDueDate: "agentcompany:set-goal-due-date",
|
||||
revokeConnector: "agentcompany:revoke-connector",
|
||||
search: "agentcompany:search",
|
||||
rendererEvent: "agentcompany:renderer-event",
|
||||
|
||||
@@ -72,6 +72,13 @@ test("the pinned CEO conversation is the Messages front door", async () => {
|
||||
// The message itself says where the reply stands.
|
||||
await expect(chatLog.locator(".chat-message", { hasText: "How are we tracking against the goal?" }).first()).toContainText("CEO will reply; queued.");
|
||||
|
||||
// A goal's pace is measured against its target date; without one, no pace is claimed.
|
||||
await page.getByRole("button", { name: "Home" }).click();
|
||||
await expect(page.getByText("no target date")).toBeVisible();
|
||||
await page.getByLabel("Target date").fill("2099-12-31");
|
||||
await expect(page.getByText("on track", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Target date")).toHaveValue("2099-12-31");
|
||||
|
||||
// From a task, its goal, run and back are one click each.
|
||||
await page.getByRole("button", { name: "Work" }).click();
|
||||
// The CEO's reply to the founder is a chat vehicle, not work: it stays off the board.
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
|
||||
describe("a goal's target date", () => {
|
||||
test("is set at creation or later, cleared with null, and refused when it is not a date", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-goal-due-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let goalId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
goalId = organization.createGoal({ orgId, title: "Launch", description: "d", ownerAgentId: null, dueAt: "2026-12-31T23:59:59.000Z" });
|
||||
});
|
||||
const dueAt = () => (connection.db.prepare("select due_at from goals where id = ?").get(goalId) as { due_at: string | null }).due_at;
|
||||
expect(dueAt()).toBe("2026-12-31T23:59:59.000Z");
|
||||
organization.setGoalDueDate({ orgId, goalId, dueAt: null });
|
||||
expect(dueAt()).toBeNull();
|
||||
expect(() => organization.setGoalDueDate({ orgId, goalId, dueAt: "soon" })).toThrow('"soon" is not a date.');
|
||||
expect(() => organization.setGoalDueDate({ orgId, goalId, dueAt: "2026-02-30T23:59:59.000Z" })).toThrow('"2026-02-30T23:59:59.000Z" is not a date.');
|
||||
organization.setGoalDueDate({ orgId, goalId, dueAt: "2026-02-28T23:59:59Z" });
|
||||
expect(dueAt()).toBe("2026-02-28T23:59:59.000Z");
|
||||
expect(() => organization.setGoalDueDate({ orgId, goalId: "gol_missing", dueAt: null })).toThrow("Goal was not found.");
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { goalProgress } from "../../src/renderer/src/pages/home/goal-progress";
|
||||
import type { GoalRecord, TaskRecord } from "../../src/shared/domain";
|
||||
|
||||
const goal = (dueAt: string | null) => ({ id: "gol_1", createdAt: "2026-01-01T00:00:00.000Z", dueAt }) as GoalRecord;
|
||||
const tasks = (done: number, total: number) => Array.from({ length: total }, (_, index) => ({ goalId: "gol_1", status: index < done ? "done" : "in_progress" })) as TaskRecord[];
|
||||
|
||||
describe("goal progress", () => {
|
||||
test("without a target date it reports completion only, never a pace", () => {
|
||||
expect(goalProgress(goal(null), tasks(1, 4), new Date("2026-01-10T00:00:00.000Z"))).toEqual({ percent: 25, pace: null });
|
||||
});
|
||||
|
||||
test("with a target date the pace compares completion with the time elapsed toward it", () => {
|
||||
const due = goal("2026-01-11T00:00:00.000Z");
|
||||
const day = (n: number) => new Date(`2026-01-${String(n).padStart(2, "0")}T00:00:00.000Z`);
|
||||
expect(goalProgress(due, tasks(1, 4), day(6))).toEqual({ percent: 25, pace: "behind" });
|
||||
expect(goalProgress(due, tasks(2, 4), day(6))).toEqual({ percent: 50, pace: "on_track" });
|
||||
expect(goalProgress(due, tasks(3, 4), day(6))).toEqual({ percent: 75, pace: "ahead" });
|
||||
expect(goalProgress(due, tasks(3, 4), day(12))).toEqual({ percent: 75, pace: "overdue" });
|
||||
expect(goalProgress(due, tasks(4, 4), day(12))).toEqual({ percent: 100, pace: "on_track" });
|
||||
// Counted, not rounded: one unfinished task of many is still overdue.
|
||||
expect(goalProgress(due, tasks(200, 201), day(12))).toEqual({ percent: 100, pace: "overdue" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user