feat(dashboard): add daily plan summary
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
135
src/renderer/src/lib/day-plan.test.ts
Normal file
135
src/renderer/src/lib/day-plan.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
187
src/renderer/src/lib/day-plan.ts
Normal file
187
src/renderer/src/lib/day-plan.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user