diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index 090a059..9138e17 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -85,6 +85,42 @@ export const ru: Dict = { 'dashboard.meeting.title': 'Не дёргаем — ты на встрече', 'dashboard.meeting.hint': 'Запущен Zoom / Teams / Discord / Webex / Slack-huddle. Напоминания возобновятся когда закроешь.', + 'dashboard.plan.title': 'План дня', + 'dashboard.plan.subtitle': 'Следующее действие и дневные цели', + 'dashboard.plan.due_count': '{n} ждёт', + 'dashboard.plan.all_caught_up': 'всё спокойно', + 'dashboard.plan.next_action': 'Следующее действие', + 'dashboard.plan.kind.exercise': 'упражнение', + 'dashboard.plan.kind.meal': 'питание', + 'dashboard.plan.due_now': 'можно сделать сейчас', + 'dashboard.plan.next_in': 'через {time}', + 'dashboard.plan.paused': 'напоминания на паузе', + 'dashboard.plan.meal_time': 'в {time}', + 'dashboard.plan.done_now': 'Сделал', + 'dashboard.plan.ate_now': 'Поел', + 'dashboard.plan.clear.title': 'На сегодня чисто', + 'dashboard.plan.clear.hint': 'Можно отдохнуть или добавить новое действие', + 'dashboard.plan.goals': 'Дневные цели', + 'dashboard.plan.goals.progress': '{done}/{goal}', + 'dashboard.plan.goals.remaining': 'осталось {n}', + 'dashboard.plan.goals.hint': 'прогресс по упражнениям с дневной целью', + 'dashboard.plan.goals.empty': + 'Добавь дневную цель в упражнении, чтобы видеть прогресс', + 'dashboard.plan.meals': 'Питание', + 'dashboard.plan.meals.progress': '{done}/{total}', + 'dashboard.plan.recovery': 'Режим', + 'dashboard.plan.recovery.first.title': 'Первый шаг', + 'dashboard.plan.recovery.first.hint': 'Начни с одного лёгкого действия', + 'dashboard.plan.recovery.return.title': 'Мягкий возврат', + 'dashboard.plan.recovery.return.hint': + '{n} дн. без действий — начни с минимума', + 'dashboard.plan.recovery.steady.title': 'Ритм держится', + 'dashboard.plan.recovery.steady.today': 'сегодня уже есть действие', + 'dashboard.plan.recovery.steady.yesterday': 'вчера был активный день', + 'dashboard.plan.recovery.steady.none': 'держим спокойный темп', + 'dashboard.plan.up_next': 'Дальше', + 'dashboard.plan.item.remaining': 'осталось {n}', + 'dashboard.plan.item.reps': '{n} раз', 'dashboard.empty.title': 'Программа пуста', 'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать', @@ -268,9 +304,11 @@ export const ru: Dict = { 'updater.available.title': 'Доступна v{v}', 'updater.downloading.title': 'Загружаем обновление', 'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с', - 'updater.downloading.hint': 'Можно закрыть это окно — скачивание продолжится в фоне.', + 'updater.downloading.hint': + 'Можно закрыть это окно — скачивание продолжится в фоне.', 'updater.downloaded.title': 'Готово · v{v}', - 'updater.downloaded.subtitle': 'Нажми «Рестарт» — приложение моментально откроется в новой версии.', + 'updater.downloaded.subtitle': + 'Нажми «Рестарт» — приложение моментально откроется в новой версии.', 'updater.error.title': 'Ошибка проверки', 'updater.idle.title': 'Проверить обновления', 'updater.idle.subtitle': 'Авто-проверка раз в час', @@ -446,6 +484,42 @@ export const en: Dict = { 'dashboard.meeting.hint': 'Zoom / Teams / Discord / Webex / Slack-huddle is running. Reminders resume when you close it.', 'dashboard.paused.hint': 'Resume to continue countdown', + 'dashboard.plan.title': 'Day plan', + 'dashboard.plan.subtitle': 'Next action and daily goals', + 'dashboard.plan.due_count': '{n} due', + 'dashboard.plan.all_caught_up': 'all clear', + 'dashboard.plan.next_action': 'Next action', + 'dashboard.plan.kind.exercise': 'exercise', + 'dashboard.plan.kind.meal': 'meal', + 'dashboard.plan.due_now': 'ready now', + 'dashboard.plan.next_in': 'in {time}', + 'dashboard.plan.paused': 'reminders paused', + 'dashboard.plan.meal_time': 'at {time}', + 'dashboard.plan.done_now': 'Done', + 'dashboard.plan.ate_now': 'Ate', + 'dashboard.plan.clear.title': 'Clear for today', + 'dashboard.plan.clear.hint': 'Rest or add another action', + 'dashboard.plan.goals': 'Daily goals', + 'dashboard.plan.goals.progress': '{done}/{goal}', + 'dashboard.plan.goals.remaining': '{n} left', + 'dashboard.plan.goals.hint': 'progress across exercises with daily goals', + 'dashboard.plan.goals.empty': + 'Add a daily goal to an exercise to see progress', + 'dashboard.plan.meals': 'Meals', + 'dashboard.plan.meals.progress': '{done}/{total}', + 'dashboard.plan.recovery': 'Mode', + 'dashboard.plan.recovery.first.title': 'First step', + 'dashboard.plan.recovery.first.hint': 'Start with one easy action', + 'dashboard.plan.recovery.return.title': 'Gentle return', + 'dashboard.plan.recovery.return.hint': + '{n} days without actions — start small', + 'dashboard.plan.recovery.steady.title': 'Rhythm holding', + 'dashboard.plan.recovery.steady.today': 'you already logged one today', + 'dashboard.plan.recovery.steady.yesterday': 'yesterday stayed active', + 'dashboard.plan.recovery.steady.none': 'keep a calm pace', + 'dashboard.plan.up_next': 'Up next', + 'dashboard.plan.item.remaining': '{n} left', + 'dashboard.plan.item.reps': '{n} reps', 'dashboard.empty.title': 'Program is empty', 'dashboard.empty.hint': 'Add your first exercise to start', @@ -629,9 +703,11 @@ export const en: Dict = { 'updater.available.title': 'v{v} available', 'updater.downloading.title': 'Downloading update', 'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s', - 'updater.downloading.hint': 'You can close this window — download continues in the background.', + 'updater.downloading.hint': + 'You can close this window — download continues in the background.', 'updater.downloaded.title': 'Ready · v{v}', - 'updater.downloaded.subtitle': 'Click Restart — the app will reopen instantly in the new version.', + 'updater.downloaded.subtitle': + 'Click Restart — the app will reopen instantly in the new version.', 'updater.error.title': 'Check failed', 'updater.idle.title': 'Check for updates', 'updater.idle.subtitle': 'Auto-check every hour', diff --git a/src/renderer/src/lib/day-plan.test.ts b/src/renderer/src/lib/day-plan.test.ts new file mode 100644 index 0000000..f2d32be --- /dev/null +++ b/src/renderer/src/lib/day-plan.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import type { Exercise, HistoryEntry, Meal } from '@shared/types' +import { computeTodayPlan } from './day-plan' + +const NOW = new Date(2026, 5, 6, 12, 0, 0, 0).getTime() +const HOUR = 60 * 60 * 1000 +const DAY = 24 * HOUR + +function exercise(partial: Partial & { id: string }): Exercise { + return { + id: partial.id, + name: partial.name ?? partial.id, + reps: partial.reps ?? 10, + icon: partial.icon ?? 'Activity', + intervalMinutes: partial.intervalMinutes ?? 30, + enabled: partial.enabled ?? true, + nextFireAt: partial.nextFireAt ?? NOW + HOUR, + category: partial.category, + dailyGoal: partial.dailyGoal, + adaptive: partial.adaptive + } +} + +function meal(partial: Partial & { id: string }): Meal { + return { + id: partial.id, + name: partial.name ?? partial.id, + time: partial.time ?? '13:00', + icon: partial.icon ?? 'UtensilsCrossed', + enabled: partial.enabled ?? true, + days: partial.days ?? [], + nextFireAt: partial.nextFireAt ?? NOW + HOUR + } +} + +function done( + exerciseId: string, + ts = NOW, + reps?: number, + actualReps?: number +): HistoryEntry { + const entry: HistoryEntry = { exerciseId, ts, action: 'done' } + if (reps !== undefined) entry.reps = reps + if (actualReps !== undefined) entry.actualReps = actualReps + return entry +} + +describe('computeTodayPlan', () => { + it('summarises daily goals and puts due exercises first', () => { + const plan = computeTodayPlan({ + now: NOW, + exercises: [ + exercise({ id: 'pushups', dailyGoal: 30, nextFireAt: NOW - 1 }), + exercise({ id: 'water', dailyGoal: 2, reps: 1, nextFireAt: NOW + HOUR }) + ], + meals: [meal({ id: 'lunch', nextFireAt: NOW + 30 * 60_000 })], + history: [done('pushups', NOW - HOUR, 10)] + }) + + expect(plan.goalDone).toBe(10) + expect(plan.goalTarget).toBe(32) + expect(plan.goalRemaining).toBe(22) + expect(plan.dueCount).toBe(1) + expect(plan.nextItem?.id).toBe('pushups') + expect(plan.nextItem?.due).toBe(true) + }) + + it('removes completed daily goals from the action list', () => { + const plan = computeTodayPlan({ + now: NOW, + exercises: [ + exercise({ id: 'squats', dailyGoal: 20, nextFireAt: NOW - HOUR }) + ], + meals: [], + history: [done('squats', NOW - HOUR, 20)] + }) + + expect(plan.goalRemaining).toBe(0) + expect(plan.items).toHaveLength(0) + expect(plan.nextItem).toBeUndefined() + }) + + it('tracks meal completion via meal history entries', () => { + const plan = computeTodayPlan({ + now: NOW, + exercises: [], + meals: [ + meal({ id: 'breakfast', nextFireAt: NOW - HOUR }), + meal({ id: 'lunch', nextFireAt: NOW + HOUR }) + ], + history: [done('meal:breakfast', NOW - 2 * HOUR, 1)] + }) + + expect(plan.enabledMeals).toBe(2) + expect(plan.doneMeals).toBe(1) + expect(plan.remainingMeals).toBe(1) + expect(plan.items.map((item) => item.id)).toEqual(['lunch']) + }) + + it('enters recovery mode after two inactive days', () => { + const plan = computeTodayPlan({ + now: NOW, + exercises: [exercise({ id: 'a' })], + meals: [], + history: [done('a', NOW - 3 * DAY)] + }) + + expect(plan.recovery).toEqual({ kind: 'recovery', daysSinceDone: 3 }) + }) + + it('uses first-run state when no done history exists', () => { + const plan = computeTodayPlan({ + now: NOW, + exercises: [exercise({ id: 'a' })], + meals: [], + history: [] + }) + + expect(plan.recovery).toEqual({ kind: 'first-run' }) + }) + + it('ignores disabled exercises and meals', () => { + const plan = computeTodayPlan({ + now: NOW, + exercises: [exercise({ id: 'a', enabled: false, dailyGoal: 100 })], + meals: [meal({ id: 'm', enabled: false })], + history: [] + }) + + expect(plan.enabledExercises).toBe(0) + expect(plan.enabledMeals).toBe(0) + expect(plan.goalTarget).toBe(0) + expect(plan.items).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/lib/day-plan.ts b/src/renderer/src/lib/day-plan.ts new file mode 100644 index 0000000..f8a3388 --- /dev/null +++ b/src/renderer/src/lib/day-plan.ts @@ -0,0 +1,187 @@ +import type { + Exercise, + HistoryEntry, + Meal, + ReminderCategory +} from '@shared/types' +import { dayKey } from './history' + +export type PlanItemKind = 'exercise' | 'meal' + +export type PlanItem = { + kind: PlanItemKind + id: string + name: string + icon: string + nextFireAt: number + due: boolean + doneToday: boolean + category?: ReminderCategory + reps?: number + goal?: number + doneReps?: number + remainingReps?: number + time?: string +} + +export type RecoveryState = + | { kind: 'first-run' } + | { kind: 'recovery'; daysSinceDone: number } + | { kind: 'steady'; daysSinceDone: number | null } + +export type TodayPlan = { + goalDone: number + goalTarget: number + goalRemaining: number + enabledExercises: number + enabledMeals: number + doneMeals: number + remainingMeals: number + dueCount: number + items: PlanItem[] + nextItem?: PlanItem + recovery: RecoveryState +} + +function localDayOrdinal(ts: number): number { + const d = new Date(ts) + return Math.floor( + Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000 + ) +} + +function doneRepsForExercise( + entries: HistoryEntry[], + exercise: Exercise, + today: string +): number { + let sum = 0 + for (const e of entries) { + if (e.action !== 'done') continue + if (e.exerciseId !== exercise.id) continue + if (dayKey(e.ts) !== today) continue + sum += e.actualReps ?? e.reps ?? exercise.reps + } + return sum +} + +function mealDoneToday( + entries: HistoryEntry[], + meal: Meal, + today: string +): boolean { + const id = `meal:${meal.id}` + return entries.some( + (e) => e.action === 'done' && e.exerciseId === id && dayKey(e.ts) === today + ) +} + +function computeRecovery(entries: HistoryEntry[], now: number): RecoveryState { + const latestDone = entries + .filter((e) => e.action === 'done' && e.ts <= now) + .reduce((latest, e) => { + if (latest === null) return e.ts + return e.ts > latest ? e.ts : latest + }, null) + + if (latestDone === null) return { kind: 'first-run' } + + const daysSinceDone = Math.max( + 0, + localDayOrdinal(now) - localDayOrdinal(latestDone) + ) + return daysSinceDone >= 2 + ? { kind: 'recovery', daysSinceDone } + : { kind: 'steady', daysSinceDone } +} + +function sortPlanItems(a: PlanItem, b: PlanItem): number { + if (a.due !== b.due) return a.due ? -1 : 1 + if (a.nextFireAt !== b.nextFireAt) return a.nextFireAt - b.nextFireAt + if (a.kind !== b.kind) return a.kind === 'exercise' ? -1 : 1 + return a.name.localeCompare(b.name) +} + +export function computeTodayPlan({ + exercises, + meals, + history, + now = Date.now() +}: { + exercises: Exercise[] + meals: Meal[] + history: HistoryEntry[] + now?: number +}): TodayPlan { + const today = dayKey(now) + const enabledExercises = exercises.filter((e) => e.enabled) + const enabledMeals = meals.filter((m) => m.enabled) + + let goalDone = 0 + let goalTarget = 0 + + const exerciseItems = enabledExercises + .map((exercise) => { + const doneReps = doneRepsForExercise(history, exercise, today) + const goal = + exercise.dailyGoal !== undefined && exercise.dailyGoal > 0 + ? exercise.dailyGoal + : undefined + const remainingReps = + goal !== undefined ? Math.max(0, goal - doneReps) : undefined + const complete = goal !== undefined && remainingReps === 0 + + if (goal !== undefined) { + goalTarget += goal + goalDone += Math.min(doneReps, goal) + } + + return { + kind: 'exercise', + id: exercise.id, + name: exercise.name, + icon: exercise.icon, + category: exercise.category ?? 'exercise', + reps: exercise.reps, + goal, + doneReps, + remainingReps, + doneToday: doneReps > 0, + due: !complete && exercise.nextFireAt <= now, + nextFireAt: exercise.nextFireAt + } + }) + .filter((item) => item.remainingReps !== 0) + + const mealItems = enabledMeals + .map((meal) => { + const doneToday = mealDoneToday(history, meal, today) + return { + kind: 'meal', + id: meal.id, + name: meal.name, + icon: meal.icon, + time: meal.time, + doneToday, + due: !doneToday && meal.nextFireAt <= now, + nextFireAt: meal.nextFireAt + } + }) + .filter((item) => !item.doneToday) + + const items = [...exerciseItems, ...mealItems].sort(sortPlanItems) + + return { + goalDone, + goalTarget, + goalRemaining: Math.max(0, goalTarget - goalDone), + enabledExercises: enabledExercises.length, + enabledMeals: enabledMeals.length, + doneMeals: enabledMeals.length - mealItems.length, + remainingMeals: mealItems.length, + dueCount: items.filter((item) => item.due).length, + items, + nextItem: items[0], + recovery: computeRecovery(history, now) + } +} diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index b98e553..9dab452 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -7,7 +7,11 @@ import { Flame, Activity, TrendingUp, - Video + Video, + CalendarCheck, + Target, + RotateCcw, + Check } from 'lucide-react' import { useAppStore } from '../store/appStore' import { ExerciseCard } from '../components/ExerciseCard' @@ -16,9 +20,20 @@ import { HistoryHeatmap } from '../components/HistoryHeatmap' import { AchievementsCard } from '../components/AchievementsCard' import { Button } from '../components/ui/Button' import { ConfirmModal } from '../components/ui/ConfirmModal' -import type { Exercise, GameStatus, HistoryEntry } from '@shared/types' +import type { + Exercise, + GameStatus, + HistoryEntry, + Language +} from '@shared/types' import { formatCountdown } from '../lib/format' -import { useT } from '../i18n' +import { useT, type TFn } from '../i18n' +import { Icon } from '../lib/icon' +import { + computeTodayPlan, + type PlanItem, + type TodayPlan +} from '../lib/day-plan' import { currentStreak, dailyReps, @@ -38,7 +53,9 @@ export default function Dashboard(): JSX.Element { // on every render — `state?.exercises ?? []` creates a fresh array each time // the parent re-renders even when nothing changed. const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises]) + const meals = useMemo(() => state?.meals ?? [], [state?.meals]) const settings = state?.settings + const [planActionKey, setPlanActionKey] = useState(null) // Игры: запрашиваем реальный статус (integrationActive + launchOption // applied), а не просто `state.gamesEnabled`. Без этого badge показывал @@ -51,13 +68,12 @@ export default function Dashboard(): JSX.Element { }, []) const gamesLive = games.some( (g) => - g.enabled && - g.integrationActive && - g.launchOptionStatus === 'applied' + g.enabled && g.integrationActive && g.launchOptionStatus === 'applied' ) // «Включена, но не готова» — отдельное состояние, в badge другой tone. const gamesEnabledButNotLive = games.some( - (g) => g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied') + (g) => + g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied') ) // Local history mirror. Перетягиваем (а) на mount, (б) при изменении @@ -109,6 +125,11 @@ export default function Dashboard(): JSX.Element { } }, [exercises, ticks]) + const plan = useMemo(() => { + void ticks + return computeTodayPlan({ exercises, meals, history }) + }, [exercises, meals, history, ticks]) + const paused = !settings?.globalEnabled function openCreate(): void { @@ -137,6 +158,17 @@ export default function Dashboard(): JSX.Element { if (!settings) return await window.api.updateSettings({ globalEnabled: !settings.globalEnabled }) } + async function handlePlanItemDone(item: PlanItem): Promise { + const key = `${item.kind}:${item.id}` + if (planActionKey !== null) return + setPlanActionKey(key) + try { + if (item.kind === 'meal') await window.api.markMealDone(item.id) + else await window.api.markDone(item.id) + } finally { + setPlanActionKey(null) + } + } const today = new Date().toLocaleDateString( lang === 'en' ? 'en-US' : 'ru-RU', @@ -211,7 +243,11 @@ export default function Dashboard(): JSX.Element { /> - {history.length > 0 && ( -
- - -
- )} - {paused && ( )} + void handlePlanItemDone(item)} + /> + + {history.length > 0 && ( +
+ + +
+ )} +
{exercises.map((ex) => ( @@ -348,6 +393,340 @@ export default function Dashboard(): JSX.Element { ) } +function TodayPlanPanel({ + plan, + paused, + lang, + t, + actionBusy, + onItemDone +}: { + plan: TodayPlan + paused: boolean + lang: Language + t: TFn + actionBusy: boolean + onItemDone: (item: PlanItem) => void +}): JSX.Element { + const nextItem = plan.nextItem + const upcoming = plan.items.slice(nextItem ? 1 : 0, nextItem ? 4 : 3) + const goalPct = + plan.goalTarget > 0 + ? Math.min(100, Math.round((plan.goalDone / plan.goalTarget) * 100)) + : 0 + const mealPct = + plan.enabledMeals > 0 + ? Math.min(100, Math.round((plan.doneMeals / plan.enabledMeals) * 100)) + : 0 + const recovery = recoveryCopy(plan, t) + + return ( +
+
+
+
+ +
+
+

+ {t('dashboard.plan.title')} +

+
+ {t('dashboard.plan.subtitle')} +
+
+
+
0 + ? 'bg-accent/12 text-accent' + : 'bg-success/12 text-success' + ].join(' ')} + > + {plan.dueCount > 0 + ? t('dashboard.plan.due_count', { n: plan.dueCount }) + : t('dashboard.plan.all_caught_up')} +
+
+ +
+
+
+ {t('dashboard.plan.next_action')} +
+ + {nextItem ? ( + <> +
+ +
+
+
+ {nextItem.name} +
+ {nextItem.due && ( + + )} +
+
+ {t(`dashboard.plan.kind.${nextItem.kind}`)} ·{' '} + {planItemMeta(nextItem, t)} +
+
+
+
+
+ {planItemTiming(nextItem, paused, lang, t)} +
+ +
+ + ) : ( +
+
+ {t('dashboard.plan.clear.title')} +
+
+ {t('dashboard.plan.clear.hint')} +
+
+ )} +
+ +
+
+ + {t('dashboard.plan.goals')} +
+
+
+ {plan.goalTarget > 0 + ? t('dashboard.plan.goals.progress', { + done: plan.goalDone, + goal: plan.goalTarget + }) + : '—'} +
+ {plan.goalTarget > 0 && ( +
+ {t('dashboard.plan.goals.remaining', { + n: plan.goalRemaining + })} +
+ )} +
+ +
+ {plan.goalTarget > 0 + ? t('dashboard.plan.goals.hint') + : t('dashboard.plan.goals.empty')} +
+ + {plan.enabledMeals > 0 && ( +
+
+
+ {t('dashboard.plan.meals')} +
+
+ {t('dashboard.plan.meals.progress', { + done: plan.doneMeals, + total: plan.enabledMeals + })} +
+
+ +
+ )} +
+ +
+
+ + {t('dashboard.plan.recovery')} +
+
+
+ +
+
+
+ {recovery.title} +
+
+ {recovery.hint} +
+
+
+
+
+ + {upcoming.length > 0 && ( +
+
+ {t('dashboard.plan.up_next')} +
+
+ {upcoming.map((item) => ( + + ))} +
+
+ )} +
+ ) +} + +function PlanItemGlyph({ item }: { item: PlanItem }): JSX.Element { + const dueClass = item.due + ? 'bg-accent text-white' + : item.kind === 'meal' + ? 'bg-success/12 text-success' + : 'bg-accent/12 text-accent' + + return ( +
+ +
+ ) +} + +function PlanListRow({ + item, + paused, + lang, + t +}: { + item: PlanItem + paused: boolean + lang: Language + t: TFn +}): JSX.Element { + return ( +
+ +
+
+ {item.name} +
+
+ {planItemMeta(item, t)} +
+
+
+ {planItemTiming(item, paused, lang, t)} +
+
+ ) +} + +function ProgressBar({ + pct, + tone +}: { + pct: number + tone: 'accent' | 'success' +}): JSX.Element { + return ( +
+
+
+ ) +} + +function planItemMeta(item: PlanItem, t: TFn): string { + if (item.kind === 'meal') { + return item.time + ? t('dashboard.plan.meal_time', { time: item.time }) + : t('dashboard.plan.kind.meal') + } + if (item.goal !== undefined) { + return t('dashboard.plan.item.remaining', { + n: item.remainingReps ?? 0 + }) + } + return t('dashboard.plan.item.reps', { n: item.reps ?? 1 }) +} + +function planItemTiming( + item: PlanItem, + paused: boolean, + lang: Language, + t: TFn +): string { + if (item.due) return t('dashboard.plan.due_now') + if (paused) return t('dashboard.plan.paused') + return t('dashboard.plan.next_in', { + time: formatCountdown(item.nextFireAt - Date.now(), lang) + }) +} + +function recoveryCopy( + plan: TodayPlan, + t: TFn +): { title: string; hint: string; tone: string } { + if (plan.recovery.kind === 'first-run') { + return { + title: t('dashboard.plan.recovery.first.title'), + hint: t('dashboard.plan.recovery.first.hint'), + tone: 'bg-info/12 text-info' + } + } + if (plan.recovery.kind === 'recovery') { + return { + title: t('dashboard.plan.recovery.return.title'), + hint: t('dashboard.plan.recovery.return.hint', { + n: plan.recovery.daysSinceDone + }), + tone: 'bg-warning/12 text-warning' + } + } + return { + title: t('dashboard.plan.recovery.steady.title'), + hint: + plan.recovery.daysSinceDone === 0 + ? t('dashboard.plan.recovery.steady.today') + : plan.recovery.daysSinceDone === 1 + ? t('dashboard.plan.recovery.steady.yesterday') + : t('dashboard.plan.recovery.steady.none'), + tone: 'bg-success/12 text-success' + } +} + function HeroStat({ tone, label,