docs: tabbed settings, auto-update toggle and diagnostics

This commit is contained in:
Horizontal42 2026-07-03 12:03:58 +03:00
parent 1e06281f5e
commit 6f190c7245
4 changed files with 220 additions and 46 deletions

View file

@ -21,6 +21,8 @@ src/
index.ts SumEngine facade + extension API + sheet totals
ui/
editor.ts CodeMirror 6 wiring: highlight, results overlay, copy
search.ts cross-sheet search overlay (text + result-value queries)
export.ts renders the sheet + results to a PNG (clipboard/file)
app.css themes (light/dark via CSS variables)
main.ts app bootstrap: documents, settings, status bar, dnd
storage.ts Tauri commands / localStorage fallback
@ -29,8 +31,8 @@ src/
updater.ts checks tauri-plugin-updater, installs + relaunches
src-tauri/
src/main.rs tray, hide-to-tray, storage commands, rates fetching
(open.er-api.com + CoinGecko + Yahoo Finance) with
cache, historical rates (frankfurter.app, permanent
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
with cache, historical rates (frankfurter.dev, permanent
per-date cache), backups, data-folder migration,
file drops, plugin registration (autostart,
global-shortcut, single-instance, opener, dialog,
@ -111,7 +113,49 @@ snapshot price) plus the mirror list in `src-tauri/src/main.rs`.
bypasses `formatValue` — the results overlay renders an SVG sparkline
instead of a text string.
- The registry is rebuilt never; extensions mutate it (add phrases/functions)
at startup.
at startup. `buildRegistry()` (`registry.ts`) is a thin orchestrator over
`registerCoreVocab` / `registerUnits` / `registerAreaVolume` /
`registerCurrencies` / `registerExtraUnits` / `registerCrypto` /
`registerCompletions` — **call order matters**, since `Registry.addPhrase`
keeps the *first* registration of an identical phrase and drops later ones
(core vocab must win over unit/currency phrases that happen to collide).
- Multiplying two same-dimension quantities (`2 kg * 500 g`) converts the
right operand into the left operand's unit before multiplying, so the
result keeps a sane unit (`1 kg`, not `1000` in a `kg` label). Cross-dimension
multiplication (`$12 * 3`, `5 hours * 2`) is unaffected — the right side is
treated as a plain scale factor there.
- A `//` only starts a comment at the start of a line or after whitespace
(`findCommentStart` in `index.ts`), so a URL like `see https://example.com`
isn't truncated at the double slash.
- **Date literals and formats.** Besides ISO (`2024-01-01`), the lexer accepts
`D.M.YYYY` / `M/D/YYYY` (`31.12.2024`, `12/31/2024`) with the same separator
on both sides and a 4-digit year. Day/month order is disambiguated per
literal: a component `>12` fixes which side is the day; if both are `≤12`,
the engine's `dateFormat` setting decides. All three literal forms reject
out-of-range components via a round-trip check (`validDateMs` in `lexer.ts`
— `new Date(y, mo-1, d)` silently *rolls over* invalid dates like
`2026-13-45` instead of erroring, so the constructed date's year/month/day
are compared back against the input). `SumEngine` resolves `dateFormat:
"system"` to an actual day/month order once via `detectDateOrder()`
(`datetime.ts`, probes `Intl.DateTimeFormat` on a `2000-12-31` fixture) and
passes it into `tokenize()` → `lexLine()` on every call; `"dmy"`/`"mdy"`
settings skip detection. Display works the same way in reverse: `formatter.ts`
either uses the OS-locale `Intl` formatting (`"system"`) or builds a fixed
`YYYY-MM-DD` / `DD.MM.YYYY` / `MM/DD/YYYY` string via
`Intl.DateTimeFormat(...).formatToParts()` (needed to respect the value's
own IANA timezone, which a hand-rolled `ms`-based formatter can't do).
`Intl.DateTimeFormat` instances are cached by options key in `formatter.ts`
(`dtf()`) since constructing one is comparatively expensive and every dated
line rebuilds its format on every render.
- Timezone name parsing (`time in Europe/Berlin`) has two independent traps.
First, `parseLine`'s noise-filter only keeps word tokens that immediately
follow a `conv` token (`in`/`as`/...); any other token type resets that
state — a bare `/` between two timezone words would normally drop the
second word before the parser ever sees it, so the filter special-cases a
`/` that appears while collecting post-conv words. Second, `parseTarget()`'s
word-collecting loop mirrors that same allowance so it keeps consuming past
the `/` instead of stopping at the first word. Both places need the
exception — fixing only one still drops `Europe/Berlin` down to `Europe`.
## Cross-sheet references
@ -147,10 +191,19 @@ recursing forever.
scroll/geometry changes. Click-to-copy reads `data-value`.
- Engine evaluation is synchronous on every keystroke — a full sheet parse
is well under a millisecond, so there's no debounce or worker.
- Search-all-sheets (`Ctrl+F`) is a plain line scan over `data.contents` in
`main.ts`, not a CodeMirror search extension — it has to cross documents,
so it never touches the active editor until a result is clicked
(`editor.goToLine`).
- Search-all-sheets (`Ctrl+F`) is a plain line scan over every open sheet's
text, not a CodeMirror search extension — it has to cross documents, so it
never touches the active editor until a result is clicked
(`editor.goToLine`). It lives in `src/ui/search.ts` (`initSearch()`), which
owns the `#search-overlay` DOM and its own 150 ms input debounce — a
result-value query (`>1000`) re-evaluates every open sheet, so debouncing
matters once there are more than a couple of sheets open.
- The settings popover is split into four tabs (General / Format / Data /
Updates) in `index.html`, each a `.settings-tabpanel` toggled by a
`#settings-tabs .tab` button in `bindSettingsUI()` (`main.ts`) — plain
`classList.toggle`, no router. Tab styling reuses the sidebar's
`doc-item.active` idiom (`background: var(--sel)` for the active pill)
rather than introducing a new visual pattern.
## Storage
@ -165,7 +218,28 @@ recursing forever.
- `updater.ts` calls `tauri-plugin-updater`'s `check()` on boot; if a signed
update is available the user is prompted, then `downloadAndInstall()` +
`tauri-plugin-process`'s `relaunch()`.
`tauri-plugin-process`'s `relaunch()`. `checkForUpdate()` returns
`AvailableUpdate | null | "error"` — `null` means the check succeeded and
found nothing newer, `"error"` means the check itself failed (offline,
signature mismatch, etc.); the distinction only matters for the manual path
below. Every outcome is now visible in devtools: failures via
`console.warn`, and a successful "no update" result logs the running
version alongside "that's the latest published release" via `console.info`
— the most common false alarm is simply that the installed build already
*is* the newest release (e.g. right after installing it), which used to be
indistinguishable from a silently broken check.
- Closing the window hides it to the tray instead of exiting the process (see
`on_window_event` in `main.rs`), so a boot-time-only check can go unnoticed
for a long time on a machine that isn't restarted often. `main.ts` also
reruns `checkUpdate()` on a `setInterval` (every 6 hours) so a long-lived
tray process still notices new releases, and Settings → Updates has a
manual "Check for updates" button (`checkUpdate(true)`) that reports
"up to date" / "check failed" via toast — those toasts are suppressed on
the automatic background checks so the app doesn't nag every few hours.
A `SettingsData.autoUpdateEnabled` toggle turns off both the boot check and
the periodic recheck (re-read from `settings` on every interval tick, so
flipping it while the app is running takes effect immediately); the manual
button ignores the toggle and always works.
- The update endpoint is `latest.json` published alongside each GitHub
Release. `release.yml` signs both architectures with a minisign keypair:
the public half is embedded in `tauri.conf.json`; the private half + its
@ -181,16 +255,19 @@ All files below live in `%APPDATA%/app.summarum.calc` (or the user-chosen folder
|------|----------|-----|
| `settings.json` | app settings | persisted |
| `documents.json` | all sheets | persisted (400 ms debounce) |
| `rates.json` | live exchange rates | 1 hour |
| `market.json` | stock/commodity prices | 15 minutes |
| `rates-YYYY-MM-DD.json` | historical ECB rates for one date | permanent |
| `rates.json` | live exchange rates (open.er-api.com + CoinGecko) | 1 hour |
| `market.json` | stock/commodity prices (Yahoo Finance v8 chart, one request per symbol) | 15 minutes |
| `rates-YYYY-MM-DD.json` | historical ECB rates for one date (frankfurter.dev) | permanent |
| `backups/documents-YYYY-MM-DD.json` | daily snapshots | 14 days |
| `backups/deleted/*.numi` | soft-deleted sheets | configurable |
## Tests
`npm test` runs 113 vitest cases over the engine (`src/engine/*.test.ts`):
every expression class, both languages, deterministic injected rates,
goal seek, historical rates (injected), plus a regression suite covering
all known-fixed bugs. UI is exercised manually; the engine is where the
complexity lives.
`npm test` runs 152 vitest cases: `src/engine/*.test.ts` covers every
expression class, both languages, deterministic injected rates, goal seek,
historical rates (injected), date-format literals/display (dedicated engines
with an explicit `dateFormat`, since the default "system" format depends on
the CI machine's locale), plus a regression suite covering all known-fixed
bugs; `src/workspace.test.ts` covers cross-sheet references, caching and
cycle detection. UI is exercised manually; the engine is where the complexity
lives.

View file

@ -22,6 +22,8 @@ src/
index.ts фасад SumEngine + API расширений + итоги листа
ui/
editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование
search.ts оверлей поиска по всем листам (текст + запросы по значению)
export.ts рендерит лист + результаты в PNG (буфер обмена/файл)
app.css темы (светлая/тёмная через CSS-переменные)
main.ts запуск приложения: документы, настройки, строка статуса, dnd
storage.ts команды Tauri / запасной вариант на localStorage
@ -30,10 +32,10 @@ src/
updater.ts проверка tauri-plugin-updater, установка + перезапуск
src-tauri/
src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов
(open.er-api.com + CoinGecko + Yahoo Finance) с кэшем,
исторические курсы (frankfurter.app, постоянный кэш
на каждую дату), резервные копии, миграция папки данных,
открытие файлов, регистрация плагинов (autostart,
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
с кэшем, исторические курсы (frankfurter.dev, постоянный
кэш на каждую дату), резервные копии, миграция папки
данных, открытие файлов, регистрация плагинов (autostart,
global-shortcut, single-instance, opener, dialog,
updater, process)
```
@ -109,7 +111,53 @@ src-tauri/
- `ChartValue` (`kind: "chart"`) возвращается из `evalAgg("chart", …)` и
минует `formatValue` — оверлей результатов рисует SVG-спарклайн вместо текста.
- Реестр не пересобирается никогда; расширения мутируют его (добавляют фразы/функции)
при запуске.
при запуске. `buildRegistry()` (`registry.ts`) — тонкий оркестратор над
`registerCoreVocab` / `registerUnits` / `registerAreaVolume` /
`registerCurrencies` / `registerExtraUnits` / `registerCrypto` /
`registerCompletions` — **порядок вызовов важен**: `Registry.addPhrase`
оставляет *первую* регистрацию одинаковой фразы и отбрасывает последующие
(базовый словарь обязан побеждать фразы единиц/валют, если они случайно
совпадают).
- Умножение двух величин одного измерения (`2 kg * 500 g`) конвертирует
правый операнд в единицу левого перед умножением, чтобы результат имел
осмысленную единицу (`1 kg`, а не `1000` в подписи `kg`). Умножение разных
измерений (`$12 * 3`, `5 hours * 2`) не затронуто — там правая часть
трактуется как простой числовой множитель.
- `//` начинает комментарий только в начале строки или после пробела
(`findCommentStart` в `index.ts`), поэтому URL вида `see https://example.com`
не обрезается на двойном слэше.
- **Литералы и форматы дат.** Помимо ISO (`2024-01-01`), лексер понимает
`D.M.YYYY` / `M/D/YYYY` (`31.12.2024`, `12/31/2024`) с одинаковым
разделителем с обеих сторон и 4-значным годом. Порядок день/месяц
разрешается для каждого литерала отдельно: компонент `>12` однозначно
определяет, где день; если оба `≤12`, решает настройка движка `dateFormat`.
Все три формы литералов отклоняют компоненты вне диапазона через
round-trip проверку (`validDateMs` в `lexer.ts` — `new Date(y, mo-1, d)`
молча *перекатывает* невалидные даты вроде `2026-13-45` вместо ошибки,
поэтому построенные год/месяц/день сравниваются обратно со входом).
`SumEngine` разрешает `dateFormat: "system"` в реальный порядок день/месяц
один раз через `detectDateOrder()` (`datetime.ts`, пробует
`Intl.DateTimeFormat` на фикстуре `2000-12-31`) и передаёт его в
`tokenize()` → `lexLine()` при каждом вызове; настройки `"dmy"`/`"mdy"`
пропускают детекцию. Отображение работает так же в обратную сторону:
`formatter.ts` либо использует форматирование `Intl` по локали ОС
(`"system"`), либо строит фиксированную строку `YYYY-MM-DD` / `DD.MM.YYYY` /
`MM/DD/YYYY` через `Intl.DateTimeFormat(...).formatToParts()` (это нужно,
чтобы уважать собственный IANA-часовой пояс значения, чего форматтер на
основе голого `ms` сделать не может). Экземпляры `Intl.DateTimeFormat`
кэшируются по ключу опций в `formatter.ts` (`dtf()`), так как их создание
сравнительно дорого, а каждая строка с датой пересобирает формат при
каждой отрисовке.
- У разбора названий часовых поясов (`time in Europe/Berlin`) есть две
независимые ловушки. Во-первых, шумовой фильтр `parseLine` оставляет
word-токены только сразу после `conv`-токена (`in`/`as`/...); любой другой
тип токена сбрасывает это состояние — голый `/` между двумя словами
часового пояса обычно отбросил бы второе слово раньше, чем его увидит
парсер, поэтому фильтр отдельно разрешает `/`, встреченный при сборе
post-conv слов. Во-вторых, цикл сбора слов в `parseTarget()` зеркалит то же
исключение, чтобы продолжать читать после `/`, а не останавливаться на
первом слове. Нужны оба места — исправление только одного всё равно
урезает `Europe/Berlin` до `Europe`.
## Ссылки между листами
@ -144,10 +192,20 @@ src-tauri/
прокрутке/изменении геометрии. Клик для копирования читает `data-value`.
- Оценка движка синхронная на каждое нажатие клавиши — полный разбор листа
занимает менее миллисекунды, поэтому нет дебаунса и воркеров.
- Поиск по всем листам (`Ctrl+F`) — простое построчное сканирование
`data.contents` в `main.ts`, а не расширение поиска CodeMirror — ему нужно
- Поиск по всем листам (`Ctrl+F`) — простое построчное сканирование текста
каждого открытого листа, а не расширение поиска CodeMirror — ему нужно
пересекать документы, поэтому он не трогает активный редактор, пока не
кликнут результат (`editor.goToLine`).
кликнут результат (`editor.goToLine`). Живёт в `src/ui/search.ts`
(`initSearch()`), который владеет DOM `#search-overlay` и собственным
дебаунсом ввода на 150 мс — запрос по значению (`>1000`) пересчитывает
каждый открытый лист, так что дебаунс важен, как только листов становится
больше пары.
- Поповер настроек разбит на четыре вкладки (Общие / Формат / Данные /
Обновления) в `index.html`, каждая — `.settings-tabpanel`, переключаемая
кнопкой `#settings-tabs .tab` в `bindSettingsUI()` (`main.ts`) — обычный
`classList.toggle`, без роутера. Стиль вкладок повторяет идиому сайдбара
`doc-item.active` (`background: var(--sel)` для активной таблетки) вместо
нового визуального паттерна.
## Хранилище
@ -163,6 +221,28 @@ src-tauri/
- `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если
подписанное обновление доступно, пользователю предлагают установить, затем
`downloadAndInstall()` + `relaunch()` из `tauri-plugin-process`.
`checkForUpdate()` возвращает `AvailableUpdate | null | "error"` — `null`
значит проверка прошла успешно и ничего новее нет, `"error"` значит сама
проверка не удалась (офлайн, рассинхрон подписи и т.п.); различие важно
только для ручного пути ниже. Теперь виден любой исход: сбои через
`console.warn`, а успешный результат «нет обновления» логирует текущую
версию рядом с «это последний опубликованный релиз» через `console.info` —
самый частый ложный сигнал тревоги это когда установленная сборка И ЕСТЬ
последний релиз (например, сразу после его установки), что раньше было
неотличимо от молча сломанной проверки.
- Закрытие окна прячет его в трей вместо завершения процесса (см.
`on_window_event` в `main.rs`), поэтому проверка только при старте может
долго оставаться незамеченной на машине, которую редко перезагружают.
`main.ts` также перезапускает `checkUpdate()` через `setInterval` (каждые
6 часов), чтобы долгоживущий трей-процесс всё равно замечал новые релизы,
а в настройках (вкладка «Обновления») есть кнопка «Проверить обновления»
(`checkUpdate(true)`), которая сообщает «последняя версия» / «не удалось
проверить» через toast — эти уведомления подавлены на автоматических
фоновых проверках, чтобы приложение не напоминало о себе каждые несколько
часов. Тумблер `SettingsData.autoUpdateEnabled` отключает и проверку при
запуске, и периодическую (значение перечитывается из `settings` на каждом
тике интервала, так что переключение прямо во время работы срабатывает
сразу); ручная кнопка тумблер игнорирует и работает всегда.
- Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub
Release. `release.yml` подписывает обе архитектуры парой ключей minisign:
публичная половина зашита в `tauri.conf.json`; приватная половина + пароль
@ -179,16 +259,19 @@ src-tauri/
|------|-----------|-----|
| `settings.json` | настройки | постоянно |
| `documents.json` | все листы | постоянно (дебаунс 400 мс) |
| `rates.json` | живые курсы валют | 1 час |
| `market.json` | котировки акций/сырья | 15 минут |
| `rates-YYYY-MM-DD.json` | исторические курсы ЕЦБ за одну дату | навсегда |
| `rates.json` | живые курсы валют (open.er-api.com + CoinGecko) | 1 час |
| `market.json` | котировки акций/сырья (Yahoo Finance v8 chart, по запросу на символ) | 15 минут |
| `rates-YYYY-MM-DD.json` | исторические курсы ЕЦБ за одну дату (frankfurter.dev) | навсегда |
| `backups/documents-YYYY-MM-DD.json` | ежедневные снимки | 14 дней |
| `backups/deleted/*.numi` | мягко удалённые листы | настраивается |
## Тесты
`npm test` запускает 113 vitest-кейсов по движку (`src/engine/*.test.ts`):
все классы выражений, оба языка, детерминированные инжектированные курсы,
поиск неизвестного, исторические курсы (инжектированные), плюс набор регрессий
по всем известным исправленным багам. UI проверяется вручную; движок — там
где живёт вся сложность.
`npm test` запускает 152 vitest-кейса: `src/engine/*.test.ts` покрывает все
классы выражений, оба языка, детерминированные инжектированные курсы, поиск
неизвестного, исторические курсы (инжектированные), литералы и отображение
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по
умолчанию зависит от локали машины CI), плюс набор регрессий по всем известным
исправленным багам; `src/workspace.test.ts` покрывает межлистовые ссылки,
кэширование и обнаружение циклов. UI проверяется вручную; движок — там где
живёт вся сложность.

View file

@ -67,13 +67,16 @@ the math is ignored, so `spent 20 on pizza and 5 on coffee` just gives 25.
`100 AAPL in USD`, `2 GC=F in USD` (gold futures), `MSFT + GOOGL`.
**Historical exchange rates** — `1000 USD in EUR on 2024-01-01` looks up the
ECB rate for that exact date (via frankfurter.app) and caches it permanently.
ECB rate for that exact date (via frankfurter.dev) and caches it permanently.
**Percents the human way** — `20% of 80`, `20% off 1500`, `5 as a % of 25`,
`20% of what is 30`.
**Dates and time** — `today + 2 weeks`, `tomorrow - today`, `time in New York`,
`now in unix`, `1750000000 as date`. ISO date literals: `2024-01-01 + 30 days`,
**Dates and time** — `today + 2 weeks`, `tomorrow - today`, `time in New York`
(also works with a raw IANA id: `time in Europe/Berlin`), `now in unix`,
`1750000000 as date`. Date literals: ISO (`2024-01-01 + 30 days`), or
dot/slash with a 4-digit year — `31.12.2024`, `12/31/2024` — where the
day/month order for ambiguous cases follows the Date format setting.
`days until 2027-01-01`, `days since 2020-03-15`.
**Goal seek** — put `?` where the unknown is and an `=` with the target:
@ -117,9 +120,13 @@ auto-derived one) so the reference doesn't break when its first line changes.
- **Data folder is configurable** — point it at OneDrive/Dropbox and your
sheets follow you between machines.
- **Always on top** (Settings) pins the window above everything else.
- The app checks for updates on launch and offers to install them — no
manual download-and-reinstall.
- Light/dark theme, en/ru interface, configurable precision and separators.
- The app checks for updates on launch and every few hours after that, and
offers to install them — no manual download-and-reinstall. Settings →
Updates → "Check for updates" runs one on demand, or turn automatic
checking off entirely with the toggle next to it.
- Light/dark theme, en/ru interface, configurable precision, separators, and
date format (system locale, `31.12.2024`, `12/31/2024`, or `2024-12-31`).
Settings is split into General / Format / Data / Updates tabs.
## Extensions
@ -149,7 +156,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
```bash
npm install
npm test # engine tests (113 cases)
npm test # engine tests (152 cases)
npm run tauri dev # run the app
npm run tauri build # build the installer
```

View file

@ -63,13 +63,16 @@ AAPL в USD → $213.55
`100 AAPL в USD`, `2 GC=F в USD` (фьючерс на золото), `MSFT + GOOGL`.
**Исторические курсы** — `1000 USD в EUR на 2024-01-01` запрашивает курс ЕЦБ
за этот день (через frankfurter.app) и кэширует его навсегда.
за этот день (через frankfurter.dev) и кэширует его навсегда.
**Проценты по-человечески** — `20% от 80`, `скидка 20% от 1500`, `5 это сколько % от 25`,
`20% от какого числа равно 30`.
**Даты и время** — `сегодня + 2 недели`, `завтра - сегодня`, `время в Нью-Йорке`,
`сейчас в unix`, `1750000000 как дата`. ISO-даты: `2024-01-01 + 30 дней`,
**Даты и время** — `сегодня + 2 недели`, `завтра - сегодня`, `время в Нью-Йорке`
(работает и с прямым IANA-id: `время в Europe/Berlin`), `сейчас в unix`,
`1750000000 как дата`. Форматы дат: ISO (`2024-01-01 + 30 дней`), или через
точку/слэш с 4-значным годом — `31.12.2024`, `12/31/2024` — порядок день/месяц
для неоднозначных случаев берётся из настройки «Формат даты».
`дней до 2027-01-01`, `дней с 2020-03-15`.
**Поиск неизвестного** — поставь `?` на место неизвестного и `=` с нужным результатом:
@ -112,9 +115,13 @@ AAPL в USD → $213.55
- **Папка данных настраивается** — укажи OneDrive/Dropbox и листы синхронизируются
между машинами.
- **Поверх всех окон** (в настройках) закрепляет окно над остальными.
- Приложение само проверяет обновления при запуске и предлагает установить —
вручную скачивать и переустанавливать не нужно.
- Светлая/тёмная тема, интерфейс en/ru, настраиваемая точность и разделители.
- Приложение проверяет обновления при запуске и потом каждые несколько
часов, предлагая установить — вручную скачивать и переустанавливать не
нужно. Настройки → Обновления → «Проверить обновления» запускает проверку
вручную, либо отключи автопроверку целиком тумблером рядом.
- Светлая/тёмная тема, интерфейс en/ru, настраиваемая точность, разделители
и формат даты (системный, `31.12.2024`, `12/31/2024` или `2024-12-31`).
Настройки разбиты на вкладки: Общие / Формат / Данные / Обновления.
## Расширения
@ -144,7 +151,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" },
```bash
npm install
npm test # тесты движка (113 кейсов)
npm test # тесты движка (152 кейса)
npm run tauri dev # запустить приложение
npm run tauri build # собрать установщик
```