// Общие помощники UI задач: подписи статусов, приоритеты, Markdown.
// Все строки — через vue-i18n (i18n.global), локаль переключается реактивно.
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import i18n from './i18n'
import { intlLocale } from './i18n/locale'
function t(key: string, params?: Record<string, unknown>): string {
return i18n.global.t(key, params ?? {})
}
// Плюральные формы (0.69): «3 дня назад», а не «3 день». Формы лежат в словаре
// одной строкой через «|» (одна, две-четыре, много), а выбирает их правило
// самой локали — Intl.PluralRules, без хардкода славянских счётчиков.
// Встроенный плюрал vue-i18n 11 для этого не годится: он не применяет правила
// локали вовсе (для любого n отдаёт последнюю форму — проверено), поэтому берём
// сырое значение словаря через tm() и выбираем форму сами
const PLURAL_INDEX: Record<string, number> = { one: 0, few: 1, many: 2, other: 3 }
function tn(key: string, n: number): string {
const forms = String(i18n.global.tm(key))
.split('|')
.map((form) => form.trim())
const category = new Intl.PluralRules(intlLocale()).select(n)
// «other» (дроби) и незнакомая категория схлопываются в последнюю форму
const index = Math.min(PLURAL_INDEX[category] ?? forms.length - 1, forms.length - 1)
return (forms[index] ?? forms[0]).replace('{n}', String(n))
}
// Название сервиса — шапка, тайтлы вкладок
export const APP_NAME = 'GNexus Tasks'
// Тайтл вкладки: «{страница} — GNexus Tasks»; без части — просто название сервиса
export function setPageTitle(page?: string): void {
document.title = page ? `${page} — ${APP_NAME}` : APP_NAME
}
// Ключ отсутствует в словаре (неизвестный код статуса) → отдаём код как есть
function tOr(key: string, fallback: string): string {
return i18n.global.te(key) ? i18n.global.t(key) : fallback
}
export function statusLabel(status: string): string {
return tOr(`taskui.status.${status}`, status)
}
// Цвета — варианты бейджа кита (accent у бейджей без раскраски, поэтому
// «в работе» — синий secondary: полутон статуса читается сразу)
export const STATUS_VARIANTS: Record<string, string> = {
to_do: 'info',
in_progress: 'secondary',
done: 'success',
cancelled: 'error',
deferred: 'warning',
}
export function statusVariant(status: string): string {
return STATUS_VARIANTS[status] ?? 'neutral'
}
// Иконка статуса — согласована со счётчиками статистики.
// Серия кругов: пустой (к выполнению) → наполовину (в работе) → галочка (завершено).
export const STATUS_ICONS: Record<string, string> = {
to_do: 'ph-circle',
in_progress: 'ph-circle-half',
done: 'ph-check-circle',
cancelled: 'ph-x-circle',
deferred: 'ph-pause-circle',
}
export function statusIcon(status: string): string {
return STATUS_ICONS[status] ?? 'ph-circle'
}
export function relevanceLabel(status: string): string {
return tOr(`taskui.relevance.${status}`, status)
}
// Приоритет — понятная 5-градусная шкала с цветом; в БД хранится число 0–10.
// very_low 0–2 · low 3–4 · medium 5–6 · high 7–8 · urgent 9–10.
export type PriorityGrade = 'very_low' | 'low' | 'medium' | 'high' | 'urgent'
export function priorityToGrade(priority: number): PriorityGrade {
if (priority <= 2) return 'very_low'
if (priority <= 4) return 'low'
if (priority <= 6) return 'medium'
if (priority <= 8) return 'high'
return 'urgent'
}
export function gradeToPriority(grade: string): number {
const map: Record<PriorityGrade, number> = { very_low: 1, low: 3, medium: 5, high: 7, urgent: 9 }
return map[grade as PriorityGrade] ?? 5
}
export function priorityLabel(priority: number): string {
return t(`taskui.priority.${priorityToGrade(priority)}`)
}
export function priorityVariant(priority: number): string {
// Шкала серьёзности: от нейтрального светлого к тревожному красному
const variants: Record<PriorityGrade, string> = {
very_low: 'primary',
low: 'info',
medium: 'secondary',
high: 'warning',
urgent: 'error',
}
return variants[priorityToGrade(priority)]
}
export function priorityOptions(): { value: string; label: string }[] {
return (['very_low', 'low', 'medium', 'high', 'urgent'] as PriorityGrade[]).map((g) => ({
value: g,
label: t(`taskui.priority.${g}`),
}))
}
// Опции статусов для GnSelect (в видах оборачивать в computed)
export function statusOptions(): { value: string; label: string }[] {
return ['to_do', 'in_progress', 'done', 'cancelled', 'deferred'].map((s) => ({
value: s,
label: t(`taskui.status.${s}`),
}))
}
export function renderMarkdown(text: string): string {
// Линки — во внешность (новая вкладка/браузер, чтобы PWA не ловила навигацию)
return DOMPurify.sanitize(marked.parse(text, { async: false })).replace(
/<a\s+href=/g,
'<a target="_blank" rel="noopener" href=',
)
}
// Переключить чекбокс №index в markdown-списке задач ([ ] ↔ [x]).
// Возвращает null, если чекбокса с таким номером нет (DOM разошёлся с текстом —
// вслепую не правим). Понимает маркеры - * + и нумерованные 1. / 1).
export function toggleMarkdownCheckbox(md: string, index: number): string | null {
const re = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+\[)([ xX])(\])/gm
let i = 0
let replaced = false
const out = md.replace(re, (line, pre, mark, post) => {
if (i++ === index) {
replaced = true
return pre + (mark === ' ' ? 'x' : ' ') + post
}
return line
})
return replaced ? out : null
}
// «90» → «1 ч 30 м»; для чипов оценок времени
export function formatMinutes(minutes: number | null): string {
if (minutes === null) return ''
const h = Math.floor(minutes / 60)
const m = minutes % 60
if (h && m) return t('taskui.time.hm', { h, m })
if (h) return t('taskui.time.h', { h })
return t('taskui.time.m', { m })
}
// Валюта бюджетов — глобальная настройка, выбирается один раз и применяется всюду
export function currencyOptions(): { value: string; label: string }[] {
return ['UAH', 'USD', 'EUR', 'GBP', 'PLN'].map((c) => ({
value: c,
label: t(`taskui.currency.${c}`),
}))
}
const CURRENCY_SYMBOLS: Record<string, string> = {
UAH: '₴',
USD: '$',
EUR: '€',
GBP: '£',
PLN: 'zł',
}
let activeCurrency = 'UAH'
export function setCurrency(currency: string): void {
activeCurrency = currency
}
export function currencySymbol(): string {
return CURRENCY_SYMBOLS[activeCurrency] ?? activeCurrency
}
// «5 000 ₴» — суммы задач; валюта берётся из глобальной настройки
export function formatMoney(amount: number | null): string {
if (amount === null) return ''
return `${amount.toLocaleString(intlLocale())} ${currencySymbol()}`
}
// --- Дедлайны и регулярность (ТЗ 3.4, 3.5) ---
export const DEADLINE_PERIOD_VALUES = ['day', 'week', 'month', 'year'] as const
export function deadlinePeriodOptions(): { value: string; label: string }[] {
return DEADLINE_PERIOD_VALUES.map((p) => ({ value: p, label: t(`taskui.deadline.${p}`) }))
}
export const RECUR_KIND_VALUES = ['interval', 'weekdays', 'monthly'] as const
export function recurKindOptions(): { value: string; label: string }[] {
return RECUR_KIND_VALUES.map((k) => ({
value: k,
label: t(`taskui.recur.kind.${k}`),
}))
}
// Короткое имя дня недели через Intl: 2024-01-01 — понедельник (ISO: пн=1..вс=7)
export function weekdayShort(isoWeekday: number): string {
const d = new Date(2024, 0, 7 + isoWeekday)
return new Intl.DateTimeFormat(intlLocale(), { weekday: 'short' }).format(d)
}
// Дата дедлайна в локали: ru → «19.09.2026», en → «09/19/2026».
// Дата без времени (YYYY-MM-DD) — локальная полночь: new Date('2026-10-15') это
// UTC-полночь, и западнее UTC день показывался бы вчерашним. Полные таймстемпы
// (created_at, done_at, approved_at) парсятся как раньше
export function formatDate(isoDate: string): string {
return new Intl.DateTimeFormat(intlLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(parseIsoDate(isoDate))
}
function parseIsoDate(value: string): Date {
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00`) : new Date(value)
}
// Время суток в локали («14:30» / «2:30 PM») — для сроков, живущих часами:
// аренда задачи агентом (ТЗ 3.20), записи журнала
export function formatTime(iso: string): string {
return new Intl.DateTimeFormat(intlLocale(), { hour: '2-digit', minute: '2-digit' }).format(
parseIsoDate(iso),
)
}
// Дата со временем — журнал: записи идут одна за другой, и одних суток мало
export function formatDateTime(iso: string): string {
return new Intl.DateTimeFormat(intlLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(parseIsoDate(iso))
}
// Подпись даты. У регулярной та же `deadline_date` — не срок, а дата следующего
// экземпляра (0.91, ТЗ 3.5), и «до 11.10» читалось бы как «успей к»: ветка для
// повторяющихся задач говорит «след. 11.10». Ветка одна на всех вызывающих —
// бейдж в списке, карточка на главной и промпт агенту берут её отсюда
export function deadlineLabel(task: {
task_type: string
deadline_date: string | null
deadline_period: string | null
}): string {
if (task.deadline_date) {
const key = task.task_type === 'recurring' ? 'taskui.recur.next' : 'taskui.deadline.until'
return t(key, { date: formatDate(task.deadline_date) })
}
const period = task.deadline_period ?? ''
return i18n.global.te(`taskui.deadline.${period}`)
? t(`taskui.deadline.${period}`)
: ''
}
// Дней до дедлайна (0.66): отрицательное — просрочено. Считаем по календарным суткам
// в Date.UTC от локальных Г/М/Д: toISOString с ночи до полуночи (UTC впереди) называет
// вчерашним днём ещё не наступивший дедлайн, а Date.UTC снимает вопрос часовых поясов
// и перехода на летнее время разом
export function daysUntil(isoDate: string): number {
const [year, month, day] = isoDate.slice(0, 10).split('-').map(Number)
const now = new Date()
const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())
return Math.round((Date.UTC(year, month - 1, day) - today) / 86_400_000)
}
export function deadlineOverdue(task: { deadline_date: string | null }): boolean {
return task.deadline_date !== null && daysUntil(task.deadline_date) < 0
}
// Отсчёт до дедлайна словами (0.66): «сегодня», «завтра», «через 3 дня»,
// «просрочено на 5 дней». Формы числа — общие с подписью свежести (0.69): выбор
// по правилу локали, «дн.» больше не нужно
export function deadlineCountdown(isoDate: string): string {
const days = daysUntil(isoDate)
if (days === 0) return t('taskui.countdown.today')
if (days === 1) return t('taskui.countdown.tomorrow')
if (days < 0) return tn('taskui.countdown.overdueDays', -days)
return tn('taskui.countdown.inDays', days)
}
// Свежесть словами (0.69): «Сегодня», «Вчера», «3 дня назад», «2 недели назад»,
// «месяц назад», «4 месяца назад», «год назад» — для подвала карточки проекта.
// Сутки считаем календарные и по локальным Г/М/Д в Date.UTC (как daysUntil):
// иначе ночью метка перескакивает на день раньше. Ниже недели — дни, дальше
// округление вниз до крупной единицы: 45 дней — «месяц назад», 400 — «год назад»
export function timeAgo(epochMs: number | null): string {
if (!epochMs) return ''
const then = new Date(epochMs)
const now = new Date()
const from = Date.UTC(then.getFullYear(), then.getMonth(), then.getDate())
const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())
// метка из будущего (часовые пояса) — считаем сегодняшней, «-1 день назад» хуже
const days = Math.max(0, Math.round((today - from) / 86_400_000))
if (days === 0) return t('taskui.ago.today')
if (days === 1) return t('taskui.ago.yesterday')
if (days < 7) return tn('taskui.ago.days', days)
// Единственное число — без числа, как принято в речи: «месяц назад» (у en
// число остаётся: «1 month ago»), поэтому у недель/месяцев/лет своя форма
// на n = 1; у дней её не нужно — сутки с единицей уже «Вчера»
const unit = (key: string, size: number) => {
const n = Math.floor(days / size)
return n === 1 ? t(`taskui.ago.${key}One`) : tn(`taskui.ago.${key}`, n)
}
if (days < 30) return unit('weeks', 7)
if (days < 365) return unit('months', 30)
return unit('years', 365)
}
export function recurrenceLabel(task: {
recur_kind: string | null
recur_interval_days: number | null
recur_weekdays: string | null
recur_day_of_month: number | null
}): string {
if (task.recur_kind === 'interval') {
const step = task.recur_interval_days ?? 1
if (step === 1) return t('taskui.recur.daily')
if (step === 7) return t('taskui.recur.weekly')
return t('taskui.recur.everyN', { n: step })
}
if (task.recur_kind === 'weekdays') {
if (!task.recur_weekdays) return t('taskui.recur.byWeekdays')
return task.recur_weekdays
.split(',')
.map((d) => weekdayShort(Number(d)))
.join(' ')
}
if (task.recur_kind === 'monthly') {
return t('taskui.recur.monthlyDay', { day: task.recur_day_of_month ?? '' })
}
return ''
}
// «бюджет 5 000 ₴ / оценка 2 000 ₴» — бейджи в видах
export function budgetBadge(task: { budget_money: number | null; cost_estimate_money: number | null }): string {
if (task.budget_money === null) return ''
const parts = [t('taskui.money.budget', { amount: formatMoney(task.budget_money) })]
if (task.cost_estimate_money !== null) {
parts.push(t('taskui.money.estimate', { amount: formatMoney(task.cost_estimate_money) }))
}
return parts.join(' / ')
}
// Подписи чернового предложения LLM (стек, страница задачи)
export function proposalItems(task: {
ai_proposal: import('./api').AiProposal | null
}): { icon: string; label: string }[] {
const p = task.ai_proposal
if (!p) return []
const items: { icon: string; label: string }[] = []
if (p.title) items.push({ icon: 'ph-text-t', label: p.title })
p.tags.forEach((tag) => items.push({ icon: 'ph-tag', label: tag }))
if (p.project)
items.push({
icon: 'ph-folder',
label: p.new_project ? t('stack.proposalNewProject', { name: p.project }) : p.project,
})
if (p.priority != null)
items.push({ icon: 'ph-flag', label: priorityLabel(p.priority) })
if (p.estimated_minutes !== null && p.estimated_minutes !== undefined)
items.push({ icon: 'ph-timer', label: `≈ ${formatMinutes(p.estimated_minutes)}` })
return items
}