feat(oxibadge): add settings screen for shifts, rounding and language
Check, format and test / build (push) Successful in 10m22s
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:
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
|
||||
Reference in New Issue
Block a user