feat(oxibadge): add month-confirmation flow and fetch records/employees
Check, format and test / build (push) Successful in 10m0s

Adds the screen after onboarding: pick a month/year (ConfirmMonth), show
loading screens while the device is queried, then download that month's
records and resolve each distinct employee's name, printing both to
stdout (debug pretty-print) before landing on the placeholder "done"
screen.

- r701: RecordIterator's boxed stream is now Send, required for it to
  cross into a Tauri command's future.
- oxibadge: load_records/load_employees commands, sharing the existing
  3-retry connect_with_retries helper. load_records uses skip_while/
  take_while on the device's newest-first stream to grab only the
  target month without walking its whole history, then a single
  collect + in-place Vec::reverse() back to chronological order.
- Shared LoadingScreen/Intro components extracted so future screens
  reuse the same look without duplicating markup/CSS.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 12:47:36 +02:00
parent 99772b9e4a
commit 28186c7745
14 changed files with 621 additions and 41 deletions
+3 -1
View File
@@ -19,4 +19,6 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
toml = "0.9"
r701 = { path = "../../r701" }
r701 = { path = "../../r701", features = ["serde"] }
chrono = { version = "0.4.38", default-features = false, features = ["clock"] }
tokio-stream = { version = "0.1.19", default-features = false }
+94 -5
View File
@@ -1,7 +1,11 @@
mod preferences;
use chrono::{Local, TimeZone};
use preferences::Reader;
use r701::{Record, R701};
use std::collections::HashMap;
use tauri::AppHandle;
use tokio_stream::StreamExt;
#[tauri::command]
fn load_preferences(app: AppHandle) -> Option<Reader> {
@@ -13,14 +17,19 @@ fn save_preferences(app: AppHandle, host: String, port: u16) -> Result<(), Strin
preferences::save(&app, Reader { host, port })
}
/// Connects to the device up to 3 times, returning a stable error code (not
/// a user-facing message) on failure so the frontend can translate it.
#[tauri::command]
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.
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::R701::connect((host.as_str(), port)).await {
Ok(_) => return Ok(()),
let err = match R701::connect((host, port)).await {
Ok(r701) => return Ok(r701),
Err(err) => err,
};
last_error = match err.kind() {
@@ -38,13 +47,93 @@ async fn test_connection(host: String, port: u16) -> Result<(), String> {
Err(last_error)
}
/// Fetches every attendance record for `year`/`month` (1-12) from the
/// configured reader.
///
/// The device streams records newest first, so the target month's records
/// are skipped over (`skip_while`) until found, then taken (`take_while`)
/// until the stream moves past them into older months — without walking
/// the device's entire history. The result is reversed back to
/// chronological (oldest first) order before being returned.
#[tauri::command]
async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Record>, String> {
let reader = preferences::load(&app).ok_or_else(|| "no reader configured".to_string())?;
let r701 = connect_with_retries(&reader.host, reader.port).await?;
let invalid = || "invalid year/month".to_string();
let start = Local
.with_ymd_and_hms(year, month, 1, 0, 0, 0)
.single()
.ok_or_else(invalid)?;
let (next_year, next_month) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
let end = Local
.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
.single()
.ok_or_else(invalid)?;
let mut records: Vec<Record> = r701
.into_record_iter()
.await
.map_err(|err| err.to_string())?
.skip_while(|record| matches!(record, Ok(record) if record.datetime >= end))
.take_while(|record| !matches!(record, Ok(record) if record.datetime < start))
.collect::<Result<Vec<_>, _>>()
.await
.map_err(|err| err.to_string())?;
records.reverse();
dbg!(&records);
Ok(records)
}
/// Resolves the name of every distinct employee in `records`, printing both
/// the records and the resulting employee map to stdout before returning
/// the map.
#[tauri::command]
async fn load_employees(
app: AppHandle,
records: Vec<Record>,
) -> Result<HashMap<u32, String>, String> {
let reader = preferences::load(&app).ok_or_else(|| "no reader configured".to_string())?;
let r701 = connect_with_retries(&reader.host, reader.port).await?;
let mut employees = HashMap::new();
for record in &records {
if employees.contains_key(&record.employee_id) {
continue;
}
let name = r701
.get_name(record.employee_id)
.await
.map_err(|err| err.to_string())?
.map(|name| {
name.unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned())
});
if let Some(name) = name {
employees.insert(record.employee_id, name);
}
}
dbg!(&employees);
Ok(employees)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
load_preferences,
save_preferences,
test_connection
test_connection,
load_records,
load_employees
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+10
View File
@@ -49,3 +49,13 @@ svg {
stroke-linejoin: round;
flex: none;
}
h1 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 48px;
line-height: 1.05;
letter-spacing: -0.03em;
color: var(--ox-text-strong);
}
@@ -0,0 +1,316 @@
<script lang="ts">
import { tick } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import Column from './Column.svelte';
import Intro from './Intro.svelte';
import Button from './Button.svelte';
let { onstart }: { onstart: (selection: { year: number; month: number }) => void } = $props();
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth(); // 0-11
const lastMonth = new Date(currentYear, currentMonth - 1, 1);
let year = $state(lastMonth.getFullYear());
let month = $state(lastMonth.getMonth()); // 0-11
let open = $state<'month' | 'year' | null>(null);
let yearCount = $state(25);
let yearList: HTMLDivElement | undefined = $state();
const years = $derived(
Array.from({ length: Math.max(yearCount, currentYear - year + 8) }, (_, k) => currentYear - k)
);
const monthShort = $derived(Array.from({ length: 12 }, (_, i) => monthName($locale, i + 1, 'short')));
const monthLong = $derived(Array.from({ length: 12 }, (_, i) => monthName($locale, i + 1, 'long')));
function isFuture(i: number) {
return year === currentYear && i > currentMonth;
}
async function toggle(which: 'month' | 'year') {
open = open === which ? null : which;
if (open === 'year') {
await tick();
const selected = yearList?.querySelector('[aria-pressed="true"]');
if (yearList && selected instanceof HTMLElement) {
yearList.scrollTop = selected.offsetTop - yearList.clientHeight / 2 + selected.offsetHeight / 2;
selected.focus({ preventScroll: true });
}
}
}
function pickMonth(i: number) {
if (isFuture(i)) return;
month = i;
open = null;
}
function pickYear(y: number) {
year = y;
if (y === currentYear && month > currentMonth) month = currentMonth;
open = null;
}
function onYearScroll() {
if (!yearList) return;
if (yearList.scrollTop + yearList.clientHeight > yearList.scrollHeight - 80) {
yearCount = years.length + 20;
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) open = null;
}
function start() {
onstart({ year, month: month + 1 });
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if open}
<button
type="button"
class="backdrop"
aria-label={$_('confirm_month.close_aria')}
onclick={() => (open = null)}
></button>
{/if}
<Column>
<Intro title={$_('confirm_month.title')} subtitle={$_('confirm_month.subtitle')} />
<div class="period">
<div class="anchor">
<button
type="button"
class="pick"
aria-haspopup="true"
aria-expanded={open === 'month'}
aria-label={$_('confirm_month.change_month_aria', { values: { month: monthLong[month] } })}
onclick={() => toggle('month')}
>
<span>{monthLong[month]}</span>
<svg class="chev" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg
>
</button>
{#if open === 'month'}
<div class="pop months" role="group" aria-label={$_('confirm_month.choose_month_aria')}>
{#each monthShort as label, i}
<button
type="button"
class="opt"
class:sel={i === month}
disabled={isFuture(i)}
aria-pressed={i === month}
aria-label={$_('confirm_month.month_option_aria', { values: { month: monthLong[i], year } })}
onclick={() => pickMonth(i)}>{label}</button
>
{/each}
</div>
{/if}
</div>
<span class="slash" aria-hidden="true">/</span>
<div class="anchor">
<button
type="button"
class="pick"
aria-haspopup="true"
aria-expanded={open === 'year'}
aria-label={$_('confirm_month.change_year_aria', { values: { year } })}
onclick={() => toggle('year')}
>
<span>{year}</span>
<svg class="chev" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg
>
</button>
{#if open === 'year'}
<div
class="pop years"
role="group"
aria-label={$_('confirm_month.choose_year_aria')}
bind:this={yearList}
onscroll={onYearScroll}
>
{#each years as y (y)}
<button
type="button"
class="opt mono"
class:sel={y === year}
aria-pressed={y === year}
onclick={() => pickYear(y)}>{y}</button
>
{/each}
</div>
{/if}
</div>
</div>
<Button label={$_('confirm_month.button')} onclick={start} />
</Column>
<style>
.backdrop {
all: unset;
position: absolute;
inset: 0;
z-index: 5;
cursor: default;
}
.period {
position: relative;
z-index: 6;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.anchor {
position: relative;
}
.pick {
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 12px;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 40px;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--ox-text-strong);
cursor: pointer;
transition: background 0.15s;
}
.pick .chev {
stroke-width: 2.2;
color: #6e6e6e;
transition: color 0.15s;
}
.pick:hover {
background: #1c1c1c;
}
.pick:hover .chev {
color: #c9c9c9;
}
.pick:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.slash {
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 40px;
color: #4a4a4a;
}
.pop {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
box-sizing: border-box;
background: #1a1a1a;
border: 1px solid var(--ox-border);
border-radius: 14px;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55);
}
.months {
width: 340px;
padding: 10px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.years {
width: 148px;
max-height: 292px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: #3a3a3a transparent;
}
.years::-webkit-scrollbar {
width: 6px;
}
.years::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 3px;
}
.opt {
all: unset;
box-sizing: border-box;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
font-family: var(--ox-font-body);
font-size: 14px;
font-weight: 500;
color: #d4d4d4;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.opt:not(:disabled):hover {
background: #262626;
color: #ffffff;
}
.opt:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.opt:disabled {
color: #454545;
cursor: not-allowed;
}
.opt.mono {
flex: none;
font-family: var(--ox-font-mono);
font-size: 16px;
}
.opt.sel,
.opt.sel:hover {
background: var(--ox-accent);
color: var(--ox-on-accent);
font-weight: 600;
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
let { title, subtitle }: { title: string; subtitle: string } = $props();
</script>
<header class="intro">
<h1>{title}</h1>
<p>{subtitle}</p>
</header>
<style>
.intro {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.intro p {
margin: 0;
max-width: 340px;
font-size: 15px;
line-height: 1.5;
color: var(--ox-text-muted);
}
</style>
@@ -0,0 +1,84 @@
<script lang="ts">
let { title, description }: { title: string; description: string } = $props();
</script>
<div class="content" role="status" aria-live="polite">
<svg class="spinner" width="56" height="56" viewBox="0 0 56 56" aria-hidden="true">
<circle class="track" cx="28" cy="28" r="24" />
<circle class="arc" cx="28" cy="28" r="24" />
</svg>
<div class="text">
<h1>{title}</h1>
<p>{@html description}</p>
</div>
</div>
<style>
.content {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
text-align: center;
}
.spinner {
animation: spin 0.9s linear infinite;
}
.spinner circle {
fill: none;
stroke-width: 5;
}
.track {
stroke: #2a2a2a;
}
.arc {
stroke: var(--ox-accent);
stroke-linecap: round;
stroke-dasharray: 40 151;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 2.4s;
}
}
.text {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
h1 {
font-size: 32px;
line-height: 1.15;
letter-spacing: -0.025em;
}
p {
margin: 0;
max-width: 340px;
font-size: 15px;
line-height: 1.5;
color: var(--ox-text-muted);
}
p :global(strong) {
font-weight: 500;
color: var(--ox-text);
}
</style>
+2 -30
View File
@@ -2,6 +2,7 @@
import { invoke } from '@tauri-apps/api/core';
import { _ } from 'svelte-i18n';
import Column from './Column.svelte';
import Intro from './Intro.svelte';
import Field from './Field.svelte';
import Button from './Button.svelte';
import ErrorBanner from './ErrorBanner.svelte';
@@ -48,10 +49,7 @@
</script>
<Column>
<header class="intro">
<h1>{$_('onboarding.title')}</h1>
<p>{$_('onboarding.subtitle')}</p>
</header>
<Intro title={$_('onboarding.title')} subtitle={$_('onboarding.subtitle')} />
<form
class="form"
@@ -96,32 +94,6 @@
</Column>
<style>
.intro {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
h1 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 48px;
line-height: 1.05;
letter-spacing: -0.03em;
color: var(--ox-text-strong);
}
.intro p {
margin: 0;
max-width: 340px;
font-size: 15px;
line-height: 1.5;
color: var(--ox-text-muted);
}
.form {
display: flex;
flex-direction: column;
@@ -10,6 +10,7 @@
<style>
.screen {
position: relative;
min-height: 100vh;
box-sizing: border-box;
display: flex;
+19
View File
@@ -19,6 +19,25 @@
"unknown": "Connection error"
}
},
"confirm_month": {
"title": "Confirm the month",
"subtitle": "You're about to confirm this month's clock-ins. Tap the month or year to change them.",
"change_month_aria": "Month: {month}, change",
"change_year_aria": "Year: {year}, change",
"close_aria": "Close selection",
"choose_month_aria": "Choose the month",
"choose_year_aria": "Choose the year",
"month_option_aria": "{month} {year}",
"button": "Go to clock-ins"
},
"loading_records": {
"title": "Downloading clock-ins...",
"description": "Reading <strong>{period}</strong>'s clock-ins from the time clock. This can take a few seconds."
},
"loading_employees": {
"title": "Fetching employees...",
"description": "Reading the employee names registered on the time clock. Just a moment."
},
"done": {
"message": "Nothing to do here."
}
+19
View File
@@ -19,6 +19,25 @@
"unknown": "Errore di connessione"
}
},
"confirm_month": {
"title": "Conferma del mese",
"subtitle": "Stai per confermare le timbrature di questo mese. Tocca il mese o l'anno per cambiarli.",
"change_month_aria": "Mese: {month}, cambia",
"change_year_aria": "Anno: {year}, cambia",
"close_aria": "Chiudi selezione",
"choose_month_aria": "Scegli il mese",
"choose_year_aria": "Scegli l'anno",
"month_option_aria": "{month} {year}",
"button": "Vai alle timbrature"
},
"loading_records": {
"title": "Scarico le timbrature...",
"description": "Sto leggendo dal timbratore le timbrature di <strong>{period}</strong>. Può richiedere qualche secondo."
},
"loading_employees": {
"title": "Recupero i dipendenti...",
"description": "Sto leggendo i nomi dei dipendenti registrati sul timbratore. Ci vuole solo un attimo."
},
"done": {
"message": "Non c'è niente da fare."
}
+9
View File
@@ -0,0 +1,9 @@
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
/** `month` is 1-12. */
export function monthName(loc: string | null | undefined, month: number, format: 'short' | 'long' = 'long') {
const formatter = new Intl.DateTimeFormat(loc ?? 'it', { month: format });
return capitalize(formatter.format(new Date(2000, month - 1, 1)));
}
+35 -4
View File
@@ -1,25 +1,56 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import Screen from '$lib/components/Screen.svelte';
import Onboarding from '$lib/components/Onboarding.svelte';
import ConfirmMonth from '$lib/components/ConfirmMonth.svelte';
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
type Step = 'loading' | 'setup' | 'done';
type Step = 'loading' | 'setup' | 'confirm-month' | 'loading-records' | 'loading-employees' | 'done';
let step = $state<Step>('loading');
let selectedYear = $state(0);
let selectedMonth = $state(0);
const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
onMount(async () => {
const preferences = await invoke<{ host: string; port: number } | null>('load_preferences');
step = preferences ? 'done' : 'setup';
step = preferences ? 'confirm-month' : 'setup';
});
async function loadMonth(selection: { year: number; month: number }) {
selectedYear = selection.year;
selectedMonth = selection.month;
step = 'loading-records';
try {
const records = await invoke('load_records', selection);
step = 'loading-employees';
await invoke('load_employees', { records });
step = 'done';
} catch (err) {
console.error(err);
step = 'confirm-month';
}
}
</script>
<Screen>
{#if step === 'loading'}
<p>{$_('loading')}</p>
{:else if step === 'setup'}
<Onboarding onconnected={() => (step = 'done')} />
<Onboarding onconnected={() => (step = 'confirm-month')} />
{:else if step === 'confirm-month'}
<ConfirmMonth onstart={loadMonth} />
{:else if step === 'loading-records'}
<LoadingScreen
title={$_('loading_records.title')}
description={$_('loading_records.description', { values: { period } })}
/>
{:else if step === 'loading-employees'}
<LoadingScreen title={$_('loading_employees.title')} description={$_('loading_employees.description')} />
{:else}
<p>{$_('done.message')}</p>
{/if}