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 { TaskDetailDialog } from "./work/TaskDetailDialog";
|
||||
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 { 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({
|
||||
task,
|
||||
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