Vectrum
Sign In
127 lines · 4.6 KB · TypeScript
// 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 };
export type Member = { id: string; username: string; name: 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;
  status: Status;
  priority: number;
  assigneeId: string | null;
  assignee: 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) {
    next.assigneeId = patch.assigneeId;
    next.assignee = lookup.members.find((m) => m.id === patch.assigneeId)?.username ?? 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;
}