feat(plan): order timeline by dates and priority

This commit is contained in:
Dave 2026-09-22 08:35:17 +02:00
parent a6785f74ee
commit 9778ee09d4
6 changed files with 143 additions and 27 deletions

View file

@ -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; }

View file

@ -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}

View file

@ -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);
}); });
}); });

View file

@ -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) =>

View 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
View 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);
});
}