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>
317 lines
7.4 KiB
Svelte
317 lines
7.4 KiB
Svelte
<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>
|