Work list titles clip instead of colliding; timeline becomes segmented status bars per goal and project
This commit is contained in:
1 parent
38bd3588e7
commit
b30b9a3bc1
3 files changed
+211
-114
No files matched your search
@@ -20,7 +20,9 @@ import { GoalDialog } from "./work/GoalDialog";
|
|||||||
import { ProjectDialog } from "./work/ProjectDialog";
|
import { ProjectDialog } from "./work/ProjectDialog";
|
||||||
import { TaskDetailDialog } from "./work/TaskDetailDialog";
|
import { TaskDetailDialog } from "./work/TaskDetailDialog";
|
||||||
import { ProjectOverview } from "./work/ProjectOverview";
|
import { ProjectOverview } from "./work/ProjectOverview";
|
||||||
import { canRun, latestTaskRun, nextStatus, prettyRunState, prettyStatus, providerFor, runTone } from "./work/task-helpers";
|
import { ListView } from "./work/ListView";
|
||||||
|
import { TimelineView } from "./work/TimelineView";
|
||||||
|
import { canRun, latestTaskRun, nextStatus, prettyRunState, providerFor, runTone } from "./work/task-helpers";
|
||||||
import type { PageProps } from "./types";
|
import type { PageProps } from "./types";
|
||||||
import { activeOrgId } from "./types";
|
import { activeOrgId } from "./types";
|
||||||
|
|
||||||
@@ -268,119 +270,6 @@ function BoardView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListView({
|
|
||||||
tasks,
|
|
||||||
agents,
|
|
||||||
projects,
|
|
||||||
providers,
|
|
||||||
onRun,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
tasks: TaskRecord[];
|
|
||||||
agents: AgentRecord[];
|
|
||||||
projects: ProjectRecord[];
|
|
||||||
providers: ProviderRecord[];
|
|
||||||
onRun: (task: TaskRecord) => void;
|
|
||||||
onOpen: (task: TaskRecord) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
|
||||||
<div
|
|
||||||
className="table-row head"
|
|
||||||
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr) 88px" }}
|
|
||||||
>
|
|
||||||
<div>Title</div><div>Status</div><div>Priority</div><div>Assignee</div><div />
|
|
||||||
</div>
|
|
||||||
{tasks.map((task, index) => {
|
|
||||||
const assignee = agents.find((agent) => agent.id === task.assigneeAgentId) ?? null;
|
|
||||||
const provider = providerFor(assignee, providers);
|
|
||||||
const project = projects.find((p) => p.id === task.projectId) ?? null;
|
|
||||||
const last = index === tasks.length - 1;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={task.id}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="table-row clickable"
|
|
||||||
onClick={() => onOpen(task)}
|
|
||||||
onKeyDown={(event) => { if (event.key === "Enter") onOpen(task); }}
|
|
||||||
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr) 88px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
|
||||||
>
|
|
||||||
<div className="row" style={{ gap: 8, minWidth: 0 }}>
|
|
||||||
{task.risk === "high" && <span style={{ width: 3, height: 12, background: "var(--red)", borderRadius: 2, flexShrink: 0 }} />}
|
|
||||||
<span style={{ fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task.title}</span>
|
|
||||||
{project && <Pill>{project.name}</Pill>}
|
|
||||||
</div>
|
|
||||||
<div><Pill>{prettyStatus(task.status)}</Pill></div>
|
|
||||||
<div><Pill tone={task.priority === "high" ? "amber" : undefined}>{task.priority}</Pill></div>
|
|
||||||
<div>
|
|
||||||
{assignee ? (
|
|
||||||
<div className="row" style={{ gap: 8 }}>
|
|
||||||
<Avatar name={assignee.name} size="sm" bot />
|
|
||||||
<span style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{assignee.name}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: "var(--text-4)" }}>—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 4 }} onClick={(event) => event.stopPropagation()}>
|
|
||||||
{assignee && <IconButton icon="send" label={`Run ${task.title}`} onClick={() => onRun(task)} disabled={!canRun(provider)} />}
|
|
||||||
<IconButton icon="more" label={`Open ${task.title}`} onClick={() => onOpen(task)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimelineView({ tasks, goals, projects }: { tasks: TaskRecord[]; goals: GoalRecord[]; projects: ProjectRecord[] }) {
|
|
||||||
const unlinked = tasks.filter((t) => !t.goalId);
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: 18 }}>
|
|
||||||
<div className="section-label">By goal</div>
|
|
||||||
{goals.map((goal) => {
|
|
||||||
const linked = tasks.filter((task) => task.goalId === goal.id);
|
|
||||||
const done = linked.filter((task) => task.status === TASK_STATUS.DONE).length;
|
|
||||||
const pct = linked.length === 0 ? 0 : Math.round((done / linked.length) * 100);
|
|
||||||
const goalProjects = projects.filter((p) => p.goalId === goal.id);
|
|
||||||
return (
|
|
||||||
<div key={goal.id} style={{ padding: "12px 0", borderBottom: "1px solid var(--border-soft)" }}>
|
|
||||||
<div className="row">
|
|
||||||
<span style={{ fontWeight: 540 }}>{goal.title}</span>
|
|
||||||
<span className="num" style={{ marginLeft: "auto", color: "var(--text-3)" }}>{done} / {linked.length}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 8, height: 3, background: "var(--panel-3)", borderRadius: 2, overflow: "hidden" }}>
|
|
||||||
<div style={{ width: `${pct}%`, height: "100%", background: "var(--text-1)" }} />
|
|
||||||
</div>
|
|
||||||
{goalProjects.length > 0 && (
|
|
||||||
<div style={{ marginTop: 8, display: "flex", gap: 6, flexWrap: "wrap" }}>
|
|
||||||
{goalProjects.map((p) => {
|
|
||||||
const projectTasks = linked.filter((t) => t.projectId === p.id);
|
|
||||||
const projectDone = projectTasks.filter((t) => t.status === TASK_STATUS.DONE).length;
|
|
||||||
return (
|
|
||||||
<Pill key={p.id}>
|
|
||||||
{truncate(p.name, 24)} {projectDone}/{projectTasks.length}
|
|
||||||
</Pill>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{unlinked.length > 0 && (
|
|
||||||
<div style={{ padding: "12px 0" }}>
|
|
||||||
<div className="row">
|
|
||||||
<span style={{ fontWeight: 540, color: "var(--text-3)" }}>Unlinked tasks</span>
|
|
||||||
<span className="num" style={{ marginLeft: "auto", color: "var(--text-3)" }}>{unlinked.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TaskCard({
|
function TaskCard({
|
||||||
task,
|
task,
|
||||||
assignee,
|
assignee,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Avatar, IconButton, Pill } from "../../shared/ui";
|
||||||
|
import type { AgentRecord, ProjectRecord, ProviderRecord, TaskRecord } from "../../../../shared/domain";
|
||||||
|
import { canRun, prettyStatus, providerFor } from "./task-helpers";
|
||||||
|
|
||||||
|
const GRID = "minmax(0, 3fr) 110px 96px minmax(0, 1.2fr) 88px";
|
||||||
|
|
||||||
|
export function ListView({
|
||||||
|
tasks,
|
||||||
|
agents,
|
||||||
|
projects,
|
||||||
|
providers,
|
||||||
|
onRun,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
tasks: TaskRecord[];
|
||||||
|
agents: AgentRecord[];
|
||||||
|
projects: ProjectRecord[];
|
||||||
|
providers: ProviderRecord[];
|
||||||
|
onRun: (task: TaskRecord) => void;
|
||||||
|
onOpen: (task: TaskRecord) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||||
|
<div className="table-row head" style={{ gridTemplateColumns: GRID }}>
|
||||||
|
<div>Title</div><div>Status</div><div>Priority</div><div>Assignee</div><div />
|
||||||
|
</div>
|
||||||
|
{tasks.map((task, index) => {
|
||||||
|
const assignee = agents.find((agent) => agent.id === task.assigneeAgentId) ?? null;
|
||||||
|
const provider = providerFor(assignee, providers);
|
||||||
|
const project = projects.find((p) => p.id === task.projectId) ?? null;
|
||||||
|
const last = index === tasks.length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="table-row clickable"
|
||||||
|
onClick={() => onOpen(task)}
|
||||||
|
onKeyDown={(event) => { if (event.key === "Enter") onOpen(task); }}
|
||||||
|
style={{ gridTemplateColumns: GRID, borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||||
|
>
|
||||||
|
<div className="row" style={{ gap: 8, minWidth: 0, overflow: "hidden" }}>
|
||||||
|
{task.risk === "high" && <span style={{ width: 3, height: 12, background: "var(--red)", borderRadius: 2, flexShrink: 0 }} />}
|
||||||
|
<span style={{ fontWeight: 480, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
{project && <span style={{ flexShrink: 0 }}><Pill>{truncate(project.name, 24)}</Pill></span>}
|
||||||
|
</div>
|
||||||
|
<div><Pill>{prettyStatus(task.status)}</Pill></div>
|
||||||
|
<div><Pill tone={task.priority === "high" ? "amber" : undefined}>{task.priority}</Pill></div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
{assignee ? (
|
||||||
|
<div className="row" style={{ gap: 8, minWidth: 0 }}>
|
||||||
|
<Avatar name={assignee.name} size="sm" bot />
|
||||||
|
<span style={{ color: "var(--text-2)", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{assignee.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--text-4)" }}>—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: 4 }} onClick={(event) => event.stopPropagation()}>
|
||||||
|
{assignee && <IconButton icon="send" label={`Run ${task.title}`} onClick={() => onRun(task)} disabled={!canRun(provider)} />}
|
||||||
|
<IconButton icon="more" label={`Open ${task.title}`} onClick={() => onOpen(task)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string, max: number): string {
|
||||||
|
if (value.length <= max) return value;
|
||||||
|
return `${value.slice(0, max - 1)}…`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { TASK_STATUS, type GoalRecord, type ProjectRecord, type TaskRecord, type TaskStatus } from "../../../../shared/domain";
|
||||||
|
|
||||||
|
const SEGMENTS: ReadonlyArray<{ id: TaskStatus; label: string; tone: string }> = [
|
||||||
|
{ id: TASK_STATUS.DONE, label: "Done", tone: "var(--green)" },
|
||||||
|
{ id: TASK_STATUS.REVIEW, label: "In review", tone: "var(--amber)" },
|
||||||
|
{ id: TASK_STATUS.IN_PROGRESS, label: "In progress", tone: "var(--accent)" },
|
||||||
|
{ id: TASK_STATUS.BACKLOG, label: "Backlog", tone: "var(--panel-3)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execution timeline by goal: each goal carries a segmented bar of its tasks
|
||||||
|
* by status (done → review → in progress → backlog), then one row per
|
||||||
|
* project with the same bar at project scope.
|
||||||
|
*/
|
||||||
|
export function TimelineView({ tasks, goals, projects }: { tasks: TaskRecord[]; goals: GoalRecord[]; projects: ProjectRecord[] }) {
|
||||||
|
const unlinked = tasks.filter((task) => !task.goalId);
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{goals.map((goal) => {
|
||||||
|
const linked = tasks.filter((task) => task.goalId === goal.id);
|
||||||
|
const goalProjects = projects.filter((project) => project.goalId === goal.id);
|
||||||
|
return (
|
||||||
|
<section key={goal.id} className="card" style={{ padding: 18 }}>
|
||||||
|
<div className="row" style={{ gap: 12 }}>
|
||||||
|
<span style={{ fontWeight: 560, fontSize: 14, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>
|
||||||
|
{goal.title}
|
||||||
|
</span>
|
||||||
|
<CompletionReadout tasks={linked} />
|
||||||
|
</div>
|
||||||
|
<StatusBar tasks={linked} height={6} style={{ marginTop: 12 }} />
|
||||||
|
<Legend tasks={linked} />
|
||||||
|
{goalProjects.length > 0 && (
|
||||||
|
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{goalProjects.map((project) => {
|
||||||
|
const projectTasks = linked.filter((task) => task.projectId === project.id);
|
||||||
|
return (
|
||||||
|
<div key={project.id} className="row" style={{ gap: 12 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
width: 260,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
<StatusBar tasks={projectTasks} height={4} style={{ flex: 1 }} />
|
||||||
|
<CompletionReadout tasks={projectTasks} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{unlinked.length > 0 && (
|
||||||
|
<section className="card" style={{ padding: 18 }}>
|
||||||
|
<div className="row" style={{ gap: 12 }}>
|
||||||
|
<span style={{ fontWeight: 560, fontSize: 14, color: "var(--text-2)", flex: 1 }}>Unlinked tasks</span>
|
||||||
|
<CompletionReadout tasks={unlinked} />
|
||||||
|
</div>
|
||||||
|
<StatusBar tasks={unlinked} height={6} style={{ marginTop: 12 }} />
|
||||||
|
<Legend tasks={unlinked} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function counts(tasks: TaskRecord[]): Map<TaskStatus, number> {
|
||||||
|
const map = new Map<TaskStatus, number>();
|
||||||
|
for (const segment of SEGMENTS) map.set(segment.id, 0);
|
||||||
|
for (const task of tasks) map.set(task.status, (map.get(task.status) ?? 0) + 1);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBar({ tasks, height, style }: { tasks: TaskRecord[]; height: number; style?: React.CSSProperties }) {
|
||||||
|
const byStatus = counts(tasks);
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return <div style={{ height, borderRadius: height, background: "var(--panel-3)", ...style }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", gap: 2, height, ...style }}>
|
||||||
|
{SEGMENTS.map((segment) => {
|
||||||
|
const count = byStatus.get(segment.id) ?? 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={segment.id}
|
||||||
|
style={{
|
||||||
|
flex: count,
|
||||||
|
background: segment.tone,
|
||||||
|
borderRadius: height,
|
||||||
|
minWidth: height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Legend({ tasks }: { tasks: TaskRecord[] }) {
|
||||||
|
const byStatus = counts(tasks);
|
||||||
|
return (
|
||||||
|
<div className="row" style={{ gap: 14, marginTop: 10, flexWrap: "wrap" }}>
|
||||||
|
{SEGMENTS.map((segment) => (
|
||||||
|
<span key={segment.id} className="row" style={{ gap: 6, fontSize: 12, color: "var(--text-3)" }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: 2, background: segment.tone, flexShrink: 0 }} />
|
||||||
|
<span className="num">{byStatus.get(segment.id) ?? 0}</span>
|
||||||
|
{segment.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompletionReadout({ tasks }: { tasks: TaskRecord[] }) {
|
||||||
|
const done = tasks.filter((task) => task.status === TASK_STATUS.DONE).length;
|
||||||
|
return (
|
||||||
|
<span className="num" style={{ color: "var(--text-3)", fontSize: 13, flexShrink: 0 }}>
|
||||||
|
{done} / {tasks.length}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user