feat(oxibadge): add month-confirmation flow and fetch records/employees
Check, format and test / build (push) Successful in 10m0s
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:
@@ -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>
|
||||
@@ -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,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,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,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."
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user