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
@@ -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>