// 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<T extends IssueSummary>(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;
}