diff --git a/docs/TZ.md b/docs/TZ.md index dddc4ee..f281f33 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -189,7 +189,9 @@ (период листается стрелками назад/вперёд). Метрики — закрытые задачи, XP, созданные задачи, часы работы, «дни с результатом» — каждая со сравнением с **предыдущим аналогичным периодом** (дельта и проценты). Анимированный - график закрытых задач по бакетам периода (часы / дни недели / дни месяца). + **сравнительный** график закрытых задач по бакетам периода (часы / дни недели / + дни месяца): текущий и предыдущий период — пары столбиков. **Сетка активности** + «как в гитхабе» за полгода (цвет по закрытым задачам или XP, переключается). Данные считаются из `xp_events` и дат задач, без новых эндпоинтов. - **Справка «О саде»**: кнопка на странице сада открывает модалку с объяснением механик (XP, уровни, растения и редкости, ачивки, принцип «только позитив»). diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 9eedcc0..b06fe54 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -113,6 +113,13 @@ ofDays: 'of {n} days', totalVs: 'Completed this period: {n} · previous period: {m}', chartTitle: 'Completed tasks', + legendNow: 'Now', + legendPrev: 'Previous period', + heatmapTitle: 'Activity over 6 months', + heatTasks: 'Tasks', + heatXp: 'XP', + heatLess: 'less', + heatMore: 'more', emptyTitle: 'No data yet', emptyText: 'Complete a couple of tasks — dynamics with comparisons will appear here.', metric: { diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index a32d919..5d2cac3 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -114,6 +114,13 @@ ofDays: 'из {n} дн.', totalVs: 'За период закрыто: {n} · в предыдущий раз: {m}', chartTitle: 'Закрытые задачи', + legendNow: 'Сейчас', + legendPrev: 'Предыдущий период', + heatmapTitle: 'Активность за полгода', + heatTasks: 'Задачи', + heatXp: 'XP', + heatLess: 'меньше', + heatMore: 'больше', emptyTitle: 'Пока нет данных', emptyText: 'Закройте пару задач — и здесь появится динамика со сравнением.', metric: { diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index 47b2ac1..cae4a61 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -113,6 +113,13 @@ ofDays: 'з {n} дн.', totalVs: 'За період виконано: {n} · минулого разу: {m}', chartTitle: 'Виконані завдання', + legendNow: 'Зараз', + legendPrev: 'Попередній період', + heatmapTitle: 'Активність за півроку', + heatTasks: 'Завдання', + heatXp: 'XP', + heatLess: 'менше', + heatMore: 'більше', emptyTitle: 'Поки немає даних', emptyText: 'Закрийте пару завдань — і тут з’явиться динаміка з порівнянням.', metric: { diff --git a/frontend/src/views/StatsView.vue b/frontend/src/views/StatsView.vue index b33125c..d541075 100644 --- a/frontend/src/views/StatsView.vue +++ b/frontend/src/views/StatsView.vue @@ -87,10 +87,11 @@ start: Date end: Date count: number + prevCount: number // тот же бакет предыдущего аналогичного периода } -const buckets = computed(() => { - const { start, end } = bounds.value +// Построение бакетов по границам (переиспользуется для сравнения с прошлым периодом) +function buildBuckets(from: Date, to: Date): Bucket[] { const fmtDay = new Intl.DateTimeFormat(locale.value, { weekday: 'short', day: 'numeric' }) const fmtFull = new Intl.DateTimeFormat(locale.value, { day: 'numeric', @@ -98,40 +99,53 @@ hour: 'numeric', minute: '2-digit', }) + const count = (a: Date, b: Date) => + events.value.filter((e) => new Date(e.created_at) >= a && new Date(e.created_at) < b).length const out: Bucket[] = [] - const inRange = (e: XpEvent, from: Date, to: Date) => - new Date(e.created_at) >= from && new Date(e.created_at) < to if (period.value === 'day') { + const dayStart = startOfDay(from) for (let h = 0; h < 24; h++) { - const from = new Date(start.getTime() + h * 3600_000) - const to = new Date(from.getTime() + 3600_000) + const start = new Date(dayStart.getTime() + h * 3600_000) + const end = new Date(start.getTime() + 3600_000) out.push({ label: String(h).padStart(2, '0') + ':00', - tooltip: fmtFull.format(from), - start: from, - end: to, - count: events.value.filter((e) => inRange(e, from, to)).length, + tooltip: fmtFull.format(start), + start, + end, + count: count(start, end), + prevCount: 0, }) } return out } - let cursor = new Date(start.getTime()) - while (cursor < end) { - const from = new Date(cursor.getTime()) - const to = new Date(from.getTime() + 24 * 3600_000) + let cursor = new Date(from.getTime()) + while (cursor < to) { + const start = new Date(cursor.getTime()) + const end = new Date(start.getTime() + 24 * 3600_000) out.push({ - label: period.value === 'week' ? fmtDay.format(from) : String(from.getDate()), - tooltip: fmtFull.format(from), - start: from, - end: to, - count: events.value.filter((e) => inRange(e, from, to)).length, + label: period.value === 'week' ? fmtDay.format(start) : String(start.getDate()), + tooltip: fmtFull.format(start), + start, + end, + count: count(start, end), + prevCount: 0, }) - cursor = to + cursor = end } return out +} + +const buckets = computed(() => { + const { start, end } = bounds.value + const prev = buildBuckets(prevBounds.value.start, prevBounds.value.end) + // Сравнительный график: текущий и предыдущий период выровнены по позиции + // (час↔час, день недели↔день недели, число месяца↔число предыдущего месяца) + return buildBuckets(start, end).map((b, i) => ({ ...b, prevCount: prev[i]?.count ?? 0 })) }) -const bucketMax = computed(() => Math.max(1, ...buckets.value.map((b) => b.count))) +const bucketMax = computed(() => + Math.max(1, ...buckets.value.flatMap((b) => [b.count, b.prevCount])), +) // --- Метрики: значение за период + сравнение с предыдущим аналогичным --- @@ -262,6 +276,75 @@ return t('stats.totalVs', { n: cur, m: prev }) }) +// --- Сетка активности «как в гитхабе»: последние 26 недель, задачи или XP --- + +type HeatMode = 'tasks' | 'xp' +const heatMode = ref('tasks') +const HEAT_WEEKS = 26 + +const heatItems = computed(() => [ + { id: 'tasks', label: t('stats.heatTasks') }, + { id: 'xp', label: t('stats.heatXp') }, +]) + +function setHeatMode(id: string | number) { + heatMode.value = id as HeatMode +} + +// Уровень заливки 0–4 по интенсивности +function heatLevel(value: number, mode: HeatMode): number { + if (value <= 0) return 0 + if (mode === 'xp') { + if (value < 10) return 1 + if (value < 20) return 2 + if (value < 50) return 3 + return 4 + } + if (value === 1) return 1 + if (value <= 3) return 2 + if (value <= 6) return 3 + return 4 +} + +interface HeatCell { + level: number // −1 — будущее (не рисуем) + tooltip: string +} + +const heatmap = computed(() => { + const fmt = new Intl.DateTimeFormat(locale.value, { day: 'numeric', month: 'short' }) + const perDay = new Map() + events.value.forEach((e) => { + const key = startOfDay(new Date(e.created_at)).getTime() + const bucket = perDay.get(key) ?? { count: 0, xp: 0 } + bucket.count += 1 + bucket.xp += e.amount + perDay.set(key, bucket) + }) + const first = startOfWeek(new Date()) + first.setDate(first.getDate() - (HEAT_WEEKS - 1) * 7) + const now = new Date() + const weeks: HeatCell[][] = [] + for (let w = 0; w < HEAT_WEEKS; w++) { + const col: HeatCell[] = [] + for (let d = 0; d < 7; d++) { + const date = new Date(first.getFullYear(), first.getMonth(), first.getDate() + w * 7 + d) + if (date > now) { + col.push({ level: -1, tooltip: '' }) + continue + } + const b = perDay.get(date.getTime()) ?? { count: 0, xp: 0 } + const value = heatMode.value === 'tasks' ? b.count : b.xp + col.push({ + level: heatLevel(value, heatMode.value), + tooltip: `${fmt.format(date)} — ${b.count} · ${b.xp} XP`, + }) + } + weeks.push(col) + } + return weeks +}) + // Пусто, если за оба периода вообще нет событий и задач const isEmpty = computed(() => { const { start, end } = bounds.value @@ -349,22 +432,68 @@ /> - +