settings: tabbed panel, date format, update controls

The flat settings list had grown to 14 fields plus footer buttons —
split it into General / Format / Data / Updates tabs (same pill style
as the pinned-doc sidebar, active tab gets the selection color).

Wired the date-format setting into the engine, and reworked update
checks per an actual bug report: a boot-only check is easy to miss on
a tray app that can run for weeks without a cold restart, and every
failure was swallowed silently with no logging at all. Now: failures
are logged (console.warn) and successful "no update" checks say why
(console.info, current version vs. latest published release) so the
next report has something to go on; checks also rerun every 6 hours;
Settings has both a manual "Check for updates" button (reports back
via toast) and a toggle to turn automatic checking off entirely —
manual checks keep working either way.
This commit is contained in:
Horizontal42 2026-07-03 12:00:39 +03:00
parent 5af7c66c3c
commit 1e06281f5e
6 changed files with 210 additions and 66 deletions

View file

@ -47,6 +47,14 @@
</main>
<div id="settings-panel" class="hidden">
<h2 data-i18n="settings">Settings</h2>
<div id="settings-tabs" role="tablist">
<button class="tab active" data-tab="general" data-i18n="tabGeneral">General</button>
<button class="tab" data-tab="format" data-i18n="tabFormat">Format</button>
<button class="tab" data-tab="data" data-i18n="tabData">Data</button>
<button class="tab" data-tab="updates" data-i18n="tabUpdates">Updates</button>
</div>
<div class="settings-tabpanel" data-tabpanel="general">
<label><span data-i18n="theme">Theme</span>
<select id="set-theme">
<option value="system" data-i18n="system">System</option>
@ -54,6 +62,30 @@
<option value="dark" data-i18n="dark">Dark</option>
</select>
</label>
<label><span data-i18n="language">Language</span>
<select id="set-lang">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</label>
<label><span data-i18n="fontsize">Font size</span>
<input id="set-fontsize" type="number" min="10" max="32" />
</label>
<label><span data-i18n="resultswidth">Results column</span>
<input id="set-resultswidth" type="range" min="20" max="60" step="1" />
</label>
<label class="row"><input id="set-alwaysontop" type="checkbox" />
<span data-i18n="alwaysontop">Always on top</span>
</label>
<label><span data-i18n="hotkey">Global hotkey</span>
<input id="set-hotkey" type="text" placeholder="Ctrl+Alt+N" />
</label>
<label class="row"><input id="set-autostart" type="checkbox" />
<span data-i18n="autostart">Launch at startup</span>
</label>
</div>
<div class="settings-tabpanel hidden" data-tabpanel="format">
<label><span data-i18n="precision">Decimal places</span>
<input id="set-precision" type="number" min="0" max="15" />
</label>
@ -70,36 +102,39 @@
<option value=",">0,5</option>
</select>
</label>
<label><span data-i18n="language">Language</span>
<select id="set-lang">
<option value="en">English</option>
<option value="ru">Русский</option>
<label><span data-i18n="dateformat">Date format</span>
<select id="set-dateformat">
<option value="system" data-i18n="sysDefault">System</option>
<option value="dmy">31.12.2024</option>
<option value="mdy">12/31/2024</option>
<option value="iso">2024-12-31</option>
</select>
</label>
<label><span data-i18n="hotkey">Global hotkey</span>
<input id="set-hotkey" type="text" placeholder="Ctrl+Alt+N" />
</label>
<label class="row"><input id="set-autostart" type="checkbox" />
<span data-i18n="autostart">Launch at startup</span>
</label>
<label class="row"><input id="set-alwaysontop" type="checkbox" />
<span data-i18n="alwaysontop">Always on top</span>
</label>
<label><span data-i18n="fontsize">Font size</span>
<input id="set-fontsize" type="number" min="10" max="32" />
</label>
<label><span data-i18n="resultswidth">Results column</span>
<input id="set-resultswidth" type="range" min="20" max="60" step="1" />
</div>
<div class="settings-tabpanel hidden" data-tabpanel="data">
<label><span data-i18n="datadir">Data folder</span>
<button id="set-datadir" class="path-btn" title=""></button>
</label>
<label><span data-i18n="bindays">Bin retention (days)</span>
<input id="set-bindays" type="number" min="1" max="365" step="1" />
</label>
<label><span data-i18n="datadir">Data folder</span>
<button id="set-datadir" class="path-btn" title=""></button>
</label>
<div class="settings-footer">
<div class="settings-tabpanel-buttons">
<button id="open-extensions" data-i18n="extensions">Extensions folder</button>
<button id="open-backups" data-i18n="backups">Backups</button>
</div>
</div>
<div class="settings-tabpanel hidden" data-tabpanel="updates">
<label class="row"><input id="set-autoupdate" type="checkbox" />
<span data-i18n="autoUpdate">Automatically check for updates</span>
</label>
<div class="settings-tabpanel-buttons">
<button id="check-update" data-i18n="checkUpdate">Check for updates</button>
</div>
</div>
<div class="settings-footer">
<button id="close-settings" data-i18n="done">Done</button>
</div>
</div>

View file

@ -66,6 +66,16 @@ const dict: Record<string, Record<string, string>> = {
exportImage: "Copy as image",
imageCopied: "Image copied",
imageFailed: "Could not copy image",
checkUpdate: "Check for updates",
upToDate: "You're on the latest version",
updateCheckFailed: "Could not check for updates",
autoUpdate: "Automatically check for updates",
dateformat: "Date format",
sysDefault: "System",
tabGeneral: "General",
tabFormat: "Format",
tabData: "Data",
tabUpdates: "Updates",
},
ru: {
settings: "Настройки",
@ -132,6 +142,16 @@ const dict: Record<string, Record<string, string>> = {
exportImage: "Скопировать картинкой",
imageCopied: "Картинка скопирована",
imageFailed: "Не удалось скопировать картинку",
checkUpdate: "Проверить обновления",
upToDate: "У вас последняя версия",
updateCheckFailed: "Не удалось проверить обновления",
autoUpdate: "Автоматически проверять обновления",
dateformat: "Формат даты",
sysDefault: "Системный",
tabGeneral: "Общие",
tabFormat: "Формат",
tabData: "Данные",
tabUpdates: "Обновления",
},
};

View file

@ -197,6 +197,7 @@ function applySettings(): void {
precision: settings.precision,
groupSeparator: settings.groupSeparator,
decimalSeparator: settings.decimalSeparator,
dateFormat: settings.dateFormat,
});
editor?.refresh();
syncTitleField();
@ -208,6 +209,7 @@ function bindSettingsUI(): void {
const precision = $<HTMLInputElement>("#set-precision");
const groupSep = $<HTMLSelectElement>("#set-groupsep");
const decimalSep = $<HTMLSelectElement>("#set-decimalsep");
const dateFmt = $<HTMLSelectElement>("#set-dateformat");
const langSel = $<HTMLSelectElement>("#set-lang");
const hotkey = $<HTMLInputElement>("#set-hotkey");
const autostart = $<HTMLInputElement>("#set-autostart");
@ -219,6 +221,7 @@ function bindSettingsUI(): void {
precision.value = String(settings.precision);
groupSep.value = settings.groupSeparator;
decimalSep.value = settings.decimalSeparator;
dateFmt.value = settings.dateFormat;
langSel.value = settings.language;
hotkey.value = settings.hotkey;
autostart.checked = settings.autostart;
@ -236,13 +239,14 @@ function bindSettingsUI(): void {
settings.groupSeparator = settings.decimalSeparator === "," ? " " : ",";
groupSep.value = settings.groupSeparator;
}
settings.dateFormat = dateFmt.value as SettingsData["dateFormat"];
settings.language = langSel.value;
settings.fontSize = Math.max(10, Math.min(32, Number(fontSize.value) || 15));
settings.resultsWidth = Math.max(20, Math.min(60, Number(resultsWidth.value) || 42));
applySettings();
void saveSettings(settings);
};
for (const el of [themeSel, precision, groupSep, decimalSep, langSel, fontSize]) {
for (const el of [themeSel, precision, groupSep, decimalSep, dateFmt, langSel, fontSize]) {
el.addEventListener("change", save);
}
resultsWidth.addEventListener("input", save); // live while sliding
@ -346,6 +350,22 @@ function bindSettingsUI(): void {
toast(t("folderChanged"));
});
const autoUpdate = $<HTMLInputElement>("#set-autoupdate");
autoUpdate.checked = settings.autoUpdateEnabled;
autoUpdate.addEventListener("change", () => {
settings.autoUpdateEnabled = autoUpdate.checked;
void saveSettings(settings);
});
const tabs = document.querySelectorAll<HTMLButtonElement>("#settings-tabs .tab");
const tabPanels = document.querySelectorAll<HTMLElement>(".settings-tabpanel");
for (const tab of tabs) {
tab.addEventListener("click", () => {
for (const other of tabs) other.classList.toggle("active", other === tab);
for (const panel of tabPanels) panel.classList.toggle("hidden", panel.dataset.tabpanel !== tab.dataset.tab);
});
}
$("#open-settings").addEventListener("click", () => {
$("#settings-panel").classList.toggle("hidden");
});
@ -354,6 +374,7 @@ function bindSettingsUI(): void {
});
$("#open-extensions").addEventListener("click", () => void openExtensionsFolder());
$("#open-backups").addEventListener("click", () => void openBackupsFolder(settings.dataDir));
$("#check-update").addEventListener("click", () => void checkUpdate(true));
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (settings.theme === "system") applySettings();
@ -591,7 +612,12 @@ async function boot(): Promise<void> {
localStorage.setItem("summarum.langInit", "1");
}
engine = new SumEngine({ precision: settings.precision, groupSeparator: settings.groupSeparator });
engine = new SumEngine({
precision: settings.precision,
groupSeparator: settings.groupSeparator,
decimalSeparator: settings.decimalSeparator,
dateFormat: settings.dateFormat,
});
// snapshots run before the app writes anything
setDataDir(settings.dataDir);
@ -852,17 +878,30 @@ async function boot(): Promise<void> {
}
}
void checkUpdate();
if (settings.autoUpdateEnabled) void checkUpdate();
// a tray app can stay running for weeks without a cold restart, and the
// update check above only ever runs once at boot — recheck periodically
// so a long-lived process still notices new releases; re-read the setting
// on every tick since the user can flip it while the app is running
setInterval(() => { if (settings.autoUpdateEnabled) void checkUpdate(); }, 6 * 60 * 60 * 1000);
}
async function checkUpdate(): Promise<void> {
const update = await checkForUpdate();
if (!update) return;
const choice = await askModal(t("updateAvailable").replace("{}", update.version), t("updateInstall"), t("updateLater"));
/** manual = true when the user clicked "Check for updates" — only then report "up to date" / "check failed" */
async function checkUpdate(manual = false): Promise<void> {
const result = await checkForUpdate();
if (result === "error") {
if (manual) toast(t("updateCheckFailed"));
return;
}
if (!result) {
if (manual) toast(t("upToDate"));
return;
}
const choice = await askModal(t("updateAvailable").replace("{}", result.version), t("updateInstall"), t("updateLater"));
if (choice !== "a") return;
toast(t("updateInstalling"));
try {
await update.install();
await result.install();
} catch {
toast(t("updateFailed"));
}

View file

@ -32,6 +32,10 @@ export interface SettingsData {
deletedRetentionDays: number;
/** keep the window above others */
alwaysOnTop: boolean;
/** date display layout; "system" also picks day/month order for ambiguous input like 01/02/2024 */
dateFormat: "system" | "iso" | "dmy" | "mdy";
/** background update checks at boot + every few hours; manual "Check for updates" always works regardless */
autoUpdateEnabled: boolean;
}
export const defaultSettingsData: SettingsData = {
@ -48,6 +52,8 @@ export const defaultSettingsData: SettingsData = {
dataDir: "",
deletedRetentionDays: 14,
alwaysOnTop: false,
dateFormat: "system",
autoUpdateEnabled: true,
};
/** "" -> undefined for the Rust side (default app data dir) */

View file

@ -360,6 +360,38 @@ body {
}
#settings-panel.hidden { display: none; }
#settings-panel h2 { margin: 0 0 10px; font-size: 14px; }
#settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
#settings-tabs .tab {
border: none;
background: none;
color: var(--fg-muted);
font-family: var(--font);
font-size: 12px;
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
}
#settings-tabs .tab:hover { background: var(--hover); color: var(--fg); }
#settings-tabs .tab.active { background: var(--sel); color: var(--fg); }
.settings-tabpanel.hidden { display: none; }
.settings-tabpanel-buttons { display: flex; gap: 6px; margin-top: 10px; }
.settings-tabpanel-buttons button {
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
border-radius: 6px;
padding: 5px 12px;
cursor: pointer;
font-size: 12.5px;
}
.settings-tabpanel-buttons button:hover { background: var(--hover); }
#settings-panel label {
display: flex;
justify-content: space-between;
@ -394,7 +426,7 @@ body {
}
.path-btn:hover { background: var(--hover); }
.settings-footer { display: flex; justify-content: space-between; gap: 6px; margin-top: 12px; }
.settings-footer { display: flex; justify-content: flex-end; gap: 6px; margin-top: 12px; }
.settings-footer button {
border: 1px solid var(--border);
background: var(--bg);

View file

@ -5,13 +5,24 @@ export interface AvailableUpdate {
install(): Promise<void>;
}
/** null if no update, offline, or running outside Tauri (`vite dev`). */
export async function checkForUpdate(): Promise<AvailableUpdate | null> {
/**
* null = no update available (checked successfully); "error" = the check
* itself failed (offline, updater/signature error); running outside Tauri
* (`vite dev`) also reports null.
*/
export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"> {
if (!isTauri()) return null;
try {
const { check } = await import("@tauri-apps/plugin-updater");
const update = await check();
if (!update) return null;
if (!update) {
// visible in devtools console — the fastest way to tell "already on
// the latest published release" apart from "the check silently failed"
const { getVersion } = await import("@tauri-apps/api/app");
console.info(`no update: running ${await getVersion()}, that's the latest published release`);
return null;
}
console.info(`update available: ${update.currentVersion} -> ${update.version}`);
return {
version: update.version,
async install() {
@ -20,7 +31,8 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null> {
await relaunch();
},
};
} catch {
return null;
} catch (e) {
console.warn("update check failed", e);
return "error";
}
}