Q208: Home shows one ordered list of what needs you and why work stopped, else the next task in line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ace76dabc7
commit
d422b2ce69
6 files changed
+224
-68
No files matched your search
@@ -600,7 +600,10 @@ export const zh: Record<string, string> = {
|
|||||||
"Version": "版本",
|
"Version": "版本",
|
||||||
"Version (semver)": "版本(semver)",
|
"Version (semver)": "版本(semver)",
|
||||||
"Visibility": "可见性",
|
"Visibility": "可见性",
|
||||||
"Waiting on you": "等你处理",
|
"Needs you": "需要你处理",
|
||||||
|
"Next in line": "下一项工作",
|
||||||
|
"Stopped: {reason}": "已停下:{reason}",
|
||||||
|
"{n} more after these": "之后还有 {n} 项",
|
||||||
"We never embed the key in the build. Credentials encrypt locally via OS-protected storage.": "密钥绝不打包进构建。凭证经操作系统保护的存储在本地加密。",
|
"We never embed the key in the build. Credentials encrypt locally via OS-protected storage.": "密钥绝不打包进构建。凭证经操作系统保护的存储在本地加密。",
|
||||||
"Welcome": "欢迎",
|
"Welcome": "欢迎",
|
||||||
"Work": "工作",
|
"Work": "工作",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { runTriggerLabel } from "../i18n/labels";
|
import { runTriggerLabel } from "../i18n/labels";
|
||||||
import { goalProgress, type GoalPace } from "./home/goal-progress";
|
import { goalProgress, type GoalPace } from "./home/goal-progress";
|
||||||
|
import { nextUp } from "./home/next-up";
|
||||||
|
import { NextUpCard } from "./home/NextUpCard";
|
||||||
import { newestRuns } from "./runs/newest-runs";
|
import { newestRuns } from "./runs/newest-runs";
|
||||||
import { isBusinessTask } from "./work/task-helpers";
|
import { isBusinessTask } from "./work/task-helpers";
|
||||||
import { useMemo, useState, type CSSProperties } from "react";
|
import { useMemo, useState, type CSSProperties } from "react";
|
||||||
@@ -7,7 +9,6 @@ import { Avatar, Button, Icon, Pill } from "../shared/ui";
|
|||||||
import { TopbarActions } from "../shared/TopbarActions";
|
import { TopbarActions } from "../shared/TopbarActions";
|
||||||
import type {
|
import type {
|
||||||
AgentRecord,
|
AgentRecord,
|
||||||
ApprovalRecord,
|
|
||||||
ChatMessageRecord,
|
ChatMessageRecord,
|
||||||
EmployeeRecord,
|
EmployeeRecord,
|
||||||
GoalRecord,
|
GoalRecord,
|
||||||
@@ -46,7 +47,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
|||||||
const usageStats = requireUsageStats(snapshot.usageStats, orgId);
|
const usageStats = requireUsageStats(snapshot.usageStats, orgId);
|
||||||
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
||||||
|
|
||||||
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
|
const { needsYou, next } = nextUp(approvals, businessTasks);
|
||||||
const totalBudgetCents = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
|
const totalBudgetCents = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
|
||||||
const primaryGoal = goals.find((goal) => goal.status === "active") ?? goals[0] ?? null;
|
const primaryGoal = goals.find((goal) => goal.status === "active") ?? goals[0] ?? null;
|
||||||
const progress = primaryGoal ? goalProgress(primaryGoal, businessTasks, new Date()) : null;
|
const progress = primaryGoal ? goalProgress(primaryGoal, businessTasks, new Date()) : null;
|
||||||
@@ -59,17 +60,19 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
|||||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={16} /> {t("New task")}</Button>
|
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={16} /> {t("New task")}</Button>
|
||||||
</TopbarActions>
|
</TopbarActions>
|
||||||
<h1 className="page-h1">{greeting}, {firstName}</h1>
|
<h1 className="page-h1">{greeting}, {firstName}</h1>
|
||||||
<p className="page-sub">{renderSummary(pendingApprovals.length, agents.length)}</p>
|
<p className="page-sub">{renderSummary(needsYou.length, agents.length)}</p>
|
||||||
|
|
||||||
<div style={STRIP}>
|
<div style={STRIP}>
|
||||||
<GoalCard goal={primaryGoal} progress={progress} organizationName={organization.name} actions={actions} />
|
<GoalCard goal={primaryGoal} progress={progress} organizationName={organization.name} actions={actions} />
|
||||||
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
|
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
|
||||||
<WaitingCard
|
<NextUpCard
|
||||||
approvals={pendingApprovals}
|
needsYou={needsYou}
|
||||||
|
next={next}
|
||||||
agents={agents}
|
agents={agents}
|
||||||
employees={[employee]}
|
employees={[employee]}
|
||||||
onApprove={(approvalId) => void actions.run(() => window.agentCompany.decideApproval({ orgId, approvalId, state: "approved", decidedByEmployeeId: employee.id }))}
|
onApprove={(approvalId) => void actions.run(() => window.agentCompany.decideApproval({ orgId, approvalId, state: "approved", decidedByEmployeeId: employee.id }))}
|
||||||
onOpen={() => navigate("approvals")}
|
onOpenApprovals={() => navigate("approvals")}
|
||||||
|
onOpenTask={(taskId) => navigate("work", { kind: "task", id: taskId })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,49 +175,6 @@ function SpendCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WaitingCard({
|
|
||||||
approvals,
|
|
||||||
agents,
|
|
||||||
employees,
|
|
||||||
onApprove,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
approvals: ApprovalRecord[];
|
|
||||||
agents: AgentRecord[];
|
|
||||||
employees: EmployeeRecord[];
|
|
||||||
onApprove: (approvalId: string) => void;
|
|
||||||
onOpen: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<div className="row" style={{ marginBottom: 12 }}>
|
|
||||||
<span className="card-label">{t("Waiting on you")}</span>
|
|
||||||
{approvals.length > 0 && <span style={{ marginLeft: "auto" }}><Pill tone="amber">{approvals.length}</Pill></span>}
|
|
||||||
</div>
|
|
||||||
{approvals.length === 0 ? (
|
|
||||||
<div className="row" style={{ gap: 8, color: "var(--text-3)", fontSize: 14 }}>
|
|
||||||
<Icon name="check" size={16} /><span>{t("You're all caught up")}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
||||||
{approvals.slice(0, 2).map((approval) => (
|
|
||||||
<div key={approval.id}>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
|
|
||||||
<div style={{ fontSize: 12, color: "var(--text-3)", margin: "2px 0 8px" }}>
|
|
||||||
{t("{kind} · {risk} risk · from {requester}", { kind: humanizeLabel(approval.kind), risk: humanizeLabel(approval.risk), requester: resolveRequester(approval, employees, agents) })}
|
|
||||||
</div>
|
|
||||||
<div className="row" style={{ gap: 8 }}>
|
|
||||||
<Button tone="primary" size="small" onClick={() => onApprove(approval.id)}>{t("Approve")}</Button>
|
|
||||||
<Button size="small" onClick={onOpen}>{t("Open")}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RunActivityCard({
|
function RunActivityCard({
|
||||||
taskRuns,
|
taskRuns,
|
||||||
tasks,
|
tasks,
|
||||||
@@ -325,24 +285,6 @@ function renderSummary(pending: number, agents: number): string {
|
|||||||
return `${things} · ${onDeck}`;
|
return `${things} · ${onDeck}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanizeLabel(value: string): string {
|
|
||||||
if (!value) return value;
|
|
||||||
const spaced = value.replace(/_/g, " ");
|
|
||||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRequester(approval: ApprovalRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string {
|
|
||||||
if (approval.requestedByType === "employee" && approval.requestedById) {
|
|
||||||
const employee = employees.find((item) => item.id === approval.requestedById);
|
|
||||||
if (employee) return employee.displayName;
|
|
||||||
}
|
|
||||||
if (approval.requestedByType === "agent" && approval.requestedById) {
|
|
||||||
const agent = agents.find((item) => item.id === approval.requestedById);
|
|
||||||
if (agent) return agent.name;
|
|
||||||
}
|
|
||||||
return t("System");
|
|
||||||
}
|
|
||||||
|
|
||||||
function runStateTone(state: string): "red" | "amber" | "violet" | "green" | undefined {
|
function runStateTone(state: string): "red" | "amber" | "violet" | "green" | undefined {
|
||||||
if (state === "failed") return "red";
|
if (state === "failed") return "red";
|
||||||
if (state === "running") return "violet";
|
if (state === "running") return "violet";
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { AgentRecord, ApprovalRecord, EmployeeRecord } from "../../../../shared/domain";
|
||||||
|
import { approvalKindLabel, riskLabel } from "../../i18n/labels";
|
||||||
|
import { t } from "../../i18n/i18n";
|
||||||
|
import { Button, Icon, Pill } from "../../shared/ui";
|
||||||
|
import type { NextUpItem } from "./next-up";
|
||||||
|
|
||||||
|
const SHOWN = 3;
|
||||||
|
const TITLE = { fontSize: 14, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } as const;
|
||||||
|
const META = { fontSize: 12, color: "var(--text-3)", margin: "2px 0 8px" } as const;
|
||||||
|
|
||||||
|
/** Home's one place for what to look at next: decisions and stopped work in priority order, or else the next task in line. */
|
||||||
|
export function NextUpCard({
|
||||||
|
needsYou,
|
||||||
|
next,
|
||||||
|
agents,
|
||||||
|
employees,
|
||||||
|
onApprove,
|
||||||
|
onOpenApprovals,
|
||||||
|
onOpenTask,
|
||||||
|
}: {
|
||||||
|
needsYou: NextUpItem[];
|
||||||
|
next: NextUpItem | null;
|
||||||
|
agents: AgentRecord[];
|
||||||
|
employees: EmployeeRecord[];
|
||||||
|
onApprove: (approvalId: string) => void;
|
||||||
|
onOpenApprovals: () => void;
|
||||||
|
onOpenTask: (taskId: string) => void;
|
||||||
|
}) {
|
||||||
|
const more = needsYou.length - SHOWN;
|
||||||
|
return (
|
||||||
|
<section className="card" aria-label={t("Needs you")}>
|
||||||
|
<div className="row" style={{ marginBottom: 12 }}>
|
||||||
|
<span className="card-label">{t("Needs you")}</span>
|
||||||
|
{needsYou.length > 0 && <span style={{ marginLeft: "auto" }}><Pill tone="amber">{needsYou.length}</Pill></span>}
|
||||||
|
</div>
|
||||||
|
{needsYou.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="row" style={{ gap: 8, color: "var(--text-3)", fontSize: 14, marginBottom: 12 }}>
|
||||||
|
<Icon name="check" size={16} /><span>{t("You're all caught up")}</span>
|
||||||
|
</div>
|
||||||
|
{next?.kind === "next" && (
|
||||||
|
<div>
|
||||||
|
<div className="card-label">{t("Next in line")}</div>
|
||||||
|
<div style={TITLE}>{next.task.title}</div>
|
||||||
|
<div className="row" style={{ gap: 8, marginTop: 8 }}>
|
||||||
|
<Button size="small" onClick={() => onOpenTask(next.task.id)}>{t("Open")}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ol style={{ display: "flex", flexDirection: "column", gap: 16, listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
|
{needsYou.slice(0, SHOWN).map((item) => item.kind === "approval" ? (
|
||||||
|
<li key={item.approval.id}>
|
||||||
|
<div style={TITLE}>{item.approval.title}</div>
|
||||||
|
<div style={META}>
|
||||||
|
{t("{kind} · {risk} risk · from {requester}", { kind: approvalKindLabel(item.approval.kind), risk: riskLabel(item.approval.risk), requester: requesterName(item.approval, employees, agents) })}
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<Button tone="primary" size="small" onClick={() => onApprove(item.approval.id)}>{t("Approve")}</Button>
|
||||||
|
<Button size="small" onClick={onOpenApprovals}>{t("Open")}</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : item.kind === "held" ? (
|
||||||
|
<li key={item.task.id}>
|
||||||
|
<div style={TITLE}>{item.task.title}</div>
|
||||||
|
<div style={{ ...META, color: "var(--amber)" }}>{t("Stopped: {reason}", { reason: item.reason })}</div>
|
||||||
|
<Button size="small" onClick={() => onOpenTask(item.task.id)}>{t("Open")}</Button>
|
||||||
|
</li>
|
||||||
|
) : null)}
|
||||||
|
{more > 0 && <li style={{ fontSize: 12, color: "var(--text-3)" }}>{t("{n} more after these", { n: more })}</li>}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requesterName(approval: ApprovalRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string {
|
||||||
|
if (approval.requestedByType === "employee" && approval.requestedById) {
|
||||||
|
const employee = employees.find((item) => item.id === approval.requestedById);
|
||||||
|
if (employee) return employee.displayName;
|
||||||
|
}
|
||||||
|
if (approval.requestedByType === "agent" && approval.requestedById) {
|
||||||
|
const agent = agents.find((item) => item.id === approval.requestedById);
|
||||||
|
if (agent) return agent.name;
|
||||||
|
}
|
||||||
|
return t("System");
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ApprovalRecord, TaskPriority, TaskRecord } from "../../../../shared/domain";
|
||||||
|
|
||||||
|
/** One thing to look at next, in the single order Home shows them: decisions waiting on you, then work stopped until you act, then the next task in line. */
|
||||||
|
export type NextUpItem =
|
||||||
|
| { kind: "approval"; approval: ApprovalRecord }
|
||||||
|
| { kind: "held"; task: TaskRecord; reason: string }
|
||||||
|
| { kind: "next"; task: TaskRecord };
|
||||||
|
|
||||||
|
const PRIORITY_RANK: Record<TaskPriority, number> = { high: 0, normal: 1, low: 2 };
|
||||||
|
|
||||||
|
const byPriorityThenOldest = (a: TaskRecord, b: TaskRecord) =>
|
||||||
|
PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] || a.createdAt.localeCompare(b.createdAt);
|
||||||
|
|
||||||
|
export function nextUp(approvals: ApprovalRecord[], tasks: TaskRecord[]): { needsYou: NextUpItem[]; next: NextUpItem | null } {
|
||||||
|
const waiting: NextUpItem[] = approvals
|
||||||
|
.filter((approval) => approval.state === "pending")
|
||||||
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
||||||
|
.map((approval) => ({ kind: "approval", approval }));
|
||||||
|
const open = tasks.filter((task) => task.status !== "done");
|
||||||
|
const held: NextUpItem[] = open
|
||||||
|
.filter((task) => task.hold?.waitingOn === "operator")
|
||||||
|
.sort(byPriorityThenOldest)
|
||||||
|
.map((task) => ({ kind: "held", task, reason: task.hold!.reason }));
|
||||||
|
// Work already under way comes before work not yet started.
|
||||||
|
const inLine = open
|
||||||
|
.filter((task) => task.hold === null && task.status !== "review")
|
||||||
|
.sort((a, b) => Number(a.status !== "in_progress") - Number(b.status !== "in_progress") || byPriorityThenOldest(a, b))[0];
|
||||||
|
return { needsYou: [...waiting, ...held], next: inLine ? { kind: "next", task: inLine } : null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { _electron as electron, expect, test } from "@playwright/test";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
|
||||||
|
import { closeApp, onboard } from "./onboard";
|
||||||
|
|
||||||
|
test("Home shows one ordered list of what needs you, with why work stopped, and else the next task in line", async () => {
|
||||||
|
const profileDir = join(tmpdir(), `agentcompany-new-e2e-nextup-${randomUUID()}`);
|
||||||
|
mkdirSync(profileDir, { recursive: true });
|
||||||
|
const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-nextup-builtin-${randomUUID()}`));
|
||||||
|
const app = await electron.launch({
|
||||||
|
args: [resolve("out/main/index.js")],
|
||||||
|
env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await app.firstWindow();
|
||||||
|
await onboard(page, "Next Up Co");
|
||||||
|
const blocked = await page.evaluate(async () => {
|
||||||
|
type Api = Record<string, (input?: unknown) => Promise<{ ok: boolean; data: { activeOrgId: string; tasks: Array<{ id: string; title: string }> } }>>;
|
||||||
|
const api = (window as unknown as { agentCompany: Api }).agentCompany;
|
||||||
|
const seeded = (await api.loadSnapshot()).data;
|
||||||
|
const orgId = seeded.activeOrgId;
|
||||||
|
// Onboarding's kickoff task is finished first, so only this test's tasks are open.
|
||||||
|
for (const task of seeded.tasks) await api.updateTaskStatus({ orgId, taskId: task.id, status: "done" });
|
||||||
|
const base = { orgId, description: "d", status: "backlog", assigneeAgentId: null, risk: "low", projectId: null, goalId: null, workspaceId: null, requiresFilesystem: false };
|
||||||
|
await api.createTask({ ...base, title: "Ship pricing page", priority: "normal" });
|
||||||
|
await api.createTask({ ...base, title: "Fix login", priority: "high" });
|
||||||
|
const id = (await api.loadSnapshot()).data.tasks.find((task) => task.title === "Fix login")!.id;
|
||||||
|
await api.setTaskBlocked({ orgId, taskId: id, reason: "Needs the staging key" });
|
||||||
|
return { orgId, id };
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Home" }).click();
|
||||||
|
const card = page.getByRole("region", { name: "Needs you" });
|
||||||
|
const stopped = card.getByRole("listitem").filter({ hasText: "Fix login" });
|
||||||
|
await expect(stopped).toContainText("Stopped: Blocked: Needs the staging key");
|
||||||
|
await expect(card.getByText("Ship pricing page")).toHaveCount(0);
|
||||||
|
await expect(page.getByText(/1 thing waiting on you/)).toBeVisible();
|
||||||
|
await stopped.getByRole("button", { name: "Open" }).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: /Fix login/ })).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await page.evaluate(async ({ orgId, id }) => {
|
||||||
|
const api = (window as unknown as { agentCompany: Record<string, (input: unknown) => Promise<unknown>> }).agentCompany;
|
||||||
|
await api.setTaskBlocked({ orgId, taskId: id, reason: null });
|
||||||
|
}, blocked);
|
||||||
|
await page.getByRole("button", { name: "Home" }).click();
|
||||||
|
await expect(card.getByText("You're all caught up")).toBeVisible();
|
||||||
|
// With nothing waiting, the most urgent open task is next.
|
||||||
|
await expect(card.getByText("Next in line")).toBeVisible();
|
||||||
|
await expect(card.getByText("Fix login")).toBeVisible();
|
||||||
|
await expect(card.getByText("Ship pricing page")).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await closeApp(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { nextUp } from "../../src/renderer/src/pages/home/next-up";
|
||||||
|
import type { ApprovalRecord, TaskRecord } from "../../src/shared/domain";
|
||||||
|
|
||||||
|
const approval = (id: string, createdAt: string, state = "pending") => ({ id, state, createdAt }) as ApprovalRecord;
|
||||||
|
const task = (id: string, fields: Partial<TaskRecord>) =>
|
||||||
|
({ id, status: "backlog", priority: "normal", hold: null, createdAt: "2026-01-01T00:00:00.000Z", ...fields }) as TaskRecord;
|
||||||
|
const ids = (items: ReturnType<typeof nextUp>["needsYou"]) =>
|
||||||
|
items.map((item) => (item.kind === "approval" ? item.approval.id : item.task.id));
|
||||||
|
|
||||||
|
test("decisions come first, oldest first, then work stopped until you act, most urgent first", () => {
|
||||||
|
const result = nextUp(
|
||||||
|
[approval("apr_new", "2026-01-03T00:00:00.000Z"), approval("apr_old", "2026-01-02T00:00:00.000Z"), approval("apr_done", "2026-01-01T00:00:00.000Z", "approved")],
|
||||||
|
[
|
||||||
|
task("tsk_low", { priority: "low", hold: { reason: "Blocked: needs a key", waitingOn: "operator", resumesAt: null } }),
|
||||||
|
task("tsk_high", { priority: "high", hold: { reason: "Ran out of runs", waitingOn: "operator", resumesAt: null } }),
|
||||||
|
task("tsk_cooldown", { hold: { reason: "Cooling down", waitingOn: "provider", resumesAt: "2026-01-04T00:00:00.000Z" } }),
|
||||||
|
task("tsk_closed", { status: "done", hold: { reason: "Old", waitingOn: "operator", resumesAt: null } }),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(ids(result.needsYou)).toEqual(["apr_old", "apr_new", "tsk_high", "tsk_low"]);
|
||||||
|
const held = result.needsYou[2];
|
||||||
|
expect(held.kind === "held" && held.reason).toBe("Ran out of runs");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the next task in line is work under way before work not started, then by priority; held, waiting and finished work is not next", () => {
|
||||||
|
const backlogHigh = task("tsk_backlog_high", { priority: "high" });
|
||||||
|
const runningLow = task("tsk_running_low", { status: "in_progress", priority: "low" });
|
||||||
|
const runningHigh = task("tsk_running_high", { status: "in_progress", priority: "high", createdAt: "2026-01-05T00:00:00.000Z" });
|
||||||
|
const review = task("tsk_review", { status: "review", priority: "high" });
|
||||||
|
const cooling = task("tsk_cooling", { status: "in_progress", priority: "high", hold: { reason: "Cooling", waitingOn: "provider", resumesAt: "2026-01-04T00:00:00.000Z" } });
|
||||||
|
expect(nextUp([], [backlogHigh, runningLow, review, cooling]).next).toEqual({ kind: "next", task: runningLow });
|
||||||
|
expect(nextUp([], [backlogHigh, runningLow, runningHigh]).next).toEqual({ kind: "next", task: runningHigh });
|
||||||
|
expect(nextUp([], [backlogHigh]).next).toEqual({ kind: "next", task: backlogHigh });
|
||||||
|
expect(nextUp([], [review, cooling, task("tsk_done", { status: "done" })]).next).toBeNull();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user