Newer
Older
gnexus-tasks / frontend / src / views / ProjectsView.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { CSSProperties } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'gnexus-ui-kit/vue'
import { api, type Project, type Task } from '../api'
import PriorityBadge from '../components/PriorityBadge.vue'
import ProjectMark from '../components/ProjectMark.vue'
import { notifyCreated } from '../gamification'
import { useRealtimeRefresh } from '../realtime'
import { deadlineCountdown, deadlineOverdue, daysUntil, formatDate, relevanceLabel, timeAgo } from '../taskui'
import QuickPrompt from '../components/QuickPrompt.vue'
import Pagination from '../components/Pagination.vue'

// Проекты (ТЗ 3.8): актуальность, приоритет, заметка с ресурсами.
// Карточка списка — только метрики (0.62): заметка целиком живёт на странице
// проекта, в списке она делала карточки разной высоты и мешала сравнивать.
// Карточка кликабельна целиком (0.63): кнопки «Открыть» нет, ссылка — обёртка
// вокруг GnCard, поэтому заголовок внутри — <span>. Плюс цвет-метка проекта.
// Прогресс на карточке — счётчики и клетки по задачам (0.64): полоса разбита
// насечками «одна клетка — одна задача» и залита цветом проекта.
// Метка проекта (0.65) — кружок и фавикон сайта одним блоком, тайтл карточки
// крупнее: проект узнаётся по картинке раньше, чем прочитано имя.
// Булавка и свежесть (0.66): закреплённые держатся секцией «Закреплённые» сверху,
// внутри групп порядок — по свежей активности задач; подвал карточки — отсчёт до
// ближайшего дедлайна словами, низ карточек в ряду выровнен.
// Подвал виден всегда (0.69): слева свежесть задач словами («3 дня назад»),
// справа ближайший срок, — у карточки появляется второй ярус метаданных.

const { t } = useI18n()
const toast = useToast()

const projects = ref<Project[]>([])
const tasks = ref<Task[]>([])
const error = ref('')
const loading = ref(false)

// Метрики карточки проекта (0.62): прогресс, «в работе», просрочка, ближайший
// дедлайн. Определения согласованы с полосой прогресса — она считает все задачи
// проекта, поэтому фильтр по detail_state здесь не применяем.
// activity (0.66) — время свежей активности, epoch ms для сортировки; null —
// у проекта без задач (такие всегда идут в конце своей группы)
type ProjectStats = {
  total: number
  done: number
  inProgress: number
  overdue: number
  nextDeadline: string | null
  activity: number | null
}

const NO_STATS: ProjectStats = {
  total: 0,
  done: 0,
  inProgress: 0,
  overdue: 0,
  nextDeadline: null,
  activity: null,
}

const projectStats = computed(() => {
  const map = new Map<number, ProjectStats>()
  tasks.value.forEach((task) => {
    if (!task.project) return
    const stats = map.get(task.project.id) ?? { ...NO_STATS }
    stats.total += 1
    if (task.status === 'done') stats.done += 1
    if (task.status === 'in_progress') stats.inProgress += 1
    // закрытые задачи ни просроченными, ни «ближайшим дедлайном» не считаем
    if (task.status !== 'done' && task.status !== 'cancelled') {
      if (deadlineOverdue(task)) stats.overdue += 1
      if (task.deadline_date && (!stats.nextDeadline || task.deadline_date < stats.nextDeadline)) {
        stats.nextDeadline = task.deadline_date
      }
    }
    // Свежесть (0.66): самая поздняя из созданных/закрытых задач проекта.
    // Правки самого проекта (заметка, цвет, статус) в активность не идут —
    // сортировка про работу над проектом, а не про правку его карточки
    const created = Date.parse(task.created_at) || 0
    const done = task.done_at ? Date.parse(task.done_at) || 0 : 0
    stats.activity = Math.max(stats.activity ?? 0, created, done)
    map.set(task.project.id, stats)
  })
  return map
})

// Метрики проекта: у проекта без задач — нули
function statsOf(id: number): ProjectStats {
  return projectStats.value.get(id) ?? NO_STATS
}

// Клеток больше этого числа не рисуем: клетка становится уже 4px и полоса
// рябит — у больших проектов она остаётся сплошной.
const CELL_MAX = 24

// Оформление полосы задаётся переменными на корне карточки, а не на самой
// полосе: кит в GnProgress ставит свой style после ...attrs, и внешний теряется.
function progressVars(row: { project: Project; stats: ProjectStats }): CSSProperties {
  const vars: CSSProperties = {}
  if (row.project.color) vars['--card-color'] = row.project.color
  if (row.stats.total >= 2) {
    vars['--task-cells'] = row.stats.total
    vars['--done-cells'] = row.stats.done
  }
  return vars
}

async function load() {
  loading.value = true
  error.value = ''
  try {
    ;[projects.value, tasks.value] = await Promise.all([api.listProjects(), api.listTasks()])
  } catch (e) {
    error.value = String(e)
  } finally {
    loading.value = false
  }
}

onMounted(load)

// Реактивность (ТЗ 3.14): счётчики зависят от задач, карточки — от проектов
useRealtimeRefresh(load, ['project.changed', 'project.deleted', 'task.changed', 'task.deleted'])

// Пагинация: по 30 проектов на страницу
const page = ref(1)

// Порядок (0.66): закреплённые → с задачами → без задач, внутри группы — по
// свежей активности задач, затем по id. Сортировка идёт ДО пагинации, иначе
// секции разъедутся по страницам и «Без задач» окажется в середине списка.
// Группы взаимоисключающие: закреплённый проект с задачами живёт только
// в «Закреплённых» (у булавки приоритет над наличием задач)
function byActivity(a: Project, b: Project): number {
  const left = statsOf(a.id).activity
  const right = statsOf(b.id).activity
  // пустая активность — в конец группы; сравнение числовое, done_at бывает null
  if (left !== right) return (right ?? -Infinity) - (left ?? -Infinity)
  return a.id - b.id
}

const orderedProjects = computed(() => {
  const groups: Project[][] = [[], [], []]
  projects.value.forEach((project) => {
    const group = project.pinned ? 0 : statsOf(project.id).total > 0 ? 1 : 2
    groups[group].push(project)
  })
  return groups.flatMap((group) => group.sort(byActivity))
})

const pagedProjects = computed(() =>
  orderedProjects.value.slice((page.value - 1) * 30, page.value * 30),
)

// Проект без задач терялся среди заполненных: одинаковый бейдж «закрыто 0/0»
// ничем не отличался от соседей. Такие проекты уходят в отдельную секцию внизу,
// закреплённые — в секцию сверху (0.66); заголовки показываем только когда
// закреплённые или пустые есть, иначе список выглядит как раньше.
// Счётчики и показ заголовков — по всему списку, а не по странице: иначе на
// второй странице заголовки то появляются, то исчезают.
// Метрики и отсчёт считаем здесь же, чтобы карточка не звала statsOf по пять раз.
const projectSections = computed(() => {
  const rows = pagedProjects.value.map((project) => {
    const stats = statsOf(project.id)
    return {
      project,
      stats,
      daysLeft: stats.nextDeadline ? daysUntil(stats.nextDeadline) : null,
      // отсчёт словами вместо даты (0.66); полная дата — в подсказке
      countdown: stats.nextDeadline ? deadlineCountdown(stats.nextDeadline) : '',
      deadlineTitle: stats.nextDeadline
        ? t('projects.nextDeadline', { date: formatDate(stats.nextDeadline) })
        : '',
      // Свежесть (0.69): подвал карточки есть всегда, поэтому в проекте без задач
      // строка пустая (активности по задачам там нет); полная дата — в подсказке
      activityAgo: timeAgo(stats.activity),
      activityTitle: stats.activity
        ? t('projects.lastActivity', {
            date: formatDate(new Date(stats.activity).toISOString()),
          })
        : '',
    }
  })
  const pinnedRows = rows.filter((row) => row.project.pinned)
  const withTasks = rows.filter((row) => !row.project.pinned && row.stats.total > 0)
  const withoutTasks = rows.filter((row) => !row.project.pinned && row.stats.total === 0)

  const pinnedTotal = projects.value.filter((project) => project.pinned).length
  const withoutTotal = projects.value.filter(
    (project) => !project.pinned && statsOf(project.id).total === 0,
  ).length
  const titled = pinnedTotal > 0 || withoutTotal > 0

  return [
    {
      key: 'pinned',
      titled,
      icon: 'ph-push-pin',
      badge: 'warning',
      label: t('projects.pinned'),
      count: pinnedTotal,
      items: pinnedRows,
    },
    {
      key: 'with',
      titled,
      icon: 'ph-folders',
      badge: 'info',
      label: t('projects.withTasks'),
      count: projects.value.length - pinnedTotal - withoutTotal,
      items: withTasks,
    },
    {
      key: 'without',
      titled,
      icon: 'ph-folder-dotted',
      badge: 'neutral',
      label: t('projects.withoutTasks'),
      count: withoutTotal,
      items: withoutTasks,
    },
  ].filter((section) => section.items.length > 0)
})

// Создание — модальное окно вместо window.prompt
const createOpen = ref(false)

async function createProject(name: string) {
  try {
    await api.createProject(name)
    toast.success({ title: t('common.created') })
    // наградный тост после основного (провайдер кита держит один тост)
    notifyCreated()
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Булавка (0.66): переворачиваем сразу — computed пересчитаются, и карточка
// тут же уезжает в «Закреплённые». Свою перезагрузку не зовём: SSE-эхо
// project.changed и так вызовет load(), а вместе получилось бы два запроса.
// Ответ сервера заменяет элемент массива (в нём нормализованные поля).
async function togglePin(project: Project) {
  const before = project.pinned
  project.pinned = !before
  // при закреплении карточка уходит в начало списка — открываем первую страницу,
  // чтобы переезд был виден (при откреплении страницу не трогаем)
  if (!before) page.value = 1
  try {
    const updated = await api.updateProject(project.id, { pinned: !before })
    const index = projects.value.findIndex((item) => item.id === project.id)
    if (index !== -1) projects.value[index] = updated
  } catch (e) {
    project.pinned = before
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

</script>

<template>
  <section>
    <GnPageHeader kicker="GNexus Tasks" :title="t('projects.title')">
      <template #meta>{{ t('projects.count', { n: projects.length }) }}</template>
      <template #actions>
        <GnLoader
          v-if="loading && projects.length"
          circle
          label=""
          :aria-label="t('common.loading')"
          class="refresh-loader"
        />
        <GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
          {{ t('projects.new') }}
        </GnButton>
      </template>
    </GnPageHeader>

    <GnAlert v-if="error" variant="error">{{ error }}</GnAlert>

    <!-- Скелетон карточек проектов: имя + ряд бейджей + блок прогресса,
         как в реальных -->
    <div v-if="loading && projects.length === 0" class="list-skeleton" aria-hidden="true">
      <div v-for="i in 3" :key="i" class="card">
        <div class="card-content">
          <!-- Шапка скелетона (0.67): имя и квадрат под булавку в правом верхнем
               углу — там же, где она у настоящей карточки -->
          <div class="sk-head">
            <span class="skeleton skeleton-title" />
            <span class="skeleton skeleton-square" />
          </div>
          <div class="sk-badges">
            <span v-for="j in 3" :key="j" class="skeleton skeleton-square" />
          </div>
          <div class="sk-progress">
            <span class="skeleton skeleton-line" style="width: 40%" />
            <span class="skeleton skeleton-line" />
          </div>
        </div>
        <!-- Подвал скелетона (0.66): строка свежести слева, место под отсчёт
             справа — как в настоящей карточке, где подвал есть всегда (0.69);
             иначе при загрузке карточки ниже, чем после отрисовки данных.
             С 0.81 это отдельная полоса карточки, как у настоящего подвала -->
        <footer class="card-footer sk-foot">
          <span class="skeleton skeleton-line" style="width: 35%" />
          <span class="skeleton skeleton-line" style="width: 20%" />
        </footer>
      </div>
    </div>

    <GnEmptyState
      v-if="!loading && projects.length === 0"
      icon="ph-folders"
      :title="t('projects.emptyTitle')"
      :text="t('projects.emptyText')"
    >
      <template #actions>
        <GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
          {{ t('projects.new') }}
        </GnButton>
      </template>
    </GnEmptyState>

    <div class="project-list">
      <template v-for="section in projectSections" :key="section.key">
        <div v-if="section.titled" class="list-section-title">
          <i :class="`ph ${section.icon}`" aria-hidden="true" />
          <span>{{ section.label }}</span>
          <GnBadge :variant="section.badge">
            {{ section.count }}
          </GnBadge>
        </div>
        <!-- Вся карточка — одна ссылка: заголовок внутри неё уже не ссылка
             (ссылка в ссылке невалидна), зато «открыть в новой вкладке»
             работает по правому клику в любом месте карточки.
             Булавка — сосед ссылки, а не её потомок: интерактив внутри <a>
             так же невалиден, поэтому обёрткой служит .card-wrap (0.66) -->
        <div v-for="row in section.items" :key="row.project.id" class="card-wrap">
          <RouterLink class="project-card" :to="`/projects/${row.project.id}`">
            <GnCard :style="progressVars(row)">
              <template #title>
                <!-- Актуальность — тихий статус в шапке справа, чтобы ряд ниже
                     занимали только изменчивые метрики -->
                <span class="card-name">
                  <!-- Метка (0.65): кружок проекта + фавикон сайта — ссылку внутрь
                       карточки не вставляем, карточка целиком сама ссылка -->
                  <ProjectMark
                    :color="row.project.color"
                    :project-id="row.project.id"
                    :site-url="row.project.site_url"
                  />{{ row.project.name }}
                </span>
                <GnBadge
                  class="relevance-badge"
                  :variant="row.project.relevance_status === 'active' ? 'info' : 'neutral'"
                >
                  <i class="ph ph-activity" aria-hidden="true" />{{ relevanceLabel(row.project.relevance_status) }}
                </GnBadge>
              </template>

              <!-- Метрики: приоритет, «в работе», просрочка. Нулевые не показываем —
                   иначе ряд бейджей шумит одинаковыми «0» на каждой карточке -->
              <div class="project-meta">
                <PriorityBadge v-if="row.project.priority !== null" :priority="row.project.priority" />
                <GnBadge v-if="row.stats.inProgress" variant="accent">
                  <i class="ph ph-circle-half" aria-hidden="true" />{{ t('projects.inProgress', { n: row.stats.inProgress }) }}
                </GnBadge>
                <GnBadge v-if="row.stats.overdue" variant="error">
                  <i class="ph ph-alarm" aria-hidden="true" />{{ t('projects.overdue', { n: row.stats.overdue }) }}
                </GnBadge>
              </div>

              <!-- Прогресс по задачам проекта: счётчик «✓ закрыто / всего»,
                   процент кита и полоса клетками — одна клетка это одна задача
                   (0.64). У проекта без задач — подсказка, иначе на карточке
                   остаётся дыра на месте полосы -->
              <GnProgress
                v-if="row.stats.total > 0"
                class="project-progress"
                :class="{
                  celled: row.stats.total >= 2 && row.stats.total <= CELL_MAX,
                  'project-progress-done': row.stats.done === row.stats.total,
                }"
                :value="row.stats.done"
                :max="row.stats.total"
                :title="t('projects.progressLabel', { done: row.stats.done, total: row.stats.total })"
              >
                <template #label>
                  <span class="tasks-done">
                    <i class="ph ph-check-circle" aria-hidden="true" />{{ row.stats.done }}
                  </span>
                  <span class="tasks-sep">/</span>
                  <span class="tasks-total">{{ row.stats.total }}</span>
                </template>
              </GnProgress>
              <p v-else class="card-hint">{{ t('projects.noTasksYet') }}</p>

              <!-- Подвал карточки есть всегда (0.69): слева свежесть — когда последний
                   раз была активность по задачам («3 дня назад»), справа отсчёт до
                   ближайшего срока словами (0.66); полные даты — в подсказках.
                   С 0.81 это слот #footer кита, а не строка внутри содержимого:
                   так подвал отделён чертой во всю ширину карточки, как в карточке
                   задачи (общая полоса — в kit-overrides.css) -->
              <template #footer>
                <span v-if="row.activityAgo" class="card-activity" :title="row.activityTitle">
                  <i class="ph ph-clock-counter-clockwise normalize" aria-hidden="true" />
                  {{ row.activityAgo }}
                </span>
                <span
                  v-if="row.countdown"
                  class="card-deadline"
                  :class="{ late: row.daysLeft !== null && row.daysLeft < 0 }"
                  :title="row.deadlineTitle"
                >
                  <i class="ph ph-calendar-check normalize" aria-hidden="true" />
                  {{ row.countdown }}
                </span>
              </template>
            </GnCard>
          </RouterLink>
          <!-- Булавка: закреплённые держатся секцией сверху. Кит кладёт label
               в aria-label, aria-pressed проходит через ...attrs -->
          <GnIconButton
            class="card-pin"
            size="sm"
            :icon="row.project.pinned ? 'ph-push-pin-fill' : 'ph-push-pin'"
            :label="t(row.project.pinned ? 'projects.unpin' : 'projects.pin')"
            :title="t(row.project.pinned ? 'projects.unpin' : 'projects.pin')"
            :aria-pressed="row.project.pinned"
            @click="togglePin(row.project)"
          />
        </div>
      </template>
    </div>

    <Pagination v-model:page="page" :total="projects.length" />

    <QuickPrompt
      v-model:open="createOpen"
      :title="t('projects.prompt')"
      :label="t('projects.promptLabel')"
      :placeholder="t('projects.prompt')"
      :confirm-text="t('common.create')"
      @confirm="createProject"
    />

  </section>
</template>

<style scoped>
/* Карточки проектов — по три в ряд (0.68; две были в 0.61), кликабельны
   целиком (0.63): грид-элемент — обёртка .card-wrap (0.66: булавка рядом со
   ссылкой), поэтому min-width снимаем с обёртки, а не с .card;
   align-items: stretch (0.66) — карточки ряда одной высоты, низ совпадает;
   скелетон повторяет ту же сетку, чтобы загрузка не дёргала раскладку. */
.project-list,
.list-skeleton {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0.75rem;
  align-items: stretch;
}
.project-list > .card-wrap,
.list-skeleton > .card {
  min-width: 0;
}
/* Уже 1200 — трём карточкам тесно: в шапке имя, бейдж актуальности и булавка
   (40px запаса), на 380px колонки имя рвётся по слогам */
@media (max-width: 1199px) {
  .project-list,
  .list-skeleton {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
@media (max-width: 860px) {
  .project-list,
  .list-skeleton {
    grid-template-columns: minmax(0, 1fr);
  }
}
/* Подпись секции списка (паттерн стека): проекты с задачами и без — 0.60;
   в сетке заголовок занимает всю ширину строки */
.list-section-title {
  grid-column: 1 / -1;
  display: flex;
  gap: 0.6rem;
  align-items: center;
  margin: 0.75rem 0 0;
  font-weight: 600;
  font-size: 1.05rem;
}
.list-section-title:first-child {
  margin-top: 0;
}
.list-section-title > i {
  color: var(--accent, #7aa2f7);
}
/* Ссылка-обёртка всей карточки: тянется на ширину трека и на всю его высоту
   (0.66: карточки ряда растягиваются, подвал прижимается к низу), наследует
   цвет текста */
.project-card {
  display: block;
  height: 100%;
  min-width: 0;
  color: inherit;
  text-decoration: none;
  border-radius: 6px;
}
/* .card-wrap — грид-элемент: держит ссылку и булавку, задаёт им точку отсчёта */
.card-wrap {
  position: relative;
  min-width: 0;
}
/* Выравнивание подвала (0.66): карточка — колонка на всю высоту трека,
   содержимое забирает свободное место, подвал прижимается к низу.
   height: auto у .card-content обязателен: у кита там height: 100%, и на
   растянутой карточке содержимое переполнялось бы на высоту шапки */
.card-wrap :deep(.card) {
  display: flex;
  flex-direction: column;
  height: 100%;
}
.card-wrap :deep(.card-content) {
  display: flex;
  flex-direction: column;
  flex: 1;
  height: auto;
  min-height: 0;
}
.project-card:hover :deep(.card) {
  border-color: var(--accent, #7aa2f7);
}
.project-card:focus-visible {
  outline: 2px solid var(--accent, #7aa2f7);
  outline-offset: 2px;
}
/* Имя проекта с меткой (0.65: кружок + фавикон): переносится, а не выдавливает
   бейдж из шапки (у шапки кита flex, и без min-width заголовок не сжимается) */
.card-name {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
  overflow-wrap: anywhere;
}
/* Бейдж актуальности — справа в шапке: базовый `.card-title` кита это block,
   флекс в нём только у варианта status-card, поэтому раскладываем сами.
   Заодно размер тайтла (0.65): в ките у .card-title размера нет — берётся
   наследованием от body (14px), поэтому здесь он поднимается явно. */
.project-card :deep(.card-title) {
  display: flex;
  justify-content: space-between;
  gap: 0.75rem;
  align-items: center;
  font-size: 16px;
  /* Запас справа под булавку в углу (0.67): 0.5rem отступ + 28px кнопка */
  padding-right: 2.5rem;
}
/* Бейдж актуальности держим у верха шапки, а не по её центру (0.67): булавка
   стоит в углу карточки и не может лечь в шапку (вложенный интерактив внутри
   ссылки невалиден), поэтому общей точкой выравнивания служит центр первой
   строки имени — он не зависит от того, перенеслось ли имя на вторую строку.
   По центру шапки бейдж на перенесённом имени уезжал бы вниз от булавки.
   1px — чтобы центр бейджа (24px) встал ровно на центр булавки (top 1rem,
   28px → 30px от верха .card-wrap) */
.relevance-badge {
  flex: none;
  align-self: flex-start;
  margin-top: 1px;
}
.list-skeleton {
  margin-bottom: 1rem;
}
/* карточка-скелет: как реальная карточка проекта — тянется на ширину трека
   и на высоту ряда, содержимое колонкой (0.66 — подвал скелета внизу) */
.list-skeleton .card {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: none;
}
.list-skeleton .card-content {
  display: flex;
  flex-direction: column;
  flex: 1;
  height: auto;
}
/* строка-заготовка имени — под кегль тайтла карточки (16px), а не под китовые
   22px: загрузка не должна дёргать раскладку. Квадрат справа — под булавку
   в углу (кнопка btn-icon-sm кита), поэтому он в шапке, а не в подвале (0.67) */
.sk-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin-bottom: 10px;
}
.sk-head .skeleton-title {
  display: block;
  height: 24px;
  flex: 1;
}
.sk-head .skeleton-square,
.sk-foot .skeleton-square {
  width: 28px;
  height: 28px;
}
.sk-badges {
  display: flex;
  gap: 6px;
}
/* Блок прогресса в скелетоне — строка счётчиков и строка полосы */
.sk-progress {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 14px;
}
/* Подвал скелетона: строка отсчёта. Полоса целиком — та же, что у настоящего
   подвала (padding, черта, кегль и высота строки из kit-overrides.css), здесь
   только раскладка строки; иначе при загрузке карточки ниже, чем после
   отрисовки данных */
.sk-foot {
  justify-content: space-between;
  gap: 6px;
}
.project-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 0.75rem;
}
/* Полоса прогресса на всю ширину карточки (в ките .progress упёрт в max-width: 640px) */
.project-progress {
  max-width: 100%;
  margin-bottom: 0.75rem;
}
/* Трек тоньше китовских 18px (0.68): на карточке полоса — подложка под
   счётчиком «закрыто / всего», а не самостоятельный элемент. 8px вместе с
   рамкой кита (2px сверху и снизу) — 4px заливки */
.project-progress :deep(.progress-track) {
  height: 8px;
}
/* Счётчики задач: «✓ закрыто / всего» слева, процент кита справа. Закрытых
   показываем крупнее (галочка наследует цвет), всего — тише. Цвет проекта
   в подписях не берём: тёмные оттенки палитры (светлота от 36%, 0.72) нечитаемы
   как текст — им закрашена только полоса. */
.project-progress :deep(.tasks-done) {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  color: var(--gn-color-text-light, #c0caf5);
  font-size: 1.25em;
  font-weight: 700;
}
.project-progress :deep(.tasks-sep) {
  opacity: 0.5;
}
.project-progress :deep(.tasks-total) {
  font-size: 1.1em;
}
/* Заполнение — цветом-меткой проекта (0.63), у проекта без цвета — акцент кита */
.project-progress :deep(.progress-bar) {
  background: var(--card-color, var(--gn-color-secondary, #7aa2f7));
}
/* Проект закрыт целиком — полоса и счётчик уходят в зелёный кита */
.project-progress.project-progress-done :deep(.progress-bar) {
  background: var(--gn-color-success, #9ece6a);
}
.project-progress.project-progress-done :deep(.tasks-done) {
  color: var(--gn-color-success, #9ece6a);
}
/* Клетки по задачам: насечка на каждой границе клетки (шаг 100%/число задач).
   Слой — ::after трека, а не его фон: фон рисуется под заливкой, и насечки
   не резали бы закрытые клетки. */
.project-progress.celled :deep(.progress-track)::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: repeating-linear-gradient(
    90deg,
    transparent 0 calc(100% - 2px),
    var(--gn-color-black, #16161e) calc(100% - 2px) 100%
  );
  background-size: calc(100% / var(--task-cells)) 100%;
}
/* Заливка ровно по клеткам: кит считает ширину от процента с округлением
   до целого, и на границе клетки осталась бы щель в 1–2px */
.project-progress.celled :deep(.progress-bar) {
  width: calc(100% / var(--task-cells) * var(--done-cells));
}
/* Подсказка вместо полосы у проекта без задач */
.card-hint {
  margin: 0 0 0.75rem;
  color: var(--gn-color-text-medium, #a9b1d6);
  font-size: 0.85em;
}
/* Подвал карточки (0.66, всегда виден с 0.69): слева свежесть — активность по
   задачам, справа ближайший срок. Сама полоса подвала (черта, раскладка, размер
   текста, высота пустой строки) — общая с карточкой задачи, в kit-overrides.css
   (0.81) */
.card-activity,
.card-deadline {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
}
/* срок стоит справа: свежесть слева, важное — под ней же, в одном и том же
   углу у всех карточек, независимо от того, есть ли активность */
.card-deadline {
  margin-left: auto;
}
.card-deadline.late {
  color: var(--gn-color-error, #f7768e);
}
/* Булавка (0.66, переставлена в 0.67): правый верхний угол — важное действие,
   всегда на виду, рядом с бейджем актуальности. Шапке добавлен запас справа
   (см. .card-title), поэтому бейдж сдвигается левее и не заезжает под кнопку.
   В покое НЕ приглушаем: жалоба была именно на незаметность. Кит при наведении
   сам красит .btn-icon в янтарный, так что отдельного hover-цвета не нужно */
.card-pin {
  position: absolute;
  /* По вертикали — по бейджу актуальности (0.67): общая точка — центр первой
     строки имени, 29.8px от верха .card-wrap; кнопка 28px, значит top =
     29.8 - 14 */
  top: 1rem;
  right: 0.5rem;
  z-index: 1;
}
.card-pin[aria-pressed='true'] {
  color: var(--gn-color-warning, #e0af68);
}
</style>