diff --git a/src/app/plan/plan.css b/src/app/plan/plan.css index c78b18c..9f41bc6 100644 --- a/src/app/plan/plan.css +++ b/src/app/plan/plan.css @@ -42,8 +42,10 @@ .plan-overview span { color: var(--text-muted); font-size: 10px; white-space: nowrap; } .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 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-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; } @@ -186,6 +188,7 @@ } @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 { width: 100%; } .plan-project-filter select { flex: 1; } diff --git a/src/components/planning/plan-board.tsx b/src/components/planning/plan-board.tsx index 6c71a58..cc1822b 100644 --- a/src/components/planning/plan-board.tsx +++ b/src/components/planning/plan-board.tsx @@ -31,6 +31,7 @@ import { type CalendarZoom, } from "@/lib/planning/calendar"; 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 { Role } from "@/lib/pb/types"; @@ -65,6 +66,7 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol const { showError, showToast } = useToast(); const [board, setBoard] = useState(projects); const [selectedProject, setSelectedProject] = useState("all"); + const [sortOrder, setSortOrder] = useState("date"); const [zoom, setZoom] = useState("weeks"); const [anchor, setAnchor] = useState(todayLocalIso()); const [editing, setEditing] = useState(null); @@ -184,15 +186,24 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
- +
+ + +
@@ -250,6 +261,7 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol project={project} axis={axis} zoom={zoom} + sortOrder={sortOrder} canEdit={canEdit} onJump={setAnchor} onEdit={(cardId) => setEditing({ projectId: project.id, cardId })} @@ -320,6 +332,7 @@ function ProjectTimeline({ project, axis, zoom, + sortOrder, canEdit, onJump, onEdit, @@ -327,6 +340,7 @@ function ProjectTimeline({ project: PlanningProject; axis: CalendarAxis; zoom: CalendarZoom; + sortOrder: PlanningSort; canEdit: boolean; onJump: (date: string) => void; onEdit: (cardId: string) => void; @@ -380,7 +394,7 @@ function ProjectTimeline({ )}
- {project.cards.map((card) => ( + {sortPlanningCards(project.cards, sortOrder).map((card) => ( { @@ -28,17 +28,17 @@ describe("planning board loader", () => { column("done", "Done", true), ], cards: [ - card("one", "inbox"), - card("two", "backlog"), - card("three", "todo"), - card("four", "progress"), - card("five", "review"), - card("six", "done"), + card("one", "inbox", 1), + card("two", "backlog", 2), + card("three", "todo", 3), + card("four", "progress", 4), + card("five", "review", 5), + card("six", "done", 6), ], subtasks: [] as SubtaskRecord[], }); 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); }); }); diff --git a/src/lib/planning/load.ts b/src/lib/planning/load.ts index eea5b37..20803fb 100644 --- a/src/lib/planning/load.ts +++ b/src/lib/planning/load.ts @@ -2,6 +2,7 @@ import "server-only"; import { getServerPb } from "@/lib/pb/server"; import { readProjectPlan, type PlanRange, type ProjectPlan } from "@/lib/planning/state"; +import { sortPlanningCards } from "@/lib/planning/sort"; import type { CardRecord, ClientRecord, @@ -12,6 +13,7 @@ import type { export interface PlanningCard { id: string; + created: string; number: number; title: string; priority: CardRecord["priority"]; @@ -83,6 +85,7 @@ export function buildPlanningBoard({ const count = counts.get(card.id) ?? { done: 0, total: 0 }; return { id: card.id, + created: card.created, number: card.number ?? 0, title: card.title, priority: card.priority, @@ -96,20 +99,13 @@ export function buildPlanningBoard({ }; }); 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({ id: project.id, name: project.name, clientName: client.name, clientColor: client.brand_color || "var(--brand)", plan, - cards: projectCards, + cards: sortPlanningCards(projectCards, "date"), }); } return output.sort((a, b) => diff --git a/src/lib/planning/sort.test.ts b/src/lib/planning/sort.test.ts new file mode 100644 index 0000000..c3116f7 --- /dev/null +++ b/src/lib/planning/sort.test.ts @@ -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"]); + }); +}); diff --git a/src/lib/planning/sort.ts b/src/lib/planning/sort.ts new file mode 100644 index 0000000..3a733a2 --- /dev/null +++ b/src/lib/planning/sort.ts @@ -0,0 +1,40 @@ +import type { PlanningCard } from "@/lib/planning/load"; + +export type PlanningSort = "date" | "priority"; + +const priorityRank: Record = { + 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); + }); +}