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:
Generated
+2
@@ -2113,10 +2113,12 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
|||||||
name = "oxibadge"
|
name = "oxibadge"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"r701",
|
"r701",
|
||||||
"serde",
|
"serde",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tokio-stream",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,6 @@ tauri-build = { version = "2", features = [] }
|
|||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.9"
|
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 }
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
mod preferences;
|
mod preferences;
|
||||||
|
|
||||||
|
use chrono::{Local, TimeZone};
|
||||||
use preferences::Reader;
|
use preferences::Reader;
|
||||||
|
use r701::{Record, R701};
|
||||||
|
use std::collections::HashMap;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn load_preferences(app: AppHandle) -> Option<Reader> {
|
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 })
|
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]
|
#[tauri::command]
|
||||||
async fn test_connection(host: String, port: u16) -> Result<(), String> {
|
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();
|
let mut last_error = String::new();
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let err = match r701::R701::connect((host.as_str(), port)).await {
|
let err = match R701::connect((host, port)).await {
|
||||||
Ok(_) => return Ok(()),
|
Ok(r701) => return Ok(r701),
|
||||||
Err(err) => err,
|
Err(err) => err,
|
||||||
};
|
};
|
||||||
last_error = match err.kind() {
|
last_error = match err.kind() {
|
||||||
@@ -38,13 +47,93 @@ async fn test_connection(host: String, port: u16) -> Result<(), String> {
|
|||||||
Err(last_error)
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
load_preferences,
|
load_preferences,
|
||||||
save_preferences,
|
save_preferences,
|
||||||
test_connection
|
test_connection,
|
||||||
|
load_records,
|
||||||
|
load_employees
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -49,3 +49,13 @@ svg {
|
|||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
flex: none;
|
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 { invoke } from '@tauri-apps/api/core';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import Column from './Column.svelte';
|
import Column from './Column.svelte';
|
||||||
|
import Intro from './Intro.svelte';
|
||||||
import Field from './Field.svelte';
|
import Field from './Field.svelte';
|
||||||
import Button from './Button.svelte';
|
import Button from './Button.svelte';
|
||||||
import ErrorBanner from './ErrorBanner.svelte';
|
import ErrorBanner from './ErrorBanner.svelte';
|
||||||
@@ -48,10 +49,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Column>
|
<Column>
|
||||||
<header class="intro">
|
<Intro title={$_('onboarding.title')} subtitle={$_('onboarding.subtitle')} />
|
||||||
<h1>{$_('onboarding.title')}</h1>
|
|
||||||
<p>{$_('onboarding.subtitle')}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
class="form"
|
class="form"
|
||||||
@@ -96,32 +94,6 @@
|
|||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.screen {
|
.screen {
|
||||||
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -19,6 +19,25 @@
|
|||||||
"unknown": "Connection error"
|
"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": {
|
"done": {
|
||||||
"message": "Nothing to do here."
|
"message": "Nothing to do here."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,25 @@
|
|||||||
"unknown": "Errore di connessione"
|
"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": {
|
"done": {
|
||||||
"message": "Non c'è niente da fare."
|
"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">
|
<script lang="ts">
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { onMount } from 'svelte';
|
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 Screen from '$lib/components/Screen.svelte';
|
||||||
import Onboarding from '$lib/components/Onboarding.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 step = $state<Step>('loading');
|
||||||
|
let selectedYear = $state(0);
|
||||||
|
let selectedMonth = $state(0);
|
||||||
|
|
||||||
|
const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const preferences = await invoke<{ host: string; port: number } | null>('load_preferences');
|
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>
|
</script>
|
||||||
|
|
||||||
<Screen>
|
<Screen>
|
||||||
{#if step === 'loading'}
|
{#if step === 'loading'}
|
||||||
<p>{$_('loading')}</p>
|
<p>{$_('loading')}</p>
|
||||||
{:else if step === 'setup'}
|
{: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}
|
{:else}
|
||||||
<p>{$_('done.message')}</p>
|
<p>{$_('done.message')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use tokio_stream::Stream;
|
|||||||
/// consume it with [`tokio_stream::StreamExt::next`] (or any other
|
/// consume it with [`tokio_stream::StreamExt::next`] (or any other
|
||||||
/// `Stream` combinator).
|
/// `Stream` combinator).
|
||||||
pub struct RecordIterator<'a> {
|
pub struct RecordIterator<'a> {
|
||||||
inner: Pin<Box<dyn Stream<Item = Result<Record>> + 'a>>,
|
inner: Pin<Box<dyn Stream<Item = Result<Record>> + Send + 'a>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> RecordIterator<'a> {
|
impl<'a> RecordIterator<'a> {
|
||||||
|
|||||||
Reference in New Issue
Block a user