fix(oxibadge): show connection errors instead of failing silently
Check, format and test / build (push) Successful in 10m4s

load_records/load_employees failures were only logged to the console
and silently reset the UI to the month picker. Errors now flow through
a shared store (src/lib/error.ts) into an ErrorBanner, reused by both
Onboarding and ConfirmMonth, with the raw message passed straight
through from Rust (no stable-code classification layer — these are
mostly plain I/O errors, not worth categorizing).

i18n stays grouped per screen as before, with error-related strings
split into their own top-level "errors" group.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:26:01 +02:00
parent 28186c7745
commit b4cd8e1796
7 changed files with 47 additions and 56 deletions
+5 -17
View File
@@ -22,27 +22,15 @@ async fn test_connection(host: String, port: u16) -> Result<(), String> {
connect_with_retries(&host, port).await.map(|_| ())
}
/// Connects to the device, trying up to 3 times, returning a stable error
/// code (not a user-facing message) on failure so the frontend can
/// translate it.
/// Connects to the device, trying up to 3 times, returning the last error
/// message as-is on failure.
async fn connect_with_retries(host: &str, port: u16) -> Result<R701, String> {
let mut last_error = String::new();
for _ in 0..3 {
let err = match R701::connect((host, port)).await {
match R701::connect((host, port)).await {
Ok(r701) => return Ok(r701),
Err(err) => err,
};
last_error = match err.kind() {
std::io::ErrorKind::ConnectionRefused => "connection-refused".to_string(),
std::io::ErrorKind::TimedOut => "timed-out".to_string(),
_ if err
.to_string()
.contains("failed to lookup address information") =>
{
"dns-failure".to_string()
}
_ => "unknown".to_string(),
};
Err(err) => last_error = err.to_string(),
}
}
Err(last_error)
}
@@ -2,9 +2,11 @@
import { tick } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import { error, clearError } from '$lib/error';
import Column from './Column.svelte';
import Intro from './Intro.svelte';
import Button from './Button.svelte';
import ErrorBanner from './ErrorBanner.svelte';
let { onstart }: { onstart: (selection: { year: number; month: number }) => void } = $props();
@@ -46,12 +48,14 @@
if (isFuture(i)) return;
month = i;
open = null;
clearError();
}
function pickYear(y: number) {
year = y;
if (y === currentYear && month > currentMonth) month = currentMonth;
open = null;
clearError();
}
function onYearScroll() {
@@ -157,6 +161,10 @@
</div>
<Button label={$_('confirm_month.button')} onclick={start} />
{#if $error}
<ErrorBanner title={$_('errors.title')} message={$error} />
{/if}
</Column>
<style>
+14 -24
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { _ } from 'svelte-i18n';
import { error, setError, clearError } from '$lib/error';
import Column from './Column.svelte';
import Intro from './Intro.svelte';
import Field from './Field.svelte';
@@ -10,40 +11,29 @@
let { onconnected }: { onconnected: (cfg: { host: string; port: number }) => void } = $props();
type Phase = 'idle' | 'testing' | 'err';
let host = $state('');
let port = $state(5005);
let phase = $state<Phase>('idle');
let errorCode = $state('');
let testing = $state(false);
const trimmedHost = $derived(host.trim());
const empty = $derived(trimmedHost === '');
const disabled = $derived(empty || phase === 'testing');
const disabled = $derived(empty || testing);
const btnLabel = $derived(
phase === 'testing'
? $_('onboarding.button.testing')
: phase === 'err'
? $_('onboarding.button.retry')
: $_('onboarding.button.idle')
testing ? $_('onboarding.button.testing') : $error ? $_('onboarding.button.retry') : $_('onboarding.button.idle')
);
function resetPhase() {
phase = 'idle';
errorCode = '';
}
async function testAndSave() {
if (disabled) return;
phase = 'testing';
errorCode = '';
testing = true;
clearError();
try {
await invoke('test_connection', { host: trimmedHost, port });
await invoke('save_preferences', { host: trimmedHost, port });
onconnected({ host: trimmedHost, port });
} catch (err) {
errorCode = String(err);
phase = 'err';
setError(err);
} finally {
testing = false;
}
}
</script>
@@ -64,7 +54,7 @@
label={$_('onboarding.host_label')}
bind:value={host}
placeholder="192.168.1.100"
oninput={resetPhase}
oninput={clearError}
>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
@@ -79,14 +69,14 @@
bind:value={port}
type="number"
placeholder="5005"
oninput={resetPhase}
oninput={clearError}
/>
</div>
<Button type="submit" label={btnLabel} faded={empty} busy={phase === 'testing'} {disabled} />
<Button type="submit" label={btnLabel} faded={empty} busy={testing} {disabled} />
{#if phase === 'err'}
<ErrorBanner title={$_('onboarding.error_title')} message={$_(`onboarding.errors.${errorCode}`)} />
{#if $error}
<ErrorBanner title={$_('errors.title')} message={$error} />
{/if}
<Hint text={$_('onboarding.hint')} />
+11
View File
@@ -0,0 +1,11 @@
import { writable } from 'svelte/store';
export const error = writable('');
export function setError(err: unknown) {
error.set(String(err));
}
export function clearError() {
error.set('');
}
+3 -7
View File
@@ -10,13 +10,6 @@
"idle": "Test connection",
"testing": "Connecting...",
"retry": "Retry"
},
"error_title": "Could not reach the time clock",
"errors": {
"connection-refused": "Connection refused",
"timed-out": "Connection timed out",
"dns-failure": "Invalid or unreachable address",
"unknown": "Connection error"
}
},
"confirm_month": {
@@ -38,6 +31,9 @@
"title": "Fetching employees...",
"description": "Reading the employee names registered on the time clock. Just a moment."
},
"errors": {
"title": "Could not reach the time clock"
},
"done": {
"message": "Nothing to do here."
}
+3 -7
View File
@@ -10,13 +10,6 @@
"idle": "Testa connessione",
"testing": "Connessione in corso...",
"retry": "Riprova"
},
"error_title": "Impossibile raggiungere il timbratore",
"errors": {
"connection-refused": "Connessione rifiutata",
"timed-out": "Connessione scaduta",
"dns-failure": "Indirizzo non valido o non raggiungibile",
"unknown": "Errore di connessione"
}
},
"confirm_month": {
@@ -38,6 +31,9 @@
"title": "Recupero i dipendenti...",
"description": "Sto leggendo i nomi dei dipendenti registrati sul timbratore. Ci vuole solo un attimo."
},
"errors": {
"title": "Impossibile raggiungere il timbratore"
},
"done": {
"message": "Non c'è niente da fare."
}
+3 -1
View File
@@ -3,6 +3,7 @@
import { onMount } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import { setError, clearError } from '$lib/error';
import Screen from '$lib/components/Screen.svelte';
import Onboarding from '$lib/components/Onboarding.svelte';
import ConfirmMonth from '$lib/components/ConfirmMonth.svelte';
@@ -25,13 +26,14 @@
selectedYear = selection.year;
selectedMonth = selection.month;
step = 'loading-records';
clearError();
try {
const records = await invoke('load_records', selection);
step = 'loading-employees';
await invoke('load_employees', { records });
step = 'done';
} catch (err) {
console.error(err);
setError(err);
step = 'confirm-month';
}
}