feat(oxibadge): add settings screen for shifts, rounding and language
Check, format and test / build (push) Successful in 10m22s

Add a settings screen (gear button on the month-confirmation screen)
to configure the expected shift times, the rounding step, the
interface language, and the time clock address — replacing the old
onboarding-only connection flow. Settings persist to the same
preferences.toml as the reader address, under a single load_settings/
save_settings command pair.

The review screen's "fix shifts" action now redistributes stacked
punches by nearest configured shift time instead of blind position,
and a stacked cell manually cleared down to one entry no longer
leaves a dangling empty input.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:29:57 +02:00
parent acd3efeac6
commit b65e85f908
26 changed files with 1397 additions and 118 deletions
@@ -0,0 +1,332 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { _ } from 'svelte-i18n';
import { error, setError, clearError } from '$lib/error';
import type { Settings } from '$lib/settings';
import Field from '../Field.svelte';
import ErrorBanner from '../ErrorBanner.svelte';
import ActionButton from '../review/ActionButton.svelte';
import { IPV4 } from './logic';
let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } =
$props();
let editing = $state(false);
let draftHost = $state('');
let draftPort = $state(0);
let ipError = $state(false);
let status = $state<'' | 'testing' | 'ok'>('');
function startEdit() {
draftHost = host;
draftPort = port;
ipError = false;
clearError();
editing = true;
}
function cancelEdit() {
editing = false;
ipError = false;
}
async function confirmEdit() {
const v = draftHost.trim();
if (!IPV4.test(v)) {
ipError = true;
return;
}
const nextPort = draftPort || 5005;
editing = false;
status = '';
clearError();
try {
const settings = await invoke<Settings>('load_settings');
await invoke('save_settings', { settings: { ...settings, reader: { host: v, port: nextPort } } });
onsave(v, nextPort);
} catch (err) {
setError(err);
}
}
async function test() {
status = 'testing';
clearError();
try {
await invoke('test_connection', { host, port });
status = 'ok';
} catch (err) {
status = '';
setError(err);
}
}
function onPortInput(e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget;
const next = input.value.replace(/\D/g, '').slice(0, 5);
if (input.value !== next) input.value = next;
draftPort = Number(next);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && editing) cancelEdit();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="card" class:active={editing}>
{#if !editing}
<div class="dev-row">
<div class="dev-icon">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 1.8"
><rect x="5" y="3" width="14" height="18" rx="2.5" /><rect x="8" y="6" width="8" height="5" rx="1" /><path
d="M9 15h.01M12 15h.01M15 15h.01M9 18h6"
/></svg
>
</div>
<div class="dev-text">
<span class="dev-label">{$_('settings.device_label')}</span>
<span class="dev-endpoint mono">{host}:{port}</span>
</div>
<span class="dev-status">
{#if status === 'testing'}
<svg class="spin" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M12 3a9 9 0 1 0 9 9" /></svg
>
{$_('settings.status_testing')}
{:else if status === 'ok'}
<span class="dot ok" aria-hidden="true"></span>
{$_('settings.status_ok')}
{:else}
<span class="dot" aria-hidden="true"></span>
{$_('settings.status_idle')}
{/if}
</span>
</div>
<div class="dev-actions">
<button type="button" class="pill" onclick={startEdit}>
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
>
<span>{$_('settings.edit_address')}</span>
</button>
<button type="button" class="pill" onclick={test} disabled={status === 'testing'}>
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01" /></svg
>
<span>{$_('settings.test_connection')}</span>
</button>
</div>
{:else}
<form
class="dev-edit"
onsubmit={(e) => {
e.preventDefault();
confirmEdit();
}}
>
<div class="grid-ip">
<div class="stack">
<Field
id="dev-ip"
label={$_('settings.ip_label')}
bind:value={draftHost}
inputmode="decimal"
placeholder="192.168.1.100"
err={ipError}
oninput={() => (ipError = false)}
/>
{#if ipError}<span class="field-error">{$_('settings.ip_invalid')}</span>{/if}
</div>
<Field
id="dev-port"
label={$_('settings.port_label')}
value={draftPort}
maxlength={5}
inputmode="numeric"
placeholder="5005"
oninput={onPortInput}
/>
</div>
<div class="row-end">
<ActionButton onclick={cancelEdit}>{$_('settings.cancel')}</ActionButton>
<ActionButton primary onclick={confirmEdit}>{$_('settings.confirm')}</ActionButton>
</div>
</form>
{/if}
</div>
{#if $error}
<ErrorBanner title={$_('errors.title')} message={$error} />
{/if}
<style>
.mono {
font-family: var(--ox-font-mono);
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.card {
border: 1px solid #262626;
border-radius: 14px;
background: #161616;
}
.card.active {
border-color: #3a3a3a;
}
.dev-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
padding: 16px 16px 16px 18px;
}
.dev-icon {
width: 44px;
height: 44px;
flex: none;
border-radius: 11px;
background: #1f1f1f;
color: #bdbdbd;
display: flex;
align-items: center;
justify-content: center;
}
.dev-text {
flex: 1;
min-width: 160px;
display: flex;
flex-direction: column;
gap: 3px;
}
.dev-label {
font-size: 12.5px;
color: #8a8a8a;
}
.dev-endpoint {
font-size: 16px;
color: #f2f2f2;
}
.dev-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6e6e6e;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.dot.ok {
background: #4cc38a;
}
.dev-status:has(.ok) {
color: #4cc38a;
}
.dev-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px 14px 18px;
border-top: 1px solid #222222;
}
.pill {
all: unset;
box-sizing: border-box;
height: 36px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 7px;
border-radius: 9px;
border: 1px solid #333333;
color: #c9c9c9;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.pill:hover:not(:disabled) {
background: #1e1e1e;
color: #ffffff;
border-color: #4a4a4a;
}
.pill:disabled {
opacity: 0.5;
cursor: progress;
}
.pill:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.dev-edit {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px 16px 16px;
}
.grid-ip {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 12px;
align-items: start;
}
.stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.row-end {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.field-error {
padding-left: 4px;
font-size: 12.5px;
color: #ff8a8f;
}
@media (max-width: 560px) {
.grid-ip {
grid-template-columns: minmax(0, 1fr);
}
}
</style>