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
@@ -1,8 +1,10 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { LUNCH, ROUND_STEP, dayWord } from './analysis';
import { autofocus } from '$lib/autofocus';
import type { Shifts } from '$lib/shifts';
import { dayWord } from './analysis';
import FixButton from './FixButton.svelte';
import IconButton from './IconButton.svelte';
import IconButton from '../IconButton.svelte';
let {
name,
@@ -17,6 +19,7 @@
stackedCount,
lunchCount,
roundCount,
shift,
onfixall,
onaddlunchall,
onroundall,
@@ -34,6 +37,7 @@
stackedCount: number;
lunchCount: number;
roundCount: number;
shift: Shifts;
onfixall: () => void;
onaddlunchall: () => void;
onroundall: () => void;
@@ -51,10 +55,6 @@
editing = false;
}
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
</script>
<div class="main-head">
@@ -121,7 +121,7 @@
{#if lunchCount}
<FixButton
size="lg"
title={$_('review.add_lunch_title', { values: { start: LUNCH.start, end: LUNCH.end } })}
title={$_('review.add_lunch_title', { values: { start: shift.out1, end: shift.in2 } })}
onclick={onaddlunchall}
>
{#snippet icon()}
@@ -139,7 +139,7 @@
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
>
{/snippet}
{$_('review.round_all', { values: { step: ROUND_STEP, days: dayWord(roundCount, $_) } })}
{$_('review.round_all', { values: { step: shift.round_step_min, days: dayWord(roundCount, $_) } })}
</FixButton>
{/if}
<div class="seg" role="group" aria-label={$_('review.day_filter_aria')}>
@@ -1,6 +1,6 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { ROUND_STEP } from './analysis';
import type { Shifts } from '$lib/shifts';
import FixButton from './FixButton.svelte';
import ActionButton from './ActionButton.svelte';
@@ -8,6 +8,7 @@
title,
scopeIssues,
scopeInfos,
shift,
items,
onclose,
onroundandexport,
@@ -16,6 +17,7 @@
title: string;
scopeIssues: number;
scopeInfos: number;
shift: Shifts;
items: { key: string; label: string; detail: string; red: boolean; go: () => void }[];
onclose: () => void;
onroundandexport: () => void;
@@ -40,7 +42,7 @@
{#if scopeIssues}
{$_('review.export_warning_issues')}
{:else}
{$_('review.export_warning_unrounded', { values: { step: ROUND_STEP } })}
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
{/if}
</p>
</div>
@@ -1,68 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
size = 'sm',
accent = false,
title,
ariaLabel,
onclick,
icon
}: {
size?: 'sm' | 'lg';
accent?: boolean;
title?: string;
ariaLabel: string;
onclick: () => void;
icon: Snippet;
} = $props();
</script>
<button type="button" class="icon-btn" class:lg={size === 'lg'} class:accent {title} aria-label={ariaLabel} {onclick}>
{@render icon()}
</button>
<style>
.icon-btn {
all: unset;
box-sizing: border-box;
width: 32px;
height: 32px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #8a8a8a;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.icon-btn.lg {
width: 40px;
height: 40px;
border-radius: 10px;
color: #a3a3a3;
}
.icon-btn:hover {
background: #1e1e1e;
color: #ffffff;
}
.icon-btn:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 1px;
}
.icon-btn.accent {
background: var(--ox-accent);
color: var(--ox-on-accent);
}
.icon-btn.accent:hover {
filter: brightness(1.08);
}
</style>
@@ -1,12 +1,14 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { COLS_KEYS, LUNCH, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
import type { Shifts } from '$lib/shifts';
import { COLS_KEYS, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
import FixButton from './FixButton.svelte';
let {
rows,
rounded,
emp,
shift,
oncellinput,
onfixday,
onaddlunch,
@@ -15,6 +17,7 @@
rows: Row[];
rounded: Record<string, string>;
emp: number;
shift: Shifts;
oncellinput: (di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) => void;
onfixday: (di: number) => void;
onaddlunch: (di: number) => void;
@@ -49,7 +52,7 @@
0
)}
{@const manual = r.rowEdits - rnd}
{@const note = noteOf(r, rnd, manual, $_)}
{@const note = noteOf(r, rnd, manual, $_, shift.round_step_min)}
{@const label = dayLabel(r)}
<tr class:we={r.weekend} class:anom={!!r.issue}>
<td class="first center"><span class="dow">{r.dow}</span><span class="mono">{r.di + 1}</span></td>
@@ -105,7 +108,7 @@
{/if}
{#if r.lunch}
<FixButton
ariaLabel={$_('review.add_lunch_day_aria', { values: { start: LUNCH.start, end: LUNCH.end, label } })}
ariaLabel={$_('review.add_lunch_day_aria', { values: { start: shift.out1, end: shift.in2, label } })}
onclick={() => onaddlunch(r.di)}
>
{#snippet icon()}
@@ -113,7 +116,7 @@
><path d="M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 3.5-3 7h3" /></svg
>
{/snippet}
{$_('review.lunch_short')} <span class="mono">{LUNCH.start}–{LUNCH.end}</span>
{$_('review.lunch_short')} <span class="mono">{shift.out1}–{shift.in2}</span>
</FixButton>
{/if}
{#if r.info}
@@ -2,7 +2,7 @@
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import { dayWord, shortSummary } from './analysis';
import IconButton from './IconButton.svelte';
import IconButton from '../IconButton.svelte';
let {
year,
+51 -51
View File
@@ -4,6 +4,10 @@
// svelte-i18n) e, dove serve la lingua per Intl, `locale` ($locale).
import { weekdayName } from '$lib/month';
import type { Shifts } from '$lib/shifts';
import { fmt, formatTyped, hm, parseT } from '$lib/time';
export { formatTyped, hm, parseT };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches svelte-i18n's own loosely-typed `values`
export type Translate = (key: string, options?: { values?: Record<string, any> }) => string;
@@ -50,10 +54,6 @@ export interface Analysis {
roundDays: number[];
}
// ---- Parametri (TODO: renderli configurabili) ----
export const LUNCH = { start: '12:30', end: '13:30' };
export const ROUND_STEP = 10;
export const COLS_KEYS = ['review.col_in_1', 'review.col_out_1', 'review.col_in_2', 'review.col_out_2'];
export function normDay(day: Day): Day {
@@ -63,73 +63,67 @@ export function normDay(day: Day): Day {
}) as Day;
}
export function parseT(t: string): number | null {
const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(t);
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
}
export function fmt(m: number): string {
return String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
}
export function hm(m: number): string {
return Math.floor(m / 60) + ':' + String(m % 60).padStart(2, '0');
}
export function isRounded(t: string): boolean {
export function isRounded(t: string, roundStep: number): boolean {
const m = parseT(t);
return m === null || m % ROUND_STEP === 0;
return m === null || m % roundStep === 0;
}
export function roundT(t: string): string {
export function roundT(t: string, roundStep: number): string {
const m = parseT(t);
if (m === null) return t;
return fmt(Math.min(Math.round(m / ROUND_STEP) * ROUND_STEP, 24 * 60 - ROUND_STEP));
return fmt(Math.min(Math.round(m / roundStep) * roundStep, 24 * 60 - roundStep));
}
export function dayWord(n: number, t: Translate): string {
return t('review.day_word', { values: { n } });
}
/** Formatta l'orario mentre si scrive, senza dover digitare ":". */
export function formatTyped(raw: string, prev: string, inputType: string): string {
const deleting = inputType ? inputType.startsWith('delete') : raw.length < prev.length && prev.startsWith(raw);
const digits = raw.replace(/\D/g, '').slice(0, 4);
if (!digits) return '';
let h: string;
let m: string;
if (digits[0] >= '3' || (digits[0] === '2' && digits.length > 1 && digits[1] > '3')) {
h = '0' + digits[0];
m = digits.slice(1, 3);
} else {
h = digits.slice(0, 2);
m = digits.slice(2, 4);
}
if (h.length < 2) return h;
if (!m) return deleting ? h : h + ':';
return h + ':' + m;
}
/** [entrata, uscita] se il giorno ha solo 2 timbrature che scavalcano la pausa pranzo. */
export function lunchSpan(day: Day): [string, string] | null {
export function lunchSpan(day: Day, shift: Shifts): [string, string] | null {
const all = day.flat().filter((x) => x !== '');
if (all.length !== 2) return null;
const t = all.map(parseT);
if (t.some((x) => x === null)) return null;
const [a, b] = (t[0] as number) <= (t[1] as number) ? [all[0], all[1]] : [all[1], all[0]];
return (parseT(a) as number) < (parseT(LUNCH.start) as number) &&
(parseT(b) as number) > (parseT(LUNCH.end) as number)
return (parseT(a) as number) < (parseT(shift.out1) as number) &&
(parseT(b) as number) > (parseT(shift.in2) as number)
? [a, b]
: null;
}
/**
* Ridistribuisce timbrature finite tutte nello stesso turno (es. per un
* timbratore configurato male) nelle 4 caselle corrette, scegliendo per
* ciascuna l'orario previsto (E1/U1/E2/U2) più vicino. Dato che gli orari
* sono ordinati e i turni previsti pure, basta confrontare ogni timbratura
* col punto a metà strada fra due turni consecutivi: è esattamente il
* confine fra "più vicino al turno prima" e "più vicino al turno dopo".
*/
export function assignToShifts(times: string[], shift: Shifts): Day {
const targets = [shift.in1, shift.out1, shift.in2, shift.out2].map((x) => parseT(x) as number);
const bounds = [(targets[0] + targets[1]) / 2, (targets[1] + targets[2]) / 2, (targets[2] + targets[3]) / 2];
const day: Day = [[], [], [], []];
const sorted = [...times].sort((a, b) => (parseT(a) ?? Infinity) - (parseT(b) ?? Infinity));
for (const time of sorted) {
const m = parseT(time);
const slot = m === null ? 3 : m < bounds[0] ? 0 : m < bounds[1] ? 1 : m < bounds[2] ? 2 : 3;
day[slot].push(time);
}
for (let i = 0; i < 4; i++) {
if (day[i].length === 0) day[i] = [''];
}
return day;
}
export function analyze(
year: number,
month: number,
values: Day[],
orig: Day[],
locale: string | null | undefined,
t: Translate
t: Translate,
shift: Shifts
): Analysis {
const rows: Row[] = [];
let minutes = 0;
@@ -156,8 +150,8 @@ export function analyze(
// 1. Turno sbagliato
if (stacked) {
const total = lists.reduce((n, c) => n + c.length, 0);
const shift = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2');
issue = t('review.issue_stacked', { values: { total, shift } });
const shiftLabel = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2');
issue = t('review.issue_stacked', { values: { total, shift: shiftLabel } });
[0, 1, 2, 3].forEach((i) => {
if (!filled[i]) miss[i] = true;
});
@@ -165,7 +159,7 @@ export function analyze(
// 2. Orario non valido
if (!issue && bad.some((c) => c.some(Boolean))) issue = t('review.issue_invalid_time');
// 3. Pausa pranzo non timbrata
const lunch = !stacked && !issue && lunchSpan(v) !== null;
const lunch = !stacked && !issue && lunchSpan(v, shift) !== null;
if (lunch) {
issue = t('review.issue_lunch_missing');
[0, 1, 2, 3].forEach((i) => {
@@ -210,10 +204,10 @@ export function analyze(
const changed = v.map((c, ci) => c.map((x, ii) => x !== (o[ci][ii] ?? '')));
const rowEdits = changed.reduce((n, c) => n + c.filter(Boolean).length, 0);
const wasStacked = o.some((c) => c.length > 1);
const wasLunch = !lunch && lunchSpan(o) !== null && v[1][0] === LUNCH.start && v[2][0] === LUNCH.end;
const wasLunch = !lunch && lunchSpan(o, shift) !== null && v[1][0] === shift.out1 && v[2][0] === shift.in2;
// 6. Info: orari non arrotondati
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x)));
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x, shift.round_step_min)));
const hasUnr = unr.some((c) => c.some(Boolean));
const info = !issue && hasUnr;
@@ -250,9 +244,15 @@ export function isRnd(
return val !== '' && rounded[`${emp}-${di}-${ci}-${ii}`] === val;
}
export function noteOf(r: Row, roundedCount: number, manualCount: number, t: Translate): { text: string; kind: string } {
export function noteOf(
r: Row,
roundedCount: number,
manualCount: number,
t: Translate,
roundStep: number
): { text: string; kind: string } {
if (r.issue) return { text: r.issue, kind: 'issue' };
if (r.info) return { text: t('review.info_unrounded', { values: { step: ROUND_STEP } }), kind: 'info' };
if (r.info) return { text: t('review.info_unrounded', { values: { step: roundStep } }), kind: 'info' };
if (r.empty && !r.weekend) return { text: t('review.note_empty'), kind: 'muted' };
if (r.wasLunch) return { text: t('review.note_lunch_added'), kind: 'edit' };
if (r.wasStacked) return { text: t('review.note_shifts_fixed'), kind: 'edit' };