// Issue tracker vocabulary shared by the server and client components (no database access here). export const STATUSES = [ { id: "backlog", label: "Backlog" }, { id: "todo", label: "Todo" }, { id: "in_progress", label: "In Progress" }, { id: "in_review", label: "In Review" }, { id: "done", label: "Done" }, { id: "canceled", label: "Canceled" }, ] as const; export type Status = (typeof STATUSES)[number]["id"]; export const STATUS_IDS = STATUSES.map((s) => s.id) as Status[]; /** Most advanced first, like Linear's list view. */ export const LIST_STATUS_ORDER: Status[] = ["in_review", "in_progress", "todo", "backlog", "done", "canceled"]; export const statusLabel = (status: Status) => STATUSES.find((s) => s.id === status)?.label ?? status; /** Done and canceled issues count as closed; everything else is open. */ export const isClosedStatus = (status: Status) => status === "done" || status === "canceled"; // Linear's numbering: 0 is "no priority", 1 the most urgent. export const PRIORITIES = [ { id: 1, label: "Urgent" }, { id: 2, label: "High" }, { id: 3, label: "Medium" }, { id: 4, label: "Low" }, { id: 0, label: "No priority" }, ] as const; export type Priority = (typeof PRIORITIES)[number]["id"]; export const priorityLabel = (priority: number) => PRIORITIES.find((p) => p.id === priority)?.label ?? "No priority"; /** Sort key that puts "no priority" after "low". */ export const priorityRank = (priority: number) => (priority === 0 ? 5 : priority); export const ESTIMATES = [1, 2, 3, 5, 8] as const; export const LABEL_COLORS = ["#95a2b3", "#5e6ad2", "#26b5ce", "#4cb782", "#f2c94c", "#f2994a", "#eb5757", "#bb87fc"] as const; export type IssueLabel = { id: string; name: string; color: string }; /** Someone an issue can be assigned to: a person, or an agent (then `id` is its key and `agentOf` its person). */ export type Member = { id: string; username: string; name: string; agentOf?: string }; /** An issue as shown in lists and on the board (without its description). */ export type IssueSummary = { id: string; number: number; title: string; authorId: string; author: string; /** The agent that opened it for its author. */ authorAgent: string | null; status: Status; priority: number; /** A person's id, or an agent's key. */ assigneeId: string | null; /** A username, or an agent's name. */ assignee: string | null; /** When assigned to an agent: the person it acts for. */ assigneeAgentOf: string | null; estimate: number | null; /** YYYY-MM-DD */ dueDate: string | null; sortOrder: number; labels: IssueLabel[]; createdAt: number; updatedAt: number; commentCount: number; }; export type Issue = IssueSummary & { body: string; state: "open" | "closed" }; /** Fields a server action may change on an issue. */ export type IssuePatch = { title?: string; body?: string; status?: Status; priority?: number; assigneeId?: string | null; estimate?: number | null; dueDate?: string | null; labelIds?: string[]; sortOrder?: number; }; export type NewIssueInput = { title: string; body: string; status?: Status; priority?: number; assigneeId?: string | null; estimate?: number | null; dueDate?: string | null; labelIds?: string[]; }; export type ActionResult = { error?: string }; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; /** "Oct 3" for a YYYY-MM-DD date, without time zone conversions (so server and client agree). */ export function formatDay(date: string, withYear = false) { const [y, m, d] = date.split("-").map(Number); return `${MONTHS[m - 1]} ${d}${withYear ? `, ${y}` : ""}`; } /** A real calendar date in YYYY-MM-DD form (rejects e.g. Feb 31, which Date.parse accepts). */ export function isValidDay(date: string) { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return false; const parsed = new Date(`${date}T00:00:00Z`); return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(date); } /** The issue as it will look once `patch` is saved, for optimistic updates. */ export function applyPatch(issue: T, patch: IssuePatch, lookup: { members: Member[]; labels: IssueLabel[] }): T { const next = { ...issue }; if (patch.title !== undefined) next.title = patch.title; if (patch.status !== undefined) next.status = patch.status; if (patch.priority !== undefined) next.priority = patch.priority; if (patch.estimate !== undefined) next.estimate = patch.estimate; if (patch.dueDate !== undefined) next.dueDate = patch.dueDate; if (patch.sortOrder !== undefined) next.sortOrder = patch.sortOrder; if (patch.assigneeId !== undefined) { const member = lookup.members.find((m) => m.id === patch.assigneeId); next.assigneeId = patch.assigneeId; next.assignee = member?.username ?? null; next.assigneeAgentOf = member?.agentOf ?? null; } if (patch.labelIds !== undefined) next.labels = lookup.labels.filter((l) => patch.labelIds!.includes(l.id)); if ("body" in next && patch.body !== undefined) (next as T & { body: string }).body = patch.body; return next; }