feat(whatsnew): экран «Что нового» — автопоказ после апдейта + кнопка в Settings
- src/shared/release-notes.ts — статический реестр заметок per-version (RU + EN), с тегами new/fix/security/perf для tint'а иконок. - Settings.lastSeenVersion — версия, для которой пользователь видел модалку. Валидатор регэксом /^\d+\.\d+\.\d+(-[\w.]+)?$/. - IPC.getAppVersion → app.getVersion() для renderer'а. - WhatsNewModal — список пунктов с цветовыми иконками. Footer-кнопка «Понятно» / «Got it». - App.tsx: после hydrate смотрит lastSeenVersion → current. Если расходятся и есть пропущенные заметки → автопоказ. На первой записи (lastSeenVersion === undefined) — тихо записываем, без модалки, чтобы не бить нового пользователя CHANGELOG'ом. - Settings → раздел «О приложении» → кнопка «Открыть» показывает модалку с заметками всех релизов.
This commit is contained in:
@@ -179,6 +179,8 @@ export function registerIpc(): void {
|
|||||||
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
|
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.getAppVersion, () => app.getVersion())
|
||||||
|
|
||||||
ipcMain.handle(IPC.quit, () => app.quit())
|
ipcMain.handle(IPC.quit, () => app.quit())
|
||||||
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
|
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,17 @@ export function validateSettingsPatch(raw: unknown): Partial<Settings> | null {
|
|||||||
if (v === undefined) return null
|
if (v === undefined) return null
|
||||||
out.meetingAutoPause = v
|
out.meetingAutoPause = v
|
||||||
}
|
}
|
||||||
|
if ('lastSeenVersion' in raw) {
|
||||||
|
// Принимаем строку 0.0.0 .. 999.999.999 (semver-light) или null/undefined
|
||||||
|
// для сброса.
|
||||||
|
if (raw.lastSeenVersion === null || raw.lastSeenVersion === undefined) {
|
||||||
|
out.lastSeenVersion = undefined
|
||||||
|
} else {
|
||||||
|
const v = safeStr(raw.lastSeenVersion, 32)
|
||||||
|
if (v === undefined || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(v)) return null
|
||||||
|
out.lastSeenVersion = v
|
||||||
|
}
|
||||||
|
}
|
||||||
if ('notificationMode' in raw) {
|
if ('notificationMode' in raw) {
|
||||||
const v = oneOf(raw.notificationMode, VALID_NOTIFY)
|
const v = oneOf(raw.notificationMode, VALID_NOTIFY)
|
||||||
if (v === undefined) return null
|
if (v === undefined) return null
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const api = {
|
|||||||
getAccentColor: (): Promise<string> => ipcRenderer.invoke(IPC.getAccentColor),
|
getAccentColor: (): Promise<string> => ipcRenderer.invoke(IPC.getAccentColor),
|
||||||
getOsTheme: (): Promise<'light' | 'dark'> =>
|
getOsTheme: (): Promise<'light' | 'dark'> =>
|
||||||
ipcRenderer.invoke(IPC.getOsTheme),
|
ipcRenderer.invoke(IPC.getOsTheme),
|
||||||
|
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion),
|
||||||
|
|
||||||
pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
|
pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
|
||||||
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
|
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { AnimatePresence, motion } from 'framer-motion'
|
|||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import { Titlebar } from './components/Titlebar'
|
import { Titlebar } from './components/Titlebar'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
import { WhatsNewModal } from './components/WhatsNewModal'
|
||||||
|
import { unseenVersions } from '@shared/release-notes'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import Exercises from './pages/Exercises'
|
import Exercises from './pages/Exercises'
|
||||||
import GamesPage from './pages/Games'
|
import GamesPage from './pages/Games'
|
||||||
@@ -17,7 +19,12 @@ let backendSubscribed = false
|
|||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
const hydrated = useAppStore((s) => s.hydrated)
|
const hydrated = useAppStore((s) => s.hydrated)
|
||||||
|
const settings = useAppStore((s) => s.state?.settings)
|
||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||||
|
const [whatsNew, setWhatsNew] = useState<{
|
||||||
|
open: boolean
|
||||||
|
versions: string[]
|
||||||
|
}>({ open: false, versions: [] })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (backendSubscribed) return undefined
|
if (backendSubscribed) return undefined
|
||||||
@@ -29,6 +36,43 @@ export default function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// После хидрации сверяем текущую версию приложения с lastSeenVersion.
|
||||||
|
// Если первая хидрация и lastSeenVersion ещё не записан — это либо
|
||||||
|
// первый запуск, либо обновление со старой версии (где поля не было) —
|
||||||
|
// в любом случае пишем текущую версию и НЕ показываем модалку (мы не
|
||||||
|
// хотим бить нового пользователя CHANGELOG'ом).
|
||||||
|
// Если lastSeenVersion есть и не совпадает с current → показываем.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated || !settings) return
|
||||||
|
void window.api.getAppVersion().then((current) => {
|
||||||
|
const last = settings.lastSeenVersion
|
||||||
|
if (!last) {
|
||||||
|
// Первая запись — сохраняем тихо.
|
||||||
|
window.api.updateSettings({ lastSeenVersion: current })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (last !== current) {
|
||||||
|
const versions = unseenVersions(current, last)
|
||||||
|
if (versions.length > 0) {
|
||||||
|
setWhatsNew({ open: true, versions })
|
||||||
|
} else {
|
||||||
|
// Версии есть, заметок нет — просто обновляем.
|
||||||
|
window.api.updateSettings({ lastSeenVersion: current })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hydrated])
|
||||||
|
|
||||||
|
function closeWhatsNew(): void {
|
||||||
|
setWhatsNew({ open: false, versions: [] })
|
||||||
|
// Записываем «видел» только после закрытия — если пользователь убил
|
||||||
|
// окно процессом до клика, при следующем запуске покажется снова.
|
||||||
|
void window.api.getAppVersion().then((current) => {
|
||||||
|
window.api.updateSettings({ lastSeenVersion: current })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
@@ -50,6 +94,11 @@ export default function App(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<WhatsNewModal
|
||||||
|
open={whatsNew.open}
|
||||||
|
versions={whatsNew.versions}
|
||||||
|
onClose={closeWhatsNew}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
108
src/renderer/src/components/WhatsNewModal.tsx
Normal file
108
src/renderer/src/components/WhatsNewModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Sparkles, Wrench, Shield, Gauge } from 'lucide-react'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { useT } from '../i18n'
|
||||||
|
import { RELEASE_NOTES } from '@shared/release-notes'
|
||||||
|
import type { ReleaseNoteItem } from '@shared/release-notes'
|
||||||
|
import type { Language } from '@shared/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
versions: string[]
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_META = {
|
||||||
|
new: { icon: Sparkles, cls: 'bg-accent text-white' },
|
||||||
|
fix: { icon: Wrench, cls: 'bg-info text-white' },
|
||||||
|
security: { icon: Shield, cls: 'bg-warning text-white' },
|
||||||
|
perf: { icon: Gauge, cls: 'bg-success text-white' }
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показывает заметки релизов для одной или нескольких версий. Используется
|
||||||
|
* (a) автоматически после апдейта (когда `lastSeenVersion` != `currentVersion`)
|
||||||
|
* и (b) вручную из Settings.
|
||||||
|
*/
|
||||||
|
export function WhatsNewModal({
|
||||||
|
open,
|
||||||
|
versions,
|
||||||
|
onClose
|
||||||
|
}: Props): JSX.Element {
|
||||||
|
const { t, lang } = useT()
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('whatsnew.title')}
|
||||||
|
size="md"
|
||||||
|
footer={<Button onClick={onClose}>{t('whatsnew.btn.close')}</Button>}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{versions.length === 0 && (
|
||||||
|
<div className="text-[14px] text-text/65 font-medium py-2">
|
||||||
|
{t('whatsnew.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{versions.map((v) => (
|
||||||
|
<VersionSection key={v} version={v} lang={lang} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionSection({
|
||||||
|
version,
|
||||||
|
lang
|
||||||
|
}: {
|
||||||
|
version: string
|
||||||
|
lang: Language
|
||||||
|
}): JSX.Element {
|
||||||
|
const items = useMemo<ReleaseNoteItem[]>(() => {
|
||||||
|
const notes = RELEASE_NOTES[version]
|
||||||
|
if (!notes) return []
|
||||||
|
return notes[lang] ?? notes.ru
|
||||||
|
}, [version, lang])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] uppercase tracking-wider text-text/55 font-bold mb-3 font-mono-num">
|
||||||
|
v{version}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<NoteRow key={i} item={it} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteRow({ item }: { item: ReleaseNoteItem }): JSX.Element {
|
||||||
|
const meta = TAG_META[item.tag ?? 'new']
|
||||||
|
const IconCmp = meta.icon
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'w-8 h-8 rounded-lg grid place-items-center shrink-0 mt-0.5',
|
||||||
|
meta.cls
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<IconCmp size={15} strokeWidth={2.4} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[14px] font-semibold leading-snug">
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
{item.detail && (
|
||||||
|
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||||
|
{item.detail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -158,6 +158,13 @@ export const ru: Dict = {
|
|||||||
'Все текущие упражнения, история и настройки будут заменены содержимым файла. Продолжить?',
|
'Все текущие упражнения, история и настройки будут заменены содержимым файла. Продолжить?',
|
||||||
'settings.data.import.ok': 'Восстановлено',
|
'settings.data.import.ok': 'Восстановлено',
|
||||||
'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?',
|
'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?',
|
||||||
|
'settings.section.about': 'О приложении',
|
||||||
|
'settings.whatsnew.label': 'Что нового',
|
||||||
|
'settings.whatsnew.hint': 'Посмотреть заметки последних релизов.',
|
||||||
|
'settings.whatsnew.btn': 'Открыть',
|
||||||
|
'whatsnew.title': 'Что нового',
|
||||||
|
'whatsnew.btn.close': 'Понятно',
|
||||||
|
'whatsnew.empty': 'Для этой версии заметок пока нет.',
|
||||||
'settings.notification_mode.label': 'Режим уведомления',
|
'settings.notification_mode.label': 'Режим уведомления',
|
||||||
'settings.notification_mode.hint': 'Как должно выглядеть напоминание',
|
'settings.notification_mode.hint': 'Как должно выглядеть напоминание',
|
||||||
'settings.notification_mode.modal': 'Окно поверх всех',
|
'settings.notification_mode.modal': 'Окно поверх всех',
|
||||||
@@ -466,6 +473,13 @@ export const en: Dict = {
|
|||||||
'All current exercises, history and settings will be replaced with the file contents. Continue?',
|
'All current exercises, history and settings will be replaced with the file contents. Continue?',
|
||||||
'settings.data.import.ok': 'Restored',
|
'settings.data.import.ok': 'Restored',
|
||||||
'settings.data.import.err': "Couldn't read the file — not our backup?",
|
'settings.data.import.err': "Couldn't read the file — not our backup?",
|
||||||
|
'settings.section.about': 'About',
|
||||||
|
'settings.whatsnew.label': "What's new",
|
||||||
|
'settings.whatsnew.hint': 'See the latest release notes.',
|
||||||
|
'settings.whatsnew.btn': 'Open',
|
||||||
|
'whatsnew.title': "What's new",
|
||||||
|
'whatsnew.btn.close': 'Got it',
|
||||||
|
'whatsnew.empty': 'No notes available for this version yet.',
|
||||||
'settings.notification_mode.label': 'Notification mode',
|
'settings.notification_mode.label': 'Notification mode',
|
||||||
'settings.notification_mode.hint': 'How a reminder appears',
|
'settings.notification_mode.hint': 'How a reminder appears',
|
||||||
'settings.notification_mode.modal': 'Window on top',
|
'settings.notification_mode.modal': 'Window on top',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useAppStore } from '../store/appStore'
|
|||||||
import { Switch } from '../components/ui/Switch'
|
import { Switch } from '../components/ui/Switch'
|
||||||
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
||||||
import { UpdaterCard } from '../components/UpdaterCard'
|
import { UpdaterCard } from '../components/UpdaterCard'
|
||||||
|
import { WhatsNewModal } from '../components/WhatsNewModal'
|
||||||
|
import { RELEASE_NOTES } from '@shared/release-notes'
|
||||||
import { useT } from '../i18n'
|
import { useT } from '../i18n'
|
||||||
import type {
|
import type {
|
||||||
Language,
|
Language,
|
||||||
@@ -175,11 +177,53 @@ export default function SettingsPage(): JSX.Element {
|
|||||||
<SectionHeader title={t('settings.section.data')} />
|
<SectionHeader title={t('settings.section.data')} />
|
||||||
<DataCard />
|
<DataCard />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<SectionHeader title={t('settings.section.about')} />
|
||||||
|
<AboutCard />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AboutCard(): JSX.Element {
|
||||||
|
const { t } = useT()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
// Все версии для которых у нас есть заметки, отсортированы desc.
|
||||||
|
const allVersions = Object.keys(RELEASE_NOTES).sort((a, b) => {
|
||||||
|
const pa = a.split('.').map(Number)
|
||||||
|
const pb = b.split('.').map(Number)
|
||||||
|
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pb[i] - pa[i]
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Row last>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[15px] font-semibold leading-tight">
|
||||||
|
{t('settings.whatsnew.label')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||||
|
{t('settings.whatsnew.hint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.whatsnew.btn')}
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
<WhatsNewModal
|
||||||
|
open={open}
|
||||||
|
versions={allVersions}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DataCard(): JSX.Element {
|
function DataCard(): JSX.Element {
|
||||||
const { t } = useT()
|
const { t } = useT()
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const IPC = {
|
|||||||
updateSettings: 'settings:update',
|
updateSettings: 'settings:update',
|
||||||
getAccentColor: 'system:accentColor',
|
getAccentColor: 'system:accentColor',
|
||||||
getOsTheme: 'system:osTheme',
|
getOsTheme: 'system:osTheme',
|
||||||
|
getAppVersion: 'system:appVersion',
|
||||||
|
|
||||||
pauseAll: 'app:pauseAll',
|
pauseAll: 'app:pauseAll',
|
||||||
resumeAll: 'app:resumeAll',
|
resumeAll: 'app:resumeAll',
|
||||||
|
|||||||
243
src/shared/release-notes.ts
Normal file
243
src/shared/release-notes.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Заметки релизов для UI «Что нового». Хардкодные — синхронизируются
|
||||||
|
* с CHANGELOG.md руками при релизе. Не парсим .md в runtime: лишняя
|
||||||
|
* сложность, плюс отделение «коротких заметок для пользователя» от
|
||||||
|
* «развёрнутого технического CHANGELOG'а».
|
||||||
|
*
|
||||||
|
* Формат: версия → язык → массив пунктов (короткий заголовок + опц. деталь).
|
||||||
|
* Не более 5-7 пунктов на версию, иначе пользователь скроллит.
|
||||||
|
*/
|
||||||
|
import type { Language } from './types'
|
||||||
|
|
||||||
|
export type ReleaseNoteItem = {
|
||||||
|
/** Лаконичная строка-заголовок (≤ 60 символов). */
|
||||||
|
title: string
|
||||||
|
/** Опциональная одна строка-деталь (≤ 140 символов). */
|
||||||
|
detail?: string
|
||||||
|
/** Категория для tint'а иконки. */
|
||||||
|
tag?: 'new' | 'fix' | 'security' | 'perf'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReleaseNotes = Record<Language, ReleaseNoteItem[]>
|
||||||
|
|
||||||
|
export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
|
||||||
|
'0.5.6': {
|
||||||
|
ru: [
|
||||||
|
{
|
||||||
|
title: 'Категории напоминаний',
|
||||||
|
detail:
|
||||||
|
'Помимо упражнений — гидратация, отдых глазам (20-20-20), осанка.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Голосовые подсказки',
|
||||||
|
detail:
|
||||||
|
'Диктор произносит название упражнения и количество. Включается в настройках.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Достижения',
|
||||||
|
detail:
|
||||||
|
'Milestones по количеству повторений и серий — с прогресс-баром до ближайшей цели.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Дневная цель',
|
||||||
|
detail:
|
||||||
|
'Soft-cap на упражнение: набрал N повторов — реминдер умолкает до завтра.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Авто-пауза на ВКС',
|
||||||
|
detail:
|
||||||
|
'Не дёргает напоминаниями, если запущен Zoom/Teams/Discord/Webex/Slack-huddle.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Адаптивный шедулер',
|
||||||
|
detail:
|
||||||
|
'Учит часы, в которые ты честно делаешь упражнение, и сдвигает fire-ы туда.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Экспорт и импорт',
|
||||||
|
detail:
|
||||||
|
'Резервная копия упражнений, истории и настроек в JSON — для переноса на другую машину.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Кнопка «Что нового»',
|
||||||
|
detail: 'Это окно. Открывается автоматически после обновления.',
|
||||||
|
tag: 'new'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
en: [
|
||||||
|
{
|
||||||
|
title: 'Reminder categories',
|
||||||
|
detail:
|
||||||
|
'Beyond exercises — hydration, eye rest (20-20-20), posture.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Voice prompts',
|
||||||
|
detail:
|
||||||
|
'Speaks the exercise name and count. Toggle in Settings.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Achievements',
|
||||||
|
detail:
|
||||||
|
'Milestones by total reps and streaks, with a progress bar to the next one.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Daily goal',
|
||||||
|
detail:
|
||||||
|
'Soft cap per exercise: hit N reps and that reminder goes quiet until tomorrow.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Meeting auto-pause',
|
||||||
|
detail:
|
||||||
|
'No reminders while Zoom/Teams/Discord/Webex/Slack-huddle is running.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Adaptive scheduling',
|
||||||
|
detail:
|
||||||
|
'Learns the hours you reliably do an exercise and shifts fires into those windows.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Export & import',
|
||||||
|
detail:
|
||||||
|
'JSON backup of exercises, history and settings — for moving to another machine.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "What's new screen",
|
||||||
|
detail: 'This screen. Opens automatically after an update.',
|
||||||
|
tag: 'new'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'0.5.5': {
|
||||||
|
ru: [
|
||||||
|
{
|
||||||
|
title: 'Sandbox для окон',
|
||||||
|
detail: 'Окна изолированы на уровне OS — даже RCE в рендере не достанет main.',
|
||||||
|
tag: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Self-hosted шрифты',
|
||||||
|
detail:
|
||||||
|
'Plus Jakarta Sans + Bricolage Grotesque локально в bundle — работает без интернета.',
|
||||||
|
tag: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Логи для диагностики',
|
||||||
|
detail:
|
||||||
|
'%APPDATA%/Exercise Reminder/logs/latest.log — отдашь файл при проблеме.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'UI не залипает при retry-ях I/O',
|
||||||
|
tag: 'perf'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GSI больше не зависает на TIME_WAIT при выходе',
|
||||||
|
tag: 'fix'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
en: [
|
||||||
|
{
|
||||||
|
title: 'Window sandbox',
|
||||||
|
detail: 'OS-level isolation — even RCE in the renderer cannot reach main.',
|
||||||
|
tag: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Self-hosted fonts',
|
||||||
|
detail:
|
||||||
|
'Plus Jakarta Sans + Bricolage Grotesque shipped in-bundle — works offline.',
|
||||||
|
tag: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Diagnostic logs',
|
||||||
|
detail:
|
||||||
|
'%APPDATA%/Exercise Reminder/logs/latest.log — share it when something breaks.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'UI no longer freezes during I/O retries',
|
||||||
|
tag: 'perf'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GSI port no longer stuck in TIME_WAIT after exit',
|
||||||
|
tag: 'fix'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'0.5.4': {
|
||||||
|
ru: [
|
||||||
|
{
|
||||||
|
title: 'Фоновое скачивание апдейта',
|
||||||
|
detail: 'Можно уйти на Dashboard и заниматься — апдейт качается в фоне.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Моментальный рестарт',
|
||||||
|
detail: 'Кнопка «Рестарт» — ~1-2 сек до открытия новой версии, без диалогов NSIS.',
|
||||||
|
tag: 'new'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
en: [
|
||||||
|
{
|
||||||
|
title: 'Background update download',
|
||||||
|
detail: 'You can go to Dashboard and work — the update keeps downloading.',
|
||||||
|
tag: 'new'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Instant restart',
|
||||||
|
detail: 'Restart button — ~1-2 sec to the new version, no NSIS dialogs.',
|
||||||
|
tag: 'new'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает версии, отсортированные desc, для которых есть заметки,
|
||||||
|
* с версиями, не виденными пользователем. Используется для «show whatsnew
|
||||||
|
* after update»: если пользователь прыгнул через несколько версий, показываем
|
||||||
|
* все пропущенные одним списком.
|
||||||
|
*/
|
||||||
|
export function unseenVersions(
|
||||||
|
current: string,
|
||||||
|
lastSeen: string | undefined
|
||||||
|
): string[] {
|
||||||
|
const all = Object.keys(RELEASE_NOTES).sort(compareSemverDesc)
|
||||||
|
if (!lastSeen) {
|
||||||
|
// Первый запуск этого механизма — показываем только текущую версию
|
||||||
|
// чтобы не перегружать историей. Старые версии показывает только
|
||||||
|
// явный «What's new» из Settings.
|
||||||
|
return all.filter((v) => v === current)
|
||||||
|
}
|
||||||
|
return all.filter((v) => compareSemver(v, lastSeen) > 0 && compareSemver(v, current) <= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSemver(v: string): [number, number, number] {
|
||||||
|
const parts = v.split('.').map((n) => parseInt(n, 10))
|
||||||
|
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSemver(a: string, b: string): number {
|
||||||
|
const [a1, a2, a3] = parseSemver(a)
|
||||||
|
const [b1, b2, b3] = parseSemver(b)
|
||||||
|
if (a1 !== b1) return a1 - b1
|
||||||
|
if (a2 !== b2) return a2 - b2
|
||||||
|
return a3 - b3
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSemverDesc(a: string, b: string): number {
|
||||||
|
return -compareSemver(a, b)
|
||||||
|
}
|
||||||
@@ -82,6 +82,12 @@ export type Settings = {
|
|||||||
language: Language
|
language: Language
|
||||||
snoozeMinutes: number
|
snoozeMinutes: number
|
||||||
quietHours: QuietHours
|
quietHours: QuietHours
|
||||||
|
/**
|
||||||
|
* Версия, для которой пользователь видел экран «Что нового». Если
|
||||||
|
* `app.getVersion()` отличается — модалка показывается при следующем
|
||||||
|
* запуске и записывает текущую версию.
|
||||||
|
*/
|
||||||
|
lastSeenVersion?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user