Newer
Older
gnexus-tasks / frontend / src / taskui.ts
// Общие помощники 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
}