feat(plan): order timeline by dates and priority
This commit is contained in:
parent
a6785f74ee
commit
9778ee09d4
6 changed files with 143 additions and 27 deletions
|
|
@ -42,8 +42,10 @@
|
||||||
.plan-overview span { color: var(--text-muted); font-size: 10px; white-space: nowrap; }
|
.plan-overview span { color: var(--text-muted); font-size: 10px; white-space: nowrap; }
|
||||||
|
|
||||||
.plan-toolbar { gap: 16px; margin-bottom: 18px; }
|
.plan-toolbar { gap: 16px; margin-bottom: 18px; }
|
||||||
|
.plan-toolbar-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; }
|
||||||
.plan-project-filter { display: flex; align-items: center; gap: 9px; color: var(--text-muted); font-size: 12px; font-weight: 600; }
|
.plan-project-filter { display: flex; align-items: center; gap: 9px; color: var(--text-muted); font-size: 12px; font-weight: 600; }
|
||||||
.plan-project-filter select { min-width: 240px; max-width: 340px; height: 36px; padding: 0 34px 0 12px; border: 1px solid var(--border-subtle); border-radius: 9px; background: var(--panel); color: var(--text-primary); font-size: 12px; font-weight: 600; }
|
.plan-project-filter select { min-width: 240px; max-width: 340px; height: 36px; padding: 0 34px 0 12px; border: 1px solid var(--border-subtle); border-radius: 9px; background: var(--panel); color: var(--text-primary); font-size: 12px; font-weight: 600; }
|
||||||
|
.plan-order-filter select { min-width: 132px; }
|
||||||
.plan-toolbar-right { gap: 10px; }
|
.plan-toolbar-right { gap: 10px; }
|
||||||
.plan-segment { display: flex; gap: 2px; padding: 3px; border: 1px solid var(--border-subtle); border-radius: 9px; background: var(--surface); }
|
.plan-segment { display: flex; gap: 2px; padding: 3px; border: 1px solid var(--border-subtle); border-radius: 9px; background: var(--surface); }
|
||||||
.plan-segment button { min-width: 58px; padding: 6px 10px; border-radius: 6px; color: var(--text-muted); font-size: 11px; font-weight: 700; }
|
.plan-segment button { min-width: 58px; padding: 6px 10px; border-radius: 6px; color: var(--text-muted); font-size: 11px; font-weight: 700; }
|
||||||
|
|
@ -186,6 +188,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 530px) {
|
@media (max-width: 530px) {
|
||||||
|
.plan-toolbar-filters { align-items: stretch; flex-direction: column; }
|
||||||
.plan-project-filter select { min-width: 0; width: 100%; }
|
.plan-project-filter select { min-width: 0; width: 100%; }
|
||||||
.plan-project-filter { width: 100%; }
|
.plan-project-filter { width: 100%; }
|
||||||
.plan-project-filter select { flex: 1; }
|
.plan-project-filter select { flex: 1; }
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
type CalendarZoom,
|
type CalendarZoom,
|
||||||
} from "@/lib/planning/calendar";
|
} from "@/lib/planning/calendar";
|
||||||
import type { PlanningCard, PlanningProject } from "@/lib/planning/load";
|
import type { PlanningCard, PlanningProject } from "@/lib/planning/load";
|
||||||
|
import { sortPlanningCards, type PlanningSort } from "@/lib/planning/sort";
|
||||||
import type { PlanRange, ProjectPlan } from "@/lib/planning/state";
|
import type { PlanRange, ProjectPlan } from "@/lib/planning/state";
|
||||||
import type { Role } from "@/lib/pb/types";
|
import type { Role } from "@/lib/pb/types";
|
||||||
|
|
||||||
|
|
@ -65,6 +66,7 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
|
||||||
const { showError, showToast } = useToast();
|
const { showError, showToast } = useToast();
|
||||||
const [board, setBoard] = useState(projects);
|
const [board, setBoard] = useState(projects);
|
||||||
const [selectedProject, setSelectedProject] = useState("all");
|
const [selectedProject, setSelectedProject] = useState("all");
|
||||||
|
const [sortOrder, setSortOrder] = useState<PlanningSort>("date");
|
||||||
const [zoom, setZoom] = useState<CalendarZoom>("weeks");
|
const [zoom, setZoom] = useState<CalendarZoom>("weeks");
|
||||||
const [anchor, setAnchor] = useState(todayLocalIso());
|
const [anchor, setAnchor] = useState(todayLocalIso());
|
||||||
const [editing, setEditing] = useState<EditingItem | null>(null);
|
const [editing, setEditing] = useState<EditingItem | null>(null);
|
||||||
|
|
@ -184,15 +186,24 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="plan-toolbar">
|
<div className="plan-toolbar">
|
||||||
<label className="plan-project-filter">
|
<div className="plan-toolbar-filters">
|
||||||
<span>Show</span>
|
<label className="plan-project-filter">
|
||||||
<select value={selectedProject} onChange={(event) => setSelectedProject(event.target.value)}>
|
<span>Show</span>
|
||||||
<option value="all">All projects</option>
|
<select value={selectedProject} onChange={(event) => setSelectedProject(event.target.value)}>
|
||||||
{board.map((project) => (
|
<option value="all">All projects</option>
|
||||||
<option key={project.id} value={project.id}>{project.clientName} · {project.name}</option>
|
{board.map((project) => (
|
||||||
))}
|
<option key={project.id} value={project.id}>{project.clientName} · {project.name}</option>
|
||||||
</select>
|
))}
|
||||||
</label>
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="plan-project-filter plan-order-filter">
|
||||||
|
<span>Order</span>
|
||||||
|
<select value={sortOrder} onChange={(event) => setSortOrder(event.target.value as PlanningSort)}>
|
||||||
|
<option value="date">Date first</option>
|
||||||
|
<option value="priority">Priority first</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="plan-toolbar-right">
|
<div className="plan-toolbar-right">
|
||||||
<div className="plan-segment" aria-label="Calendar scale">
|
<div className="plan-segment" aria-label="Calendar scale">
|
||||||
<button type="button" className={zoom === "weeks" ? "is-active" : ""} onClick={() => setZoom("weeks")}>Weeks</button>
|
<button type="button" className={zoom === "weeks" ? "is-active" : ""} onClick={() => setZoom("weeks")}>Weeks</button>
|
||||||
|
|
@ -250,6 +261,7 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
|
||||||
project={project}
|
project={project}
|
||||||
axis={axis}
|
axis={axis}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
|
sortOrder={sortOrder}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onJump={setAnchor}
|
onJump={setAnchor}
|
||||||
onEdit={(cardId) => setEditing({ projectId: project.id, cardId })}
|
onEdit={(cardId) => setEditing({ projectId: project.id, cardId })}
|
||||||
|
|
@ -320,6 +332,7 @@ function ProjectTimeline({
|
||||||
project,
|
project,
|
||||||
axis,
|
axis,
|
||||||
zoom,
|
zoom,
|
||||||
|
sortOrder,
|
||||||
canEdit,
|
canEdit,
|
||||||
onJump,
|
onJump,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
|
@ -327,6 +340,7 @@ function ProjectTimeline({
|
||||||
project: PlanningProject;
|
project: PlanningProject;
|
||||||
axis: CalendarAxis;
|
axis: CalendarAxis;
|
||||||
zoom: CalendarZoom;
|
zoom: CalendarZoom;
|
||||||
|
sortOrder: PlanningSort;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onJump: (date: string) => void;
|
onJump: (date: string) => void;
|
||||||
onEdit: (cardId: string) => void;
|
onEdit: (cardId: string) => void;
|
||||||
|
|
@ -380,7 +394,7 @@ function ProjectTimeline({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{project.cards.map((card) => (
|
{sortPlanningCards(project.cards, sortOrder).map((card) => (
|
||||||
<TimelineRow
|
<TimelineRow
|
||||||
key={card.id}
|
key={card.id}
|
||||||
card={card}
|
card={card}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ function column(id: string, name: string, is_done = false): ColumnRecord {
|
||||||
return { id, project: "project", name, is_done } as ColumnRecord;
|
return { id, project: "project", name, is_done } as ColumnRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
function card(id: string, columnId: string): CardRecord {
|
function card(id: string, columnId: string, number: number): CardRecord {
|
||||||
return { id, project: "project", column: columnId, title: id, archived: false, priority: "medium" } as CardRecord;
|
return { id, number, created: `2026-09-${String(number).padStart(2, "0")} 10:00:00.000Z`, project: "project", column: columnId, title: id, archived: false, priority: "medium" } as CardRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("planning board loader", () => {
|
describe("planning board loader", () => {
|
||||||
|
|
@ -28,17 +28,17 @@ describe("planning board loader", () => {
|
||||||
column("done", "Done", true),
|
column("done", "Done", true),
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
card("one", "inbox"),
|
card("one", "inbox", 1),
|
||||||
card("two", "backlog"),
|
card("two", "backlog", 2),
|
||||||
card("three", "todo"),
|
card("three", "todo", 3),
|
||||||
card("four", "progress"),
|
card("four", "progress", 4),
|
||||||
card("five", "review"),
|
card("five", "review", 5),
|
||||||
card("six", "done"),
|
card("six", "done", 6),
|
||||||
],
|
],
|
||||||
subtasks: [] as SubtaskRecord[],
|
subtasks: [] as SubtaskRecord[],
|
||||||
});
|
});
|
||||||
expect(board).toHaveLength(1);
|
expect(board).toHaveLength(1);
|
||||||
expect(board[0].cards.map((item) => item.id)).toEqual(["four", "one", "three", "two"]);
|
expect(board[0].cards.map((item) => item.id)).toEqual(["one", "two", "three", "four"]);
|
||||||
expect(board[0].cards.every((item) => item.draftRange === null)).toBe(true);
|
expect(board[0].cards.every((item) => item.draftRange === null)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import "server-only";
|
||||||
|
|
||||||
import { getServerPb } from "@/lib/pb/server";
|
import { getServerPb } from "@/lib/pb/server";
|
||||||
import { readProjectPlan, type PlanRange, type ProjectPlan } from "@/lib/planning/state";
|
import { readProjectPlan, type PlanRange, type ProjectPlan } from "@/lib/planning/state";
|
||||||
|
import { sortPlanningCards } from "@/lib/planning/sort";
|
||||||
import type {
|
import type {
|
||||||
CardRecord,
|
CardRecord,
|
||||||
ClientRecord,
|
ClientRecord,
|
||||||
|
|
@ -12,6 +13,7 @@ import type {
|
||||||
|
|
||||||
export interface PlanningCard {
|
export interface PlanningCard {
|
||||||
id: string;
|
id: string;
|
||||||
|
created: string;
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
priority: CardRecord["priority"];
|
priority: CardRecord["priority"];
|
||||||
|
|
@ -83,6 +85,7 @@ export function buildPlanningBoard({
|
||||||
const count = counts.get(card.id) ?? { done: 0, total: 0 };
|
const count = counts.get(card.id) ?? { done: 0, total: 0 };
|
||||||
return {
|
return {
|
||||||
id: card.id,
|
id: card.id,
|
||||||
|
created: card.created,
|
||||||
number: card.number ?? 0,
|
number: card.number ?? 0,
|
||||||
title: card.title,
|
title: card.title,
|
||||||
priority: card.priority,
|
priority: card.priority,
|
||||||
|
|
@ -96,20 +99,13 @@ export function buildPlanningBoard({
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (projectCards.length === 0) continue;
|
if (projectCards.length === 0) continue;
|
||||||
projectCards.sort((a, b) => {
|
|
||||||
const aDate = a.draftRange?.start ?? a.approvedRange?.start ?? "";
|
|
||||||
const bDate = b.draftRange?.start ?? b.approvedRange?.start ?? "";
|
|
||||||
if (!aDate && bDate) return -1;
|
|
||||||
if (aDate && !bDate) return 1;
|
|
||||||
return aDate.localeCompare(bDate) || a.title.localeCompare(b.title);
|
|
||||||
});
|
|
||||||
output.push({
|
output.push({
|
||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
clientName: client.name,
|
clientName: client.name,
|
||||||
clientColor: client.brand_color || "var(--brand)",
|
clientColor: client.brand_color || "var(--brand)",
|
||||||
plan,
|
plan,
|
||||||
cards: projectCards,
|
cards: sortPlanningCards(projectCards, "date"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return output.sort((a, b) =>
|
return output.sort((a, b) =>
|
||||||
|
|
|
||||||
63
src/lib/planning/sort.test.ts
Normal file
63
src/lib/planning/sort.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { PlanningCard } from "@/lib/planning/load";
|
||||||
|
import { sortPlanningCards } from "@/lib/planning/sort";
|
||||||
|
|
||||||
|
function card(id: string, number: number, priority: PlanningCard["priority"], start?: string): PlanningCard {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
created: `2026-09-${String(number).padStart(2, "0")} 10:00:00.000Z`,
|
||||||
|
number,
|
||||||
|
title: id,
|
||||||
|
priority,
|
||||||
|
columnName: "To Do",
|
||||||
|
boardTargetDate: "",
|
||||||
|
draftRange: start ? { start, end: start } : null,
|
||||||
|
approvedRange: null,
|
||||||
|
subtaskDone: 0,
|
||||||
|
subtaskTotal: 0,
|
||||||
|
href: `/cards/${id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("planning timeline order", () => {
|
||||||
|
const cards = [
|
||||||
|
card("later", 5, "critical", "2026-10-10"),
|
||||||
|
card("newUndated", 9, "medium"),
|
||||||
|
card("firstHigh", 2, "high", "2026-10-02"),
|
||||||
|
card("firstCritical", 3, "critical", "2026-10-02"),
|
||||||
|
card("oldUndated", 1, "medium"),
|
||||||
|
card("lowUndated", 4, "low"),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("orders scheduled work by start date and priority, then undated work by priority and creation order", () => {
|
||||||
|
expect(sortPlanningCards(cards, "date").map((item) => item.id)).toEqual([
|
||||||
|
"firstCritical", "firstHigh", "later", "oldUndated", "newUndated", "lowUndated",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can put higher priority scheduled work first while keeping undated work at the end", () => {
|
||||||
|
expect(sortPlanningCards(cards, "priority").map((item) => item.id)).toEqual([
|
||||||
|
"firstCritical", "later", "firstHigh", "oldUndated", "newUndated", "lowUndated",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a newly scheduled item into the dated rows without changing the input", () => {
|
||||||
|
const changed = cards.map((item) => item.id === "newUndated"
|
||||||
|
? { ...item, draftRange: { start: "2026-10-01", end: "2026-10-03" } }
|
||||||
|
: item);
|
||||||
|
expect(sortPlanningCards(changed, "date").map((item) => item.id)[0]).toBe("newUndated");
|
||||||
|
expect(cards.find((item) => item.id === "newUndated")?.draftRange).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses approved dates when a draft date is absent", () => {
|
||||||
|
const approved = { ...card("approved", 6, "low"), approvedRange: { start: "2026-10-01", end: "2026-10-03" } };
|
||||||
|
expect(sortPlanningCards([cards[0], approved], "date").map((item) => item.id)).toEqual(["approved", "later"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps later unnumbered board cards after earlier cards of the same priority", () => {
|
||||||
|
const first = { ...card("first", 0, "medium"), created: "2026-09-01 10:00:00.000Z" };
|
||||||
|
const addedLater = { ...card("addedLater", 0, "medium"), created: "2026-09-20 10:00:00.000Z" };
|
||||||
|
expect(sortPlanningCards([addedLater, first], "date").map((item) => item.id)).toEqual(["first", "addedLater"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
src/lib/planning/sort.ts
Normal file
40
src/lib/planning/sort.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { PlanningCard } from "@/lib/planning/load";
|
||||||
|
|
||||||
|
export type PlanningSort = "date" | "priority";
|
||||||
|
|
||||||
|
const priorityRank: Record<PlanningCard["priority"], number> = {
|
||||||
|
critical: 0,
|
||||||
|
high: 1,
|
||||||
|
medium: 2,
|
||||||
|
low: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
function startDate(card: PlanningCard): string {
|
||||||
|
return card.draftRange?.start ?? card.approvedRange?.start ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparePriority(a: PlanningCard, b: PlanningCard): number {
|
||||||
|
return priorityRank[a.priority] - priorityRank[b.priority];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareCardIdentity(a: PlanningCard, b: PlanningCard): number {
|
||||||
|
return a.created.localeCompare(b.created)
|
||||||
|
|| a.number - b.number
|
||||||
|
|| a.title.localeCompare(b.title)
|
||||||
|
|| a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep items without plan dates after the schedule, even in priority view. */
|
||||||
|
export function sortPlanningCards(cards: PlanningCard[], order: PlanningSort): PlanningCard[] {
|
||||||
|
return [...cards].sort((a, b) => {
|
||||||
|
const aDate = startDate(a);
|
||||||
|
const bDate = startDate(b);
|
||||||
|
if (!aDate && bDate) return 1;
|
||||||
|
if (aDate && !bDate) return -1;
|
||||||
|
if (!aDate && !bDate) return comparePriority(a, b) || compareCardIdentity(a, b);
|
||||||
|
|
||||||
|
return order === "priority"
|
||||||
|
? comparePriority(a, b) || aDate.localeCompare(bDate) || compareCardIdentity(a, b)
|
||||||
|
: aDate.localeCompare(bDate) || comparePriority(a, b) || compareCardIdentity(a, b);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue