P0 #1 — Match-челленджи теперь пишутся в историю. HistoryEntry расширен полями `reps?`, `name?`, `source?` (snapshot planned-reps + name на момент записи + 'reminder'/'match'). Новый store.markChallengeDone(challengeId, reps) пишет entry с exerciseId='challenge:<id>' и source='match'. Зарегистрирован IPC.markChallengeDone handler (раньше канал был в enum, но handler не подключен). ReminderApp.MatchSummaryView вызывает window.api.markChallengeDone при ✓-клике. Стрик, today_done, achievements теперь учитывают игровые тренировки. Заодно dailyReps/dailyRepsRange/totalDoneReps используют entry.reps как fallback — heatmap не теряет данные после удаления упражнения (закрывает P2 #12). P0 #2 — Tray-пауза синхронизирована с Dashboard. Раньше tray держал scheduler-local `paused` boolean, который не отражался в settings.globalEnabled — Dashboard показывал «running» с тикающим таймером, хотя fires не приходили. Сейчас оба пути (tray и Dashboard-кнопка) меняют единственный source of truth — settings.globalEnabled. setPaused/isPaused/paused удалены, IPC pauseAll/resumeAll переписаны на updateSettings. P0 #3 — Whats-new покажется существующим пользователям при апгрейде. Раньше для всех undefined lastSeenVersion (включая обновляющихся с v0.5.5) делали silent-save без модалки — никто бы не увидел v0.5.6 changelog. Сейчас: если есть Exercise с lastDoneAt → это обновляющийся пользователь, показываем заметки текущей версии; если нет — новичок, silent.
110 lines
3.3 KiB
TypeScript
110 lines
3.3 KiB
TypeScript
import { Tray, Menu, nativeImage, app } from 'electron'
|
||
import { join } from 'node:path'
|
||
import { showMainWindow } from './windows'
|
||
import { forceCheck } from './scheduler'
|
||
import { broadcastState, snoozeAll } from './state-actions'
|
||
import { getSettings, updateSettings } from './store'
|
||
import type { Language } from '@shared/types'
|
||
|
||
let tray: Tray | null = null
|
||
|
||
/**
|
||
* Minimal tray-side localisation. The renderer's full i18n dict lives in
|
||
* `src/renderer/src/i18n/dict.ts` and isn't reachable from the main process
|
||
* tsconfig, so we keep the 5 strings the tray actually uses here.
|
||
*/
|
||
const TRAY_STRINGS: Record<Language, Record<string, string>> = {
|
||
ru: {
|
||
open: 'Открыть',
|
||
pause: 'Пауза напоминаний',
|
||
resume: 'Возобновить напоминания',
|
||
snooze15: 'Отложить все на 15 мин',
|
||
quit: 'Выход'
|
||
},
|
||
en: {
|
||
open: 'Open',
|
||
pause: 'Pause reminders',
|
||
resume: 'Resume reminders',
|
||
snooze15: 'Snooze all 15 min',
|
||
quit: 'Quit'
|
||
}
|
||
}
|
||
|
||
function trayLabel(key: string): string {
|
||
// getSettings reads from cache; if the store hasn't loaded yet (very early
|
||
// boot) it lazily reads from disk. Defaults to 'ru' if anything goes wrong.
|
||
let lang: Language = 'ru'
|
||
try {
|
||
lang = getSettings().language ?? 'ru'
|
||
} catch {
|
||
/* keep default */
|
||
}
|
||
return TRAY_STRINGS[lang]?.[key] ?? TRAY_STRINGS.ru[key] ?? key
|
||
}
|
||
|
||
function resolveTrayIcon(): Electron.NativeImage {
|
||
// Try resources/, fallback to a transparent 16x16 if missing during dev.
|
||
const candidates = [
|
||
join(process.resourcesPath, 'tray.png'),
|
||
join(__dirname, '../../resources/tray.png'),
|
||
join(app.getAppPath(), 'resources/tray.png')
|
||
]
|
||
for (const p of candidates) {
|
||
const img = nativeImage.createFromPath(p)
|
||
if (!img.isEmpty()) return img
|
||
}
|
||
return nativeImage.createEmpty()
|
||
}
|
||
|
||
export function createTray(): Tray {
|
||
if (tray) return tray
|
||
const icon = resolveTrayIcon()
|
||
tray = new Tray(icon)
|
||
tray.setToolTip('Exercise Reminder')
|
||
refreshMenu()
|
||
tray.on('click', () => showMainWindow())
|
||
tray.on('double-click', () => showMainWindow())
|
||
return tray
|
||
}
|
||
|
||
export function refreshMenu(): void {
|
||
if (!tray) return
|
||
// Single source of truth — settings.globalEnabled. Раньше tray держал
|
||
// отдельный scheduler-local `paused` flag, который не синхронизировался
|
||
// с Dashboard'ом (там кнопка читает globalEnabled). Теперь оба пути
|
||
// правят одно поле.
|
||
const paused = !getSettings().globalEnabled
|
||
const menu = Menu.buildFromTemplate([
|
||
{ label: trayLabel('open'), click: () => showMainWindow() },
|
||
{ type: 'separator' },
|
||
{
|
||
label: paused ? trayLabel('resume') : trayLabel('pause'),
|
||
click: () => {
|
||
updateSettings({ globalEnabled: paused }) // toggle
|
||
broadcastState() // чтобы Dashboard перерисовал кнопку сразу
|
||
refreshMenu()
|
||
if (paused) forceCheck() // resuming — догнать пропущенные fires
|
||
}
|
||
},
|
||
{
|
||
label: trayLabel('snooze15'),
|
||
click: () => snoozeAll(15)
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: trayLabel('quit'),
|
||
click: () => {
|
||
app.quit()
|
||
}
|
||
}
|
||
])
|
||
tray.setContextMenu(menu)
|
||
}
|
||
|
||
export function destroyTray(): void {
|
||
if (tray) {
|
||
tray.destroy()
|
||
tray = null
|
||
}
|
||
}
|