feat(dashboard): add daily plan summary

This commit is contained in:
Codex
2026-06-06 13:31:06 +07:00
parent 8196bd3351
commit 925181a3b7
4 changed files with 796 additions and 19 deletions

View File

@@ -85,6 +85,42 @@ export const ru: Dict = {
'dashboard.meeting.title': 'Не дёргаем — ты на встрече', 'dashboard.meeting.title': 'Не дёргаем — ты на встрече',
'dashboard.meeting.hint': 'dashboard.meeting.hint':
'Запущен Zoom / Teams / Discord / Webex / Slack-huddle. Напоминания возобновятся когда закроешь.', 'Запущен 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.title': 'Программа пуста',
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать', 'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
@@ -268,9 +304,11 @@ export const ru: Dict = {
'updater.available.title': 'Доступна v{v}', 'updater.available.title': 'Доступна v{v}',
'updater.downloading.title': 'Загружаем обновление', 'updater.downloading.title': 'Загружаем обновление',
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с', 'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
'updater.downloading.hint': 'Можно закрыть это окно — скачивание продолжится в фоне.', 'updater.downloading.hint':
'Можно закрыть это окно — скачивание продолжится в фоне.',
'updater.downloaded.title': 'Готово · v{v}', 'updater.downloaded.title': 'Готово · v{v}',
'updater.downloaded.subtitle': 'Нажми «Рестарт» — приложение моментально откроется в новой версии.', 'updater.downloaded.subtitle':
'Нажми «Рестарт» — приложение моментально откроется в новой версии.',
'updater.error.title': 'Ошибка проверки', 'updater.error.title': 'Ошибка проверки',
'updater.idle.title': 'Проверить обновления', 'updater.idle.title': 'Проверить обновления',
'updater.idle.subtitle': 'Авто-проверка раз в час', 'updater.idle.subtitle': 'Авто-проверка раз в час',
@@ -446,6 +484,42 @@ export const en: Dict = {
'dashboard.meeting.hint': 'dashboard.meeting.hint':
'Zoom / Teams / Discord / Webex / Slack-huddle is running. Reminders resume when you close it.', 'Zoom / Teams / Discord / Webex / Slack-huddle is running. Reminders resume when you close it.',
'dashboard.paused.hint': 'Resume to continue countdown', '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.title': 'Program is empty',
'dashboard.empty.hint': 'Add your first exercise to start', 'dashboard.empty.hint': 'Add your first exercise to start',
@@ -629,9 +703,11 @@ export const en: Dict = {
'updater.available.title': 'v{v} available', 'updater.available.title': 'v{v} available',
'updater.downloading.title': 'Downloading update', 'updater.downloading.title': 'Downloading update',
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s', '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.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.error.title': 'Check failed',
'updater.idle.title': 'Check for updates', 'updater.idle.title': 'Check for updates',
'updater.idle.subtitle': 'Auto-check every hour', 'updater.idle.subtitle': 'Auto-check every hour',

View File

@@ -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<Exercise> & { 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<Meal> & { 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)
})
})

View File

@@ -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<number | null>((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<PlanItem>((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<PlanItem>((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)
}
}

View File

@@ -7,7 +7,11 @@ import {
Flame, Flame,
Activity, Activity,
TrendingUp, TrendingUp,
Video Video,
CalendarCheck,
Target,
RotateCcw,
Check
} from 'lucide-react' } from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard' import { ExerciseCard } from '../components/ExerciseCard'
@@ -16,9 +20,20 @@ import { HistoryHeatmap } from '../components/HistoryHeatmap'
import { AchievementsCard } from '../components/AchievementsCard' import { AchievementsCard } from '../components/AchievementsCard'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { ConfirmModal } from '../components/ui/ConfirmModal' 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 { 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 { import {
currentStreak, currentStreak,
dailyReps, dailyReps,
@@ -38,7 +53,9 @@ export default function Dashboard(): JSX.Element {
// on every render — `state?.exercises ?? []` creates a fresh array each time // on every render — `state?.exercises ?? []` creates a fresh array each time
// the parent re-renders even when nothing changed. // the parent re-renders even when nothing changed.
const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises]) const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises])
const meals = useMemo(() => state?.meals ?? [], [state?.meals])
const settings = state?.settings const settings = state?.settings
const [planActionKey, setPlanActionKey] = useState<string | null>(null)
// Игры: запрашиваем реальный статус (integrationActive + launchOption // Игры: запрашиваем реальный статус (integrationActive + launchOption
// applied), а не просто `state.gamesEnabled`. Без этого badge показывал // applied), а не просто `state.gamesEnabled`. Без этого badge показывал
@@ -51,13 +68,12 @@ export default function Dashboard(): JSX.Element {
}, []) }, [])
const gamesLive = games.some( const gamesLive = games.some(
(g) => (g) =>
g.enabled && g.enabled && g.integrationActive && g.launchOptionStatus === 'applied'
g.integrationActive &&
g.launchOptionStatus === 'applied'
) )
// «Включена, но не готова» — отдельное состояние, в badge другой tone. // «Включена, но не готова» — отдельное состояние, в badge другой tone.
const gamesEnabledButNotLive = games.some( const gamesEnabledButNotLive = games.some(
(g) => g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied') (g) =>
g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied')
) )
// Local history mirror. Перетягиваем (а) на mount, (б) при изменении // Local history mirror. Перетягиваем (а) на mount, (б) при изменении
@@ -109,6 +125,11 @@ export default function Dashboard(): JSX.Element {
} }
}, [exercises, ticks]) }, [exercises, ticks])
const plan = useMemo(() => {
void ticks
return computeTodayPlan({ exercises, meals, history })
}, [exercises, meals, history, ticks])
const paused = !settings?.globalEnabled const paused = !settings?.globalEnabled
function openCreate(): void { function openCreate(): void {
@@ -137,6 +158,17 @@ export default function Dashboard(): JSX.Element {
if (!settings) return if (!settings) return
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled }) await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
} }
async function handlePlanItemDone(item: PlanItem): Promise<void> {
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( const today = new Date().toLocaleDateString(
lang === 'en' ? 'en-US' : 'ru-RU', lang === 'en' ? 'en-US' : 'ru-RU',
@@ -211,7 +243,11 @@ export default function Dashboard(): JSX.Element {
/> />
<HeroStat <HeroStat
tone={ tone={
gamesLive ? 'success' : gamesEnabledButNotLive ? 'warning' : 'muted' gamesLive
? 'success'
: gamesEnabledButNotLive
? 'warning'
: 'muted'
} }
label={t('dashboard.stat.tracking')} label={t('dashboard.stat.tracking')}
value={ value={
@@ -239,13 +275,6 @@ export default function Dashboard(): JSX.Element {
/> />
</div> </div>
{history.length > 0 && (
<div className="mb-8 space-y-3">
<HistoryHeatmap history={history} exercises={exercises} />
<AchievementsCard history={history} exercises={exercises} />
</div>
)}
{paused && ( {paused && (
<motion.div <motion.div
initial={{ opacity: 0, y: -4 }} initial={{ opacity: 0, y: -4 }}
@@ -289,6 +318,22 @@ export default function Dashboard(): JSX.Element {
</motion.div> </motion.div>
)} )}
<TodayPlanPanel
plan={plan}
paused={paused}
lang={lang}
t={t}
actionBusy={planActionKey !== null}
onItemDone={(item) => void handlePlanItemDone(item)}
/>
{history.length > 0 && (
<div className="mb-8 space-y-3">
<HistoryHeatmap history={history} exercises={exercises} />
<AchievementsCard history={history} exercises={exercises} />
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
<AnimatePresence> <AnimatePresence>
{exercises.map((ex) => ( {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 (
<section className="mb-8 bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-2xl bg-accent/12 text-accent grid place-items-center shrink-0">
<CalendarCheck size={19} strokeWidth={2.5} />
</div>
<div className="min-w-0">
<h2 className="font-display text-[22px] font-bold leading-tight">
{t('dashboard.plan.title')}
</h2>
<div className="text-[14px] text-text/60 mt-0.5">
{t('dashboard.plan.subtitle')}
</div>
</div>
</div>
<div
className={[
'self-start sm:self-auto h-8 px-3 rounded-full inline-flex items-center text-[13px] font-semibold',
plan.dueCount > 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')}
</div>
</div>
<div className="mt-5 grid grid-cols-1 lg:grid-cols-[1.35fr_0.95fr_0.95fr] border-y border-hairline/35 divide-y lg:divide-y-0 lg:divide-x divide-hairline/35">
<div className="py-4 lg:pr-5 min-w-0">
<div className="text-[13px] text-text/55 font-semibold">
{t('dashboard.plan.next_action')}
</div>
{nextItem ? (
<>
<div className="mt-3 flex items-center gap-3 min-w-0">
<PlanItemGlyph item={nextItem} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="font-display text-[20px] leading-tight font-bold truncate">
{nextItem.name}
</div>
{nextItem.due && (
<span className="shrink-0 w-2 h-2 rounded-full bg-accent" />
)}
</div>
<div className="text-[13px] text-text/60 mt-1 truncate">
{t(`dashboard.plan.kind.${nextItem.kind}`)} ·{' '}
{planItemMeta(nextItem, t)}
</div>
</div>
</div>
<div className="mt-4 flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-[14px] text-text/70 flex-1 min-w-0">
{planItemTiming(nextItem, paused, lang, t)}
</div>
<Button
size="sm"
variant={nextItem.kind === 'meal' ? 'success' : 'filled'}
disabled={actionBusy}
onClick={() => onItemDone(nextItem)}
className="sm:w-auto w-full"
>
<Check size={14} strokeWidth={2.5} />
{nextItem.kind === 'meal'
? t('dashboard.plan.ate_now')
: t('dashboard.plan.done_now')}
</Button>
</div>
</>
) : (
<div className="mt-3 min-w-0">
<div className="font-display text-[20px] leading-tight font-bold">
{t('dashboard.plan.clear.title')}
</div>
<div className="text-[14px] text-text/60 mt-1">
{t('dashboard.plan.clear.hint')}
</div>
</div>
)}
</div>
<div className="py-4 lg:px-5 min-w-0">
<div className="flex items-center gap-2 text-[13px] text-text/55 font-semibold">
<Target size={14} strokeWidth={2.5} />
{t('dashboard.plan.goals')}
</div>
<div className="mt-3 flex items-end justify-between gap-3">
<div className="font-mono-num text-[24px] font-bold leading-none">
{plan.goalTarget > 0
? t('dashboard.plan.goals.progress', {
done: plan.goalDone,
goal: plan.goalTarget
})
: '—'}
</div>
{plan.goalTarget > 0 && (
<div className="text-[13px] text-text/60">
{t('dashboard.plan.goals.remaining', {
n: plan.goalRemaining
})}
</div>
)}
</div>
<ProgressBar pct={goalPct} tone="accent" />
<div className="text-[13px] text-text/58 mt-3">
{plan.goalTarget > 0
? t('dashboard.plan.goals.hint')
: t('dashboard.plan.goals.empty')}
</div>
{plan.enabledMeals > 0 && (
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="text-[13px] text-text/55 font-semibold">
{t('dashboard.plan.meals')}
</div>
<div className="font-mono-num text-[14px] font-bold">
{t('dashboard.plan.meals.progress', {
done: plan.doneMeals,
total: plan.enabledMeals
})}
</div>
</div>
<ProgressBar pct={mealPct} tone="success" />
</div>
)}
</div>
<div className="py-4 lg:pl-5 min-w-0">
<div className="flex items-center gap-2 text-[13px] text-text/55 font-semibold">
<RotateCcw size={14} strokeWidth={2.5} />
{t('dashboard.plan.recovery')}
</div>
<div className="mt-3 flex items-start gap-3">
<div
className={[
'w-9 h-9 rounded-xl grid place-items-center shrink-0',
recovery.tone
].join(' ')}
>
<RotateCcw size={17} strokeWidth={2.5} />
</div>
<div className="min-w-0">
<div className="font-display text-[20px] leading-tight font-bold">
{recovery.title}
</div>
<div className="text-[14px] text-text/60 mt-1">
{recovery.hint}
</div>
</div>
</div>
</div>
</div>
{upcoming.length > 0 && (
<div className="pt-4">
<div className="text-[13px] text-text/55 font-semibold mb-2">
{t('dashboard.plan.up_next')}
</div>
<div className="divide-y divide-hairline/35">
{upcoming.map((item) => (
<PlanListRow
key={`${item.kind}:${item.id}`}
item={item}
paused={paused}
lang={lang}
t={t}
/>
))}
</div>
</div>
)}
</section>
)
}
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 (
<div
className={[
'w-11 h-11 rounded-2xl grid place-items-center shrink-0',
dueClass
].join(' ')}
>
<Icon name={item.icon} size={20} />
</div>
)
}
function PlanListRow({
item,
paused,
lang,
t
}: {
item: PlanItem
paused: boolean
lang: Language
t: TFn
}): JSX.Element {
return (
<div className="py-3 flex items-center gap-3 min-w-0">
<PlanItemGlyph item={item} />
<div className="flex-1 min-w-0">
<div className="font-semibold text-[15px] leading-tight truncate">
{item.name}
</div>
<div className="text-[13px] text-text/58 mt-1 truncate">
{planItemMeta(item, t)}
</div>
</div>
<div className="text-[13px] text-text/62 shrink-0 max-w-[42%] truncate">
{planItemTiming(item, paused, lang, t)}
</div>
</div>
)
}
function ProgressBar({
pct,
tone
}: {
pct: number
tone: 'accent' | 'success'
}): JSX.Element {
return (
<div className="mt-3 h-2 rounded-full bg-hairline/35 overflow-hidden">
<div
className={[
'h-full rounded-full transition-all duration-300',
tone === 'accent' ? 'bg-accent' : 'bg-success'
].join(' ')}
style={{ width: `${pct}%` }}
/>
</div>
)
}
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({ function HeroStat({
tone, tone,
label, label,