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

View File

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

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' 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 { Modal } from './ui/Modal'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { ICON_CHOICES, Icon } from '../lib/icon' import { ICON_CHOICES, Icon } from '../lib/icon'
@@ -11,6 +12,7 @@ type Draft = {
icon: string icon: string
intervalMinutes: number intervalMinutes: number
enabled: boolean enabled: boolean
category: ReminderCategory
} }
const EMPTY: Draft = { const EMPTY: Draft = {
@@ -18,7 +20,8 @@ const EMPTY: Draft = {
reps: 10, reps: 10,
icon: 'Activity', icon: 'Activity',
intervalMinutes: 30, intervalMinutes: 30,
enabled: true enabled: true,
category: 'exercise'
} }
type Props = { type Props = {
@@ -44,7 +47,8 @@ export function ExerciseEditor({
reps: exercise.reps, reps: exercise.reps,
icon: exercise.icon, icon: exercise.icon,
intervalMinutes: exercise.intervalMinutes, intervalMinutes: exercise.intervalMinutes,
enabled: exercise.enabled enabled: exercise.enabled,
category: exercise.category ?? 'exercise'
}) })
} else { } else {
setDraft(EMPTY) setDraft(EMPTY)
@@ -101,6 +105,26 @@ export function ExerciseEditor({
/> />
</Field> </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"> <div className="grid grid-cols-2 gap-3">
<Field label={t('editor.field.reps')}> <Field label={t('editor.field.reps')}>
<input <input

View File

@@ -219,6 +219,17 @@ export const ru: Dict = {
'updater.idle.title': 'Проверить обновления', 'updater.idle.title': 'Проверить обновления',
'updater.idle.subtitle': 'Авто-проверка раз в час', '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 window
'reminder.kicker': 'Время тренировки', 'reminder.kicker': 'Время тренировки',
'reminder.subkicker': 'Двигайся', 'reminder.subkicker': 'Двигайся',
@@ -484,6 +495,17 @@ export const en: Dict = {
'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',
// 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 window
'reminder.kicker': 'Workout time', 'reminder.kicker': 'Workout time',
'reminder.subkicker': 'Move', 'reminder.subkicker': 'Move',

View File

@@ -72,6 +72,7 @@ export default function Dashboard(): JSX.Element {
icon: string icon: string
intervalMinutes: number intervalMinutes: number
enabled: boolean enabled: boolean
category: import('@shared/types').ReminderCategory
}): Promise<void> { }): Promise<void> {
if (editing) await window.api.updateExercise(editing.id, draft) if (editing) await window.api.updateExercise(editing.id, draft)
else await window.api.addExercise(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 = { export type Exercise = {
id: string id: string
name: string name: string
@@ -7,6 +24,8 @@ export type Exercise = {
enabled: boolean enabled: boolean
nextFireAt: number nextFireAt: number
lastDoneAt?: number lastDoneAt?: number
/** Default 'exercise' если undefined — обратная совместимость. */
category?: ReminderCategory
} }
export type NotificationMode = 'toast' | 'modal' | 'both' export type NotificationMode = 'toast' | 'modal' | 'both'
@@ -253,21 +272,40 @@ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
reps: 10, reps: 10,
icon: 'Activity', icon: 'Activity',
intervalMinutes: 30, intervalMinutes: 30,
enabled: true enabled: true,
category: 'exercise'
}, },
{ {
name: 'Отжимания', name: 'Отжимания',
reps: 10, reps: 10,
icon: 'Dumbbell', icon: 'Dumbbell',
intervalMinutes: 45, intervalMinutes: 45,
enabled: true enabled: true,
category: 'exercise'
}, },
{ {
name: 'Растяжка спины', name: 'Стакан воды',
reps: 1, reps: 1,
icon: 'StretchHorizontal', icon: 'GlassWater',
intervalMinutes: 60, 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'
} }
] ]