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'
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.getAppVersion, () => app.getVersion())
|
||||
|
||||
ipcMain.handle(IPC.quit, () => app.quit())
|
||||
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
|
||||
|
||||
|
||||
@@ -303,6 +303,17 @@ export function validateSettingsPatch(raw: unknown): Partial<Settings> | null {
|
||||
if (v === undefined) return null
|
||||
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) {
|
||||
const v = oneOf(raw.notificationMode, VALID_NOTIFY)
|
||||
if (v === undefined) return null
|
||||
|
||||
@@ -47,6 +47,7 @@ const api = {
|
||||
getAccentColor: (): Promise<string> => ipcRenderer.invoke(IPC.getAccentColor),
|
||||
getOsTheme: (): Promise<'light' | 'dark'> =>
|
||||
ipcRenderer.invoke(IPC.getOsTheme),
|
||||
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion),
|
||||
|
||||
pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
|
||||
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
|
||||
|
||||
@@ -4,6 +4,8 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { Titlebar } from './components/Titlebar'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { WhatsNewModal } from './components/WhatsNewModal'
|
||||
import { unseenVersions } from '@shared/release-notes'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Exercises from './pages/Exercises'
|
||||
import GamesPage from './pages/Games'
|
||||
@@ -17,7 +19,12 @@ let backendSubscribed = false
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const hydrated = useAppStore((s) => s.hydrated)
|
||||
const settings = useAppStore((s) => s.state?.settings)
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||
const [whatsNew, setWhatsNew] = useState<{
|
||||
open: boolean
|
||||
versions: string[]
|
||||
}>({ open: false, versions: [] })
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<ErrorBoundary>
|
||||
<HashRouter>
|
||||
@@ -50,6 +94,11 @@ export default function App(): JSX.Element {
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<WhatsNewModal
|
||||
open={whatsNew.open}
|
||||
versions={whatsNew.versions}
|
||||
onClose={closeWhatsNew}
|
||||
/>
|
||||
</div>
|
||||
</HashRouter>
|
||||
</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.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.hint': 'Как должно выглядеть напоминание',
|
||||
'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?',
|
||||
'settings.data.import.ok': 'Restored',
|
||||
'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.hint': 'How a reminder appears',
|
||||
'settings.notification_mode.modal': 'Window on top',
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useAppStore } from '../store/appStore'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
||||
import { UpdaterCard } from '../components/UpdaterCard'
|
||||
import { WhatsNewModal } from '../components/WhatsNewModal'
|
||||
import { RELEASE_NOTES } from '@shared/release-notes'
|
||||
import { useT } from '../i18n'
|
||||
import type {
|
||||
Language,
|
||||
@@ -175,11 +177,53 @@ export default function SettingsPage(): JSX.Element {
|
||||
<SectionHeader title={t('settings.section.data')} />
|
||||
<DataCard />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<SectionHeader title={t('settings.section.about')} />
|
||||
<AboutCard />
|
||||
</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 {
|
||||
const { t } = useT()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
@@ -11,6 +11,7 @@ export const IPC = {
|
||||
updateSettings: 'settings:update',
|
||||
getAccentColor: 'system:accentColor',
|
||||
getOsTheme: 'system:osTheme',
|
||||
getAppVersion: 'system:appVersion',
|
||||
|
||||
pauseAll: 'app:pauseAll',
|
||||
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
|
||||
snoozeMinutes: number
|
||||
quietHours: QuietHours
|
||||
/**
|
||||
* Версия, для которой пользователь видел экран «Что нового». Если
|
||||
* `app.getVersion()` отличается — модалка показывается при следующем
|
||||
* запуске и записывает текущую версию.
|
||||
*/
|
||||
lastSeenVersion?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user