diff --git a/src/renderer/src/components/ExerciseCard.tsx b/src/renderer/src/components/ExerciseCard.tsx
index 68783f5..701ada9 100644
--- a/src/renderer/src/components/ExerciseCard.tsx
+++ b/src/renderer/src/components/ExerciseCard.tsx
@@ -163,7 +163,7 @@ export function ExerciseCard({
diff --git a/src/renderer/src/components/HistoryHeatmap.tsx b/src/renderer/src/components/HistoryHeatmap.tsx
index 48c9322..9f92b8f 100644
--- a/src/renderer/src/components/HistoryHeatmap.tsx
+++ b/src/renderer/src/components/HistoryHeatmap.tsx
@@ -1,43 +1,57 @@
import { useMemo } from 'react'
import { dailyRepsRange } from '../lib/history'
-import type { Exercise, HistoryEntry, Language } from '@shared/types'
+import type { Exercise, HistoryEntry } from '@shared/types'
+import { translateN, useT } from '../i18n'
type Props = {
history: HistoryEntry[]
exercises: Exercise[]
days?: number
- lang: Language
}
/**
* GitHub-style contribution grid: weeks as columns, days-of-week as rows.
- * Intensity bucket from 0 to 4 based on relative reps within the window.
+ *
+ * Intensity bucket uses percentile-based thresholds (over non-zero days)
+ * rather than a flat ratio against the single max — so one outlier day
+ * doesn't blot out every normal day into the lowest bucket.
*/
export function HistoryHeatmap({
history,
exercises,
- days = 84, // 12 weeks
- lang
+ days = 84 // 12 weeks
}: Props): JSX.Element {
+ const { t, lang } = useT()
+
const cells = useMemo(
() => dailyRepsRange(history, exercises, days),
[history, exercises, days]
)
- const max = cells.reduce((m, c) => Math.max(m, c.reps), 0)
- // Bucket function — 0 for zero, 1-4 for low/med/high/peak.
+ // Percentile-based bucket thresholds over non-zero days. Stable when the
+ // user has one outlier (e.g. a 200-rep "catch up" day) — normal 10-rep
+ // days still spread across buckets 1..4 instead of all collapsing to 1.
+ const thresholds = useMemo(() => {
+ const nz = cells
+ .map((c) => c.reps)
+ .filter((n) => n > 0)
+ .sort((a, b) => a - b)
+ if (nz.length === 0) return null
+ const p = (q: number): number =>
+ nz[Math.min(nz.length - 1, Math.floor(q * nz.length))]
+ return { p25: p(0.25), p50: p(0.5), p85: p(0.85) }
+ }, [cells])
+
function bucket(n: number): number {
- if (n === 0) return 0
- if (max === 0) return 0
- const ratio = n / max
- if (ratio < 0.25) return 1
- if (ratio < 0.5) return 2
- if (ratio < 0.85) return 3
+ if (n === 0 || !thresholds) return 0
+ if (n <= thresholds.p25) return 1
+ if (n <= thresholds.p50) return 2
+ if (n <= thresholds.p85) return 3
return 4
}
- // Group cells into columns (weeks). Pad start so first column aligns to
- // its actual week (Mon-first). Memoised so monthLabels' deps are stable.
+ // Group cells into columns (weeks). Pad start so the first column aligns
+ // to its actual weekday (Mon-first).
const weeks = useMemo(() => {
const firstDay = cells[0]?.date ?? new Date()
const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon
@@ -53,10 +67,17 @@ export function HistoryHeatmap({
return out
}, [cells])
- const dayLabels =
- lang === 'en'
- ? ['Mon', '', 'Wed', '', 'Fri', '', 'Sun']
- : ['Пн', '', 'Ср', '', 'Пт', '', 'Вс']
+ // Day labels along the Y axis. Mon-first, only label every other day to
+ // keep the column narrow. Pulled from the i18n dict (index = Date.getDay()).
+ const dayLabels = [
+ t('weekday.short.1'), // Mon
+ '',
+ t('weekday.short.3'), // Wed
+ '',
+ t('weekday.short.5'), // Fri
+ '',
+ t('weekday.short.0') // Sun
+ ]
const monthLabels = useMemo(() => {
const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', {
@@ -68,7 +89,7 @@ export function HistoryHeatmap({
})
}, [weeks, lang])
- // Compress repeated month labels (only show on first week of the month)
+ // Show a month label only on the first week that lands inside it.
const monthLabelsCompressed = monthLabels.map((label, i) =>
label && label !== monthLabels[i - 1] ? label : ''
)
@@ -82,13 +103,16 @@ export function HistoryHeatmap({
[lang]
)
+ // Pluralised "{n} reps" / "{n} повторов" for the cell tooltip.
+ // Outside React state — needed inside the cell-render closure.
+ const repsLabel = (n: number): string =>
+ translateN(lang, 'heatmap.tooltip.reps', n)
+
return (
- {lang === 'en'
- ? 'Activity, last 12 weeks'
- : 'Активность за 12 недель'}
+ {t('heatmap.title')}
@@ -136,7 +160,7 @@ export function HistoryHeatmap({
return (