feat(#7): категории напоминаний (exercise/hydration/eyes/posture)
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user