/** * Display metadata for issue rows: the labels, colours and ordering the board * UI shows for values the API only names. Kept next to the domain (not in a * component) because the kanban columns, the filter chips, the list table and * the detail sidebar all have to agree on them. */ import { getLocale, isMessageKey, t } from '@app/i18n' import { fmtRelative } from '@app/lib' import type { IssueBoardView, IssuePriority, IssueSort, IssueState, WatchMode } from './issue' /** `issues.attachment.max_size` — the backend rejects anything larger. */ export const MAX_ATTACHMENT_BYTES = 26_214_400 /** `issues.max_board_prefix_len` — mirrored so the dialog validates before sending. */ export const MAX_BOARD_PREFIX_LEN = 10 /** * Board-scoped issue key as the backend resolves it (`CAD-12`), falling back to * the bare number so boards without a prefix keep today's links. */ export function issueRef(board: { prefix?: string | null } | null | undefined, number: number): string { const prefix = board?.prefix?.trim() return prefix ? `${prefix}-${number}` : String(number) } /** Display form of {@link issueRef}: `CAD-12`, or `#12` without a prefix. */ export function issueLabel(board: { prefix?: string | null } | null | undefined, number: number): string { const prefix = board?.prefix?.trim() return prefix ? `${prefix}-${number}` : `#${number}` } /** * Row stamp, Linear-style: relative while the change is fresh, then the month * (`2026年10月` / `Oct 2026`) once "3天前" stops being useful. */ export function issueStamp(value: string | null | undefined): string { const date = parseIssueDate(value) if (!date) return '' if (Date.now() - date.getTime() < 7 * 86_400_000) return fmtRelative(value as string) return new Intl.DateTimeFormat(getLocale(), { month: 'short', year: 'numeric' }).format(date) } /** `^[A-Z][A-Z0-9]{0,9}$` — the shape `PATCH …/boards` accepts. */ export function isValidBoardPrefix(value: string): boolean { return /^[A-Z][A-Z0-9]{0,9}$/.test(value) } export interface PriorityMeta { id: IssuePriority label: string /** Column dot / badge background. */ color: string /** Badge text colour that stays readable on `color`. */ text: string /** High → low, used for client-side ordering and column order. */ rank: number } /** Highest priority first — matches `?sort=priority` (backend sorts desc). */ export const PRIORITIES: readonly PriorityMeta[] = [ { id: 'urgent', get label() { return t('label.priority.urgent') }, color: '#e5484d', text: '#ffffff', rank: 4, }, { id: 'high', get label() { return t('label.priority.high') }, color: '#f76808', text: '#ffffff', rank: 3, }, { id: 'medium', get label() { return t('label.priority.medium') }, color: '#ffc53d', text: '#3d2a00', rank: 2, }, { id: 'low', get label() { return t('label.priority.low') }, color: '#8fb8ff', text: '#12315e', rank: 1, }, { id: 'none', get label() { return t('label.priority.none') }, color: '#d6d9e0', text: '#3f434b', rank: 0, }, ] as const export const PRIORITY_META: Record = PRIORITIES.reduce( (acc, meta) => ({ ...acc, [meta.id]: meta }), {} as Record, ) export function priorityOf(value: string | null | undefined): PriorityMeta { return PRIORITY_META[value as IssuePriority] ?? PRIORITY_META.none } export const STATES: Readonly> = { open: { get label() { return t('label.issueState.open') }, color: '#3ba55d', }, closed: { get label() { return t('label.issueState.closed') }, color: '#66717d', }, } export const STATE_FILTERS: ReadonlyArray<{ id: IssueState | 'all'; label: string }> = [ { id: 'open', get label() { return t('label.issueState.open') }, }, { id: 'closed', get label() { return t('label.issueState.closed') }, }, { id: 'all', get label() { return t('label.issueState.all') }, }, ] export const SORTS: ReadonlyArray<{ id: IssueSort; label: string }> = [ { id: 'created', get label() { return t('label.issueSort.created') }, }, { id: 'updated', get label() { return t('label.issueSort.updated') }, }, { id: 'priority', get label() { return t('label.issueSort.priority') }, }, { id: 'due', get label() { return t('label.issueSort.due') }, }, { id: 'number', get label() { return t('label.issueSort.number') }, }, ] /** * The two layouts a 问题集 can open in. Stored per user (`board_view_mode`); * an unknown or absent value falls back to the kanban. */ export function normalizeBoardView(value: string | null | undefined): IssueBoardView['view'] { return value === 'list' ? 'list' : 'board' } export const WATCH_LABELS: Readonly> = { get watching() { return t('issue.watch.watching') }, get unwatched() { return t('issue.watch.unwatched') }, get ignore() { return t('issue.watch.ignore') }, } /** Sort values the backend rejects are dropped so the query stays valid. */ export const SORT_PARAMS: Record = { created: 'created_at', updated: 'updated_at', priority: 'priority', due: 'due_at', number: 'number', } /** * Rust's `time` crate renders offsets as `+00:00:00` (seconds in the offset), * which `Date` cannot parse. Same fold as `shared/lib/format`. */ export function parseIssueDate(value: string | null | undefined): Date | null { if (!value) return null const date = new Date(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2')) return Number.isNaN(date.getTime()) ? null : date } export interface DueMeta { text: string /** Overdue (or due today) on a still-open issue reads as a warning. */ tone: 'muted' | 'due' | 'over' | 'done' } /** Due date as `MM-DD`, qualified for open issues only. */ export function describeDue(value: string | null | undefined, state: IssueState): DueMeta | null { const date = parseIssueDate(value) if (!date) return null const day = new Intl.DateTimeFormat(getLocale(), { month: 'numeric', day: 'numeric' }).format(date) if (state === 'closed') return { text: day, tone: 'done' } const today = new Date() today.setHours(0, 0, 0, 0) const start = new Date(date) start.setHours(0, 0, 0, 0) const days = Math.round((start.getTime() - today.getTime()) / 86_400_000) // Absolute date plus a relative phrase; Intl supplies the wording so the // table stays out of the translation catalogue. const relative = new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' }) if (days < 0) return { text: `${day} · ${relative.format(days, 'day')}`, tone: 'over' } if (days === 0) return { text: `${day} · ${relative.format(0, 'day')}`, tone: 'due' } if (days <= 7) return { text: `${day} · ${relative.format(days, 'day')}`, tone: 'due' } return { text: day, tone: 'muted' } } /** `#rgb` / `rgb` / bare hex → `#rrggbb`, so a label colour never breaks CSS. */ export function normalizeColor(value: string | null | undefined, fallback = '#8b8f99'): string { const raw = (value ?? '').trim().replace(/^#/, '') if (raw.length === 3) return `#${raw .split('') .map((c) => c + c) .join('')}` if (raw.length === 6 && /^[0-9a-f]{6}$/i.test(raw)) return `#${raw.toLowerCase()}` return fallback } /** Pick black or white text for a background so custom label colours stay legible. */ export function readableText(background: string): string { const hex = normalizeColor(background).slice(1) const [r, g, b] = [0, 2, 4].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255) const channel = (v: number) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4) const luminance = 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) return luminance > 0.55 ? '#1c1e22' : '#ffffff' } /** Suggestions offered by the label colour picker (GitHub-ish palette). */ export const LABEL_COLORS = [ '#e5484d', '#f76808', '#ffc53d', '#46a758', '#00a2c7', '#66717d', '#8e4ec6', '#eb459e', '#8b8f99', ] as const /** `YYYY-MM-DD` for ``, from the backend's own timestamp. */ export function dateInputValue(value: string | null | undefined): string { const date = parseIssueDate(value) if (!date) return '' const pad = (n: number) => String(n).padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } /** Send a date-only pick as UTC noon so it never shifts a day across timezones. */ export function dateApiValue(input: string): string | null { if (!input) return null const [year, month, day] = input.split('-').map(Number) if (!year || !month || !day) return null return new Date(Date.UTC(year, month - 1, day, 12, 0, 0)).toISOString() } /** * Activity line labels, keyed by the backend's `IssueEventType` snake_case * values. Unknown types fall back to the raw value, so a new server-side event * still renders (unreadable rather than missing). */ /** * Activity line labels, keyed by the backend's `IssueEventType` snake_case * values. Resolved lazily so the table follows the active locale; unknown types * fall back to the raw value, so a new server-side event still renders. */ export const EVENT_LABELS: Readonly> = new Proxy( {}, { get: (_target, key: string) => { const entry = `issue.event.${key}` return isMessageKey(entry) ? t(entry) : undefined }, }, ) export function eventLabel(type: string | null | undefined): string { if (!type) return t('issue.event.unknown') return EVENT_LABELS[type] ?? t('issue.event.fallback', { type }) }