diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts index 21b1afc0..3b0108a0 100644 --- a/src/main/app/ipc-contracts/organization.ts +++ b/src/main/app/ipc-contracts/organization.ts @@ -54,7 +54,8 @@ export const organizationContracts = { ]), updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType, runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, - createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType, + createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId, dueAt: z.string().nullable().optional() }) satisfies z.ZodType, + setGoalDueDate: z.strictObject({ orgId: id, goalId: id, dueAt: z.string().nullable() }) satisfies z.ZodType, resumeGoal: z.strictObject({ orgId: id, goalId: id }) satisfies z.ZodType, createProject: z.strictObject({ orgId: id, goalId: nullableId, name: z.string(), description: z.string(), leadAgentId: nullableId, diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 389d12df..4651c4a2 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -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))); diff --git a/src/main/database/migrations/102-goal-due-date.ts b/src/main/database/migrations/102-goal-due-date.ts new file mode 100644 index 00000000..07452129 --- /dev/null +++ b/src/main/database/migrations/102-goal-due-date.ts @@ -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')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 00a70e3d..d5195d48 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -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; diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 30d5df9e..6f8187be 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -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; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index fe3cfbec..1f6f2163 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 3f09474c..5182b8c1 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -53,6 +53,9 @@ export const zh: Record = { "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.": "取消的会话可能在下一次心跳时再次开始;如需保持停止,请阻塞该任务。", diff --git a/src/renderer/src/pages/HomePage.tsx b/src/renderer/src/pages/HomePage.tsx index a9055fc1..4cb4dd54 100644 --- a/src/renderer/src/pages/HomePage.tsx +++ b/src/renderer/src/pages/HomePage.tsx @@ -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) {

{renderSummary(pendingApprovals.length, agents.length)}

- +
{progress ? `${percent}%` : "—"} - {progress ? statusLabel(progress.status) : t("no work yet")} + {progress?.pace ? statusLabel(progress.pace) : t("no target date")} + {goal && }
@@ -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(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 ( + + change(event.target.value)} style={{ height: 26, padding: "0 6px", fontSize: 12 }} /> + {error && {error}} + + ); +} + +/** 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())}`; +} diff --git a/src/renderer/src/pages/home/goal-progress.ts b/src/renderer/src/pages/home/goal-progress.ts new file mode 100644 index 00000000..460a064f --- /dev/null +++ b/src/renderer/src/pages/home/goal-progress.ts @@ -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" }; +} diff --git a/src/renderer/src/pages/work/GoalDialog.tsx b/src/renderer/src/pages/work/GoalDialog.tsx index 45945fdf..11aecec5 100644 --- a/src/renderer/src/pages/work/GoalDialog.tsx +++ b/src/renderer/src/pages/work/GoalDialog.tsx @@ -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({