feat(#7): категории напоминаний (exercise/hydration/eyes/posture)

This commit is contained in:
AnRil
2026-05-22 13:39:40 +07:00
parent 50c56fec79
commit 68998607e8
6 changed files with 117 additions and 11 deletions

View File

@@ -18,7 +18,8 @@ import type {
Settings,
Theme,
Language,
NotificationMode
NotificationMode,
ReminderCategory
} from '@shared/types'
const MAX_STR_LEN = 200
@@ -33,6 +34,12 @@ const VALID_STATS: GameStat[] = [
'denies',
'duration_min'
]
const VALID_CATEGORIES: ReminderCategory[] = [
'exercise',
'hydration',
'eyes',
'posture'
]
const HHMM_RE = /^\d{1,2}:\d{2}$/
function isObj(v: unknown): v is Record<string, unknown> {
@@ -84,6 +91,7 @@ export function validateExerciseInput(
const intervalMinutes = intInRange(raw.intervalMinutes, 1, 24 * 60)
const icon = safeStr(raw.icon, 64) ?? 'Activity'
const enabled = bool(raw.enabled) ?? true
const category = oneOf(raw.category, VALID_CATEGORIES) // undefined OK = default
if (
name === undefined ||
reps === undefined ||
@@ -91,7 +99,15 @@ export function validateExerciseInput(
) {
return null
}
return { name, reps, intervalMinutes, icon, enabled }
const out: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'> = {
name,
reps,
intervalMinutes,
icon,
enabled
}
if (category !== undefined) out.category = category
return out
}
export function validateExercisePatch(
@@ -124,6 +140,11 @@ export function validateExercisePatch(
if (v === undefined) return null
out.enabled = v
}
if ('category' in raw) {
const v = oneOf(raw.category, VALID_CATEGORIES)
if (v === undefined) return null
out.category = v
}
// Allow scheduler-controlled fields to be patched (used by store.markDone
// through this same boundary), but range-check them.
if ('nextFireAt' in raw) {

View File

@@ -223,7 +223,7 @@ function ExerciseReminder({
</motion.div>
<div className="text-[13px] uppercase tracking-[0.18em] text-accent font-bold">
{t('reminder.kicker')}
{t(`category.${exercise.category ?? 'exercise'}.cta`)}
</div>
<h1 className="font-serif text-[30px] leading-tight tracking-tight mt-2 mb-3 font-bold">
{exercise.name}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import type { Exercise } from '@shared/types'
import type { Exercise, ReminderCategory } from '@shared/types'
import { REMINDER_CATEGORIES } from '@shared/types'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { ICON_CHOICES, Icon } from '../lib/icon'
@@ -11,6 +12,7 @@ type Draft = {
icon: string
intervalMinutes: number
enabled: boolean
category: ReminderCategory
}
const EMPTY: Draft = {
@@ -18,7 +20,8 @@ const EMPTY: Draft = {
reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true
enabled: true,
category: 'exercise'
}
type Props = {
@@ -44,7 +47,8 @@ export function ExerciseEditor({
reps: exercise.reps,
icon: exercise.icon,
intervalMinutes: exercise.intervalMinutes,
enabled: exercise.enabled
enabled: exercise.enabled,
category: exercise.category ?? 'exercise'
})
} else {
setDraft(EMPTY)
@@ -101,6 +105,26 @@ export function ExerciseEditor({
/>
</Field>
<Field label={t('editor.field.category')}>
<div className="grid grid-cols-4 gap-2">
{REMINDER_CATEGORIES.map((c) => (
<button
key={c}
type="button"
onClick={() => setDraft({ ...draft, category: c })}
className={[
'h-10 px-2 rounded-xl text-[13px] font-semibold transition-all active:scale-95 truncate',
draft.category === c
? 'bg-accent text-white'
: 'bg-surface-2 text-text/65 hover:text-text'
].join(' ')}
>
{t(`category.${c}`)}
</button>
))}
</div>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t('editor.field.reps')}>
<input

View File

@@ -219,6 +219,17 @@ export const ru: Dict = {
'updater.idle.title': 'Проверить обновления',
'updater.idle.subtitle': 'Авто-проверка раз в час',
// Categories
'category.exercise': 'Упражнение',
'category.hydration': 'Гидратация',
'category.eyes': 'Отдых глазам',
'category.posture': 'Осанка',
'category.exercise.cta': 'Время тренировки',
'category.hydration.cta': 'Время попить',
'category.eyes.cta': 'Дай глазам отдохнуть',
'category.posture.cta': 'Проверь осанку',
'editor.field.category': 'Категория',
// Reminder window
'reminder.kicker': 'Время тренировки',
'reminder.subkicker': 'Двигайся',
@@ -484,6 +495,17 @@ export const en: Dict = {
'updater.idle.title': 'Check for updates',
'updater.idle.subtitle': 'Auto-check every hour',
// Categories
'category.exercise': 'Exercise',
'category.hydration': 'Hydration',
'category.eyes': 'Eye rest',
'category.posture': 'Posture',
'category.exercise.cta': 'Workout time',
'category.hydration.cta': 'Time to drink',
'category.eyes.cta': 'Rest your eyes',
'category.posture.cta': 'Check your posture',
'editor.field.category': 'Category',
// Reminder window
'reminder.kicker': 'Workout time',
'reminder.subkicker': 'Move',

View File

@@ -72,6 +72,7 @@ export default function Dashboard(): JSX.Element {
icon: string
intervalMinutes: number
enabled: boolean
category: import('@shared/types').ReminderCategory
}): Promise<void> {
if (editing) await window.api.updateExercise(editing.id, draft)
else await window.api.addExercise(draft)

View File

@@ -1,3 +1,20 @@
/**
* Категория напоминания. По умолчанию `exercise` — для совместимости со
* старыми state'ами (поле optional). Категория влияет на:
* - tint иконки в карточке (hydration синий, eyes фиолетовый и т.д.)
* - текст в окне напоминания («Время попить» вместо «Время тренировки»)
* - подсчёт повторений: для hydration/eyes/posture `reps` обычно = 1
* (это не «N раз», а просто «сделай»).
*/
export type ReminderCategory = 'exercise' | 'hydration' | 'eyes' | 'posture'
export const REMINDER_CATEGORIES: ReminderCategory[] = [
'exercise',
'hydration',
'eyes',
'posture'
]
export type Exercise = {
id: string
name: string
@@ -7,6 +24,8 @@ export type Exercise = {
enabled: boolean
nextFireAt: number
lastDoneAt?: number
/** Default 'exercise' если undefined — обратная совместимость. */
category?: ReminderCategory
}
export type NotificationMode = 'toast' | 'modal' | 'both'
@@ -253,21 +272,40 @@ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true
enabled: true,
category: 'exercise'
},
{
name: 'Отжимания',
reps: 10,
icon: 'Dumbbell',
intervalMinutes: 45,
enabled: true
enabled: true,
category: 'exercise'
},
{
name: 'Растяжка спины',
name: 'Стакан воды',
reps: 1,
icon: 'StretchHorizontal',
icon: 'GlassWater',
intervalMinutes: 60,
enabled: false
enabled: false,
category: 'hydration'
},
{
name: 'Отдых глазам (20-20-20)',
reps: 1,
icon: 'Eye',
intervalMinutes: 20,
enabled: false,
category: 'eyes'
},
{
name: 'Проверь осанку',
reps: 1,
icon: 'PersonStanding',
intervalMinutes: 25,
enabled: false,
category: 'posture'
}
]