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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user