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:
AnRil
2026-05-22 13:59:29 +07:00
parent a0b89ddf71
commit 5a9ec04ba8
10 changed files with 479 additions and 0 deletions

243
src/shared/release-notes.ts Normal file
View 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)
}