Skip to content
Clone
main
323 lines 9.7 KB
/**
* 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<IssuePriority, PriorityMeta> = PRIORITIES.reduce(
(acc, meta) => ({ ...acc, [meta.id]: meta }),
{} as Record<IssuePriority, PriorityMeta>,
)
export function priorityOf(value: string | null | undefined): PriorityMeta {
return PRIORITY_META[value as IssuePriority] ?? PRIORITY_META.none
}
export const STATES: Readonly<Record<IssueState, { label: string; color: string }>> = {
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<Record<WatchMode, string>> = {
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<IssueSort, string> = {
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 `<input type="date">`, 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<Record<string, string>> = 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 })
}