22 Commits
Author SHA1 Message Date
nicolabelluti d661680539 feat(oxibadge): implement the review screen's export to ODS
Check, format and test / build (push) Successful in 11m23s
Replaces the export button's placeholder (which only dbg!-printed the
payload) with a real implementation: one employee exports a single
.ods file, more than one bundles a .zip of one .ods per employee,
written wherever the user picks via the native "save" dialog
(tauri-plugin-dialog).

The "hours worked" column is a live ODF formula with a cached fallback
value, not just a static number, so the file stays correct if edited
afterward in LibreOffice; the formula and the cached value both treat
a day's own missing punches as 0 in the subtraction, matching how a
day with only an entry and an exit (no lunch break) still nets out to
the right span. Column headers are sent by the frontend (translated
via its own i18n), not hardcoded in Rust. A stacked slot (more than
one punch the device recorded in the same shift) exports every punch,
one per line in the cell, instead of only the first.

Also closes a real gap surfaced while building this: the new-employees
screen already blocked "Continue" for a blank name, but not for a name
the device sent as invalid UTF-8 (lossily converted to text containing
the Unicode replacement character on the Rust side) — such a name is
now flagged and blocks continuing too, so every employee reaching the
review/export flow is guaranteed to have a real, displayable name.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 08:48:05 +02:00
nicolabelluti 5dbb3f51f1 feat(oxibadge): consolidate review toolbar and add a "shift times" action
Check, format and test / build (push) Successful in 11m56s
The per-employee correction buttons (Fix shifts, Add lunch break,
Round) and the day filter used to be scattered across the employee
header. They're now grouped into a single toolbar attached to the top
of the timesheet table: a "Corrections" dropdown (with per-item counts
and an "Apply all corrections" entry) and a "Shift times" panel, plus
the existing day filter and "Undo all changes" button.

"Shift times" adds a fixed offset (in steps of the configured rounding
granularity, ±12h) to every punch in a chosen day range, for either
the current employee or everyone — for when the time clock didn't
follow a daylight-saving change. EmployeeHeader is now just the name
editor and stats; TimesheetTable no longer owns the table's outer
border/background, which moved to the new shared card wrapper.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 15:21:47 +02:00
nicolabelluti da5daf5bca fix(oxibadge): accept hostnames and FQDNs for the time clock address
Check, format and test / build (push) Successful in 11m10s
The "Edit address" field in Settings only accepted an IPv4 address
(IPV4.test), rejecting a bare hostname (e.g. "timbratore") or FQDN
(e.g. "timbratore.lan") even though the underlying connection (Rust's
ToSocketAddrs) already resolves those fine. Onboarding had no such
restriction. Replaced the IPv4-only check with isValidHost (IPv4 OR a
standard DNS hostname), dropped the numeric-only keyboard hint on that
field, and reworded the labels/placeholder error to not imply IP-only.

Also makes "Edit address" test the connection before accepting it,
instead of saving unconditionally: ActionButton gained a `disabled`
prop so the confirm/cancel buttons can be locked (with a spinner on
confirm) while the test is in flight, and the edit form now only
closes on a successful test, staying open with the raw error shown
otherwise.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 14:05:12 +02:00
nicolabelluti 0c320c39b0 feat(oxibadge): warn before losing unexported review edits, add undo
Check, format and test / build (push) Successful in 10m25s
The review screen now tracks whether there are edits not yet exported
and asks for confirmation before discarding them: clicking back, or
closing the app window entirely (intercepted via onCloseRequested,
which needed a core:window:allow-destroy capability that wasn't
granted by core:default). The confirmation dialog (ConfirmDialog.svelte)
is generic, built on a shared ModalFrame extracted from ExportModal so
the two don't duplicate the backdrop/box styling.

Also adds undo: a per-row button (icon-only, left of the note, shown
when that day has edits) reverts just that day instantly, and a
per-employee "undo all" (icon in a red box, last among the header's
tools) reverts every day for the current employee after a confirmation
dialog, since that one can't be undone itself.

ActionButton moves out of review/ next to IconButton/PillButton, since
ConfirmDialog (outside the review feature) now uses it too.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 13:46:31 +02:00
nicolabelluti 25127bee96 feat(oxibadge): cache employee names locally and let readers be a list
Check, format and test / build (push) Successful in 10m27s
Employee names are now cached locally (employees.toml, keyed by reader
index then employee id) instead of being re-fetched from the time
clock on every month: load_employees only asks the device for ids not
already in the cache. When a month has employees the cache doesn't
know yet, a new screen (NewEmployees.svelte) lets the user name them
before continuing to the review screen; the chosen names are local to
Oxibadge only, never written back to the device. The same cache can be
managed afterwards from a drawer reachable from the time clock card in
Settings ("Gestisci dipendenti"), reusing the rename-in-row pattern
from the original mockup.

Settings.readers is a list again (was flattened to a single `reader`
object) so multiple time clocks can be supported later; only the first
one is used today.

Also:
- buildEmployees() now derives the employee list from the month's
  records instead of from every cached name ever seen, so the review
  screen no longer shows employees unrelated to the selected month.
- Renaming an employee from the review screen's pencil button now
  actually persists to the cache (the callback existed but was never
  wired up).
- Translated to English a number of Rust/TS/Svelte comments that had
  been written in Italian; only the i18n locale files should contain
  Italian.
- Minor dedup: extracted PillButton.svelte (was duplicated inline in
  ReaderCard), promoted the repeated .mono CSS rule to app.css, moved
  initials() out of the review feature into $lib/text.ts.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 20:04:37 +02:00
nicolabelluti b65e85f908 feat(oxibadge): add settings screen for shifts, rounding and language
Check, format and test / build (push) Successful in 10m22s
Add a settings screen (gear button on the month-confirmation screen)
to configure the expected shift times, the rounding step, the
interface language, and the time clock address — replacing the old
onboarding-only connection flow. Settings persist to the same
preferences.toml as the reader address, under a single load_settings/
save_settings command pair.

The review screen's "fix shifts" action now redistributes stacked
punches by nearest configured shift time instead of blind position,
and a stacked cell manually cleared down to one entry no longer
leaves a dangling empty input.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 18:29:57 +02:00
nicolabelluti acd3efeac6 feat(oxibadge): add timesheet review and export screen
Check, format and test / build (push) Successful in 10m16s
Add the month-review screen shown after records/employees load: an
editable per-employee timesheet table with issue/rounding detection,
bulk fixes (reorder stacked punches, add lunch break, round to step),
and an export flow (single employee or all) wired to a new Rust
export_timesheet command. Split into focused components under
review/ instead of one large file, with shared i18n keys and a new
export_timesheet IPC command.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-07 15:56:25 +02:00
nicolabelluti b4cd8e1796 fix(oxibadge): show connection errors instead of failing silently
Check, format and test / build (push) Successful in 10m4s
load_records/load_employees failures were only logged to the console
and silently reset the UI to the month picker. Errors now flow through
a shared store (src/lib/error.ts) into an ErrorBanner, reused by both
Onboarding and ConfirmMonth, with the raw message passed straight
through from Rust (no stable-code classification layer — these are
mostly plain I/O errors, not worth categorizing).

i18n stays grouped per screen as before, with error-related strings
split into their own top-level "errors" group.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-07 13:26:01 +02:00
nicolabelluti 28186c7745 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>
2026-10-07 12:47:36 +02:00
nicolabelluti 99772b9e4a feat(oxibadge): add onboarding flow with i18n and reader preferences
Check, format and test / build (push) Successful in 9m59s
Adds the first real screen: a setup form for the device's host/port,
tested against the real device (3 retries) and persisted to
preferences.toml before showing a placeholder "done" screen.

- Rust: test_connection/save_preferences/load_preferences commands;
  preferences stored as [[reader]] in TOML so more readers can be
  added later without a format change.
- Svelte: split the monolithic page into reusable components
  (Screen, Column, Field, Button, ErrorBanner, Hint) under
  src/lib/components, sharing design tokens from app.css.
- Automatic it/en i18n via svelte-i18n, locale detected from
  navigator.language.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-07 11:08:02 +02:00
nicolabelluti 268a917a69 [skip ci] Updated the root README.md 2026-10-06 20:31:24 +02:00
nicolabellutiandClaude Sonnet 5 a96561d5f0 Added Oxibadge, a minimal Tauri + Svelte desktop client
Check, format and test / build (push) Successful in 14m6s
Scaffolds the oxibadge crate/frontend as a new workspace member, stripped
down to a bare hello-world, and
extends the CI pipeline

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 20:26:24 +02:00
nicolabellutiandClaude Sonnet 5 cc8181ebdc Turned the repo into a Cargo workspace, with r701 as a member
Check, format and test / build (push) Successful in 44s
Moving the library into its own r701/ directory makes room for the
Tauri GUI client (Oxibadge) to be added as a sibling workspace member
in a following commit. edition/license/repository are now inherited
from the new workspace root instead of being duplicated per member.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 12:49:54 +02:00
nicolabellutiandClaude Sonnet 5 c2471ed548 Bumped version to 0.2.0
Check, format and test / build (push) Successful in 48s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 15:27:43 +02:00
nicolabellutiandClaude Sonnet 5 9a637190b5 Expanded the e2e test suite and fake device data
Check, format and test / build (push) Successful in 46s
- fake_records now generates a full year (2026) of classic workday
  punches (09:00-13:00, 14:00-18:00) for the employees in fake_users;
  this spans many 1024-byte blocks, exercising the multi-block
  streaming path
- fake_users/fake_records are now public so tests can assert against
  them instead of hardcoded expectations
- scoped #[allow(clippy::unwrap_used)] to only the functions that
  actually use unwrap, instead of a blanket file-level allow
- added gets_total_record_count, streams_records, and
  gets_none_for_unknown_employee tests

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 15:25:18 +02:00
nicolabellutiandClaude Sonnet 5 9977db0e46 Added an e2e test harness with a fake R701 device
Check, format and test / build (push) Successful in 47s
tests/mock/mod.rs emulates the device's TCP protocol (ping, get_name,
get_total_record_count, get_record_bytes), grounded in the reverse
engineering notes at
https://nicolabelluti.me/posts/2024/05/studying-a-communication-protocol/,
so tests/e2e.rs can exercise the real R701 client against it instead of
needing a physical reader. Starts with one test (connect + ping);
more scenarios (get_name, record streaming, error cases) to follow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 14:47:29 +02:00
nicolabellutiandClaude Sonnet 5 f697660ff4 Documented the public API and removed the missing_errors_doc allows
Check, format and test / build (push) Successful in 42s
Added rustdoc comments to every public item (R701, Record, Clock,
RecordIterator and their methods/fields/variants), each with an
# Errors section where applicable, so the clippy::missing_errors_doc
allows are no longer needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 15:07:40 +02:00
nicolabelluti d8c6440a59 Re-added npm to the CI/CD pipeline
Check, format and test / build (push) Successful in 33s
2026-10-01 14:49:02 +02:00
nicolabellutiandClaude Sonnet 5 e0e453c9c5 Cleaned up the protocol layer and metadata
Check, format and test / build (push) Failing after 5s
- request() now reads exactly the expected response length with
  read_exact instead of a single non-guaranteed fill_buf, fixing a
  latent bug where a response split across multiple TCP packets could
  be mistaken for malformed data; the per-method length checks are now
  redundant and were removed
- sequence_number wraps explicitly via wrapping_add instead of
  relying on implicit (and debug-vs-release inconsistent) overflow
  behavior after 65536 requests
- get_name validates UTF-8 strictly instead of silently lossy-decoding
  invalid bytes; on invalid data it now returns the FromUtf8Error
  itself (byte-level \0 trimming moved after the UTF-8 check), letting
  the caller choose to fail or fall back to a lossy rendering
- removed the unused Default derive from Clock and Record
- removed npm from the CI image (no JS/Node in this repo), fixed a
  typo in a CI step name
- fixed the Cargo.toml license field to the actual SPDX identifier
  (AGPL-3.0-or-later, per the notice at the end of LICENSE) instead of
  the free-text "GNU AGPLv3.0"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 14:43:50 +02:00
nicolabellutiandClaude Sonnet 5 9af4a82e39 Made R701 shareable via an internal Mutex, allowing get_name to be called while streaming records
Check, format and test / build (push) Successful in 34s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 14:02:47 +02:00
nicolabellutiandClaude Sonnet 5 1745ecc05e Made the library async using tokio and the iterator return a Result
Check, format and test / build (push) Successful in 33s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:28:32 +02:00
nicolabellutiandClaude Sonnet 5 93f9e9d3af Updated the Rust version used by the CI/CD and silenced clippy's missing_errors_doc lint
Check, format and test / build (push) Successful in 1m13s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:44:07 +02:00
90 changed files with 13885 additions and 385 deletions
+5 -3
View File
@@ -5,13 +5,15 @@ on:
jobs: jobs:
build: build:
container: docker.io/rust:1.79.0-alpine3.20 container: docker.io/rust:1.98.1-alpine3.24
env:
RUSTFLAGS: -C target-feature=-crt-static
steps: steps:
- name: Install the dependencies - name: Install the dependencies
run: | run: |
rustup component add clippy rustfmt && rustup component add clippy rustfmt &&
apk update && apk update &&
apk add musl-dev git npm apk add musl-dev git npm pkgconf gtk+3.0-dev webkit2gtk-4.1-dev librsvg-dev patchelf
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v3 uses: actions/checkout@v3
@@ -27,5 +29,5 @@ jobs:
- name: Check if the code is formatted correctly - name: Check if the code is formatted correctly
run: cargo fmt --check run: cargo fmt --check
- name: Check if Clippy has someting to say - name: Check if Clippy has something to say
run: cargo clippy --all-targets run: cargo clippy --all-targets
Generated
+4935 -62
View File
File diff suppressed because it is too large Load Diff
+13 -19
View File
@@ -1,22 +1,16 @@
[package] [workspace]
name = "r701" resolver = "3"
version = "0.1.0" members = ["r701", "oxibadge/src-tauri"]
[workspace.package]
edition = "2021" edition = "2021"
license = "GNU AGPLv3.0" license = "AGPL-3.0-or-later"
repository = "https://git.nicolabelluti.me/nicolabelluti/r701" repository = "https://git.nicolabelluti.me/nicolabelluti/r701"
[dependencies] # Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
chrono = { version = "0.4.38", default-features = false, features = ["clock"] } [profile.release]
serde = { version = "1.0.203", default-features = false, features = ["derive"], optional = true } codegen-units = 1
lto = true
[features] opt-level = 3
serde = ["chrono/serde", "dep:serde"] panic = "abort"
strip = true
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
unwrap_used = "deny"
enum_glob_use = { level = "deny", priority = 1 }
pedantic = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
+12 -48
View File
@@ -1,9 +1,9 @@
<div align="center"> <div align="center">
# R701 🕰️ # R701 | Oxibadge
[![Rust](https://img.shields.io/badge/Rust-f74c00?logo=rust)](https://www.rust-lang.org) [![Rust](https://img.shields.io/badge/Rust-f74c00?logo=rust)](https://www.rust-lang.org)
[![Brain made](https://img.shields.io/badge/Brainmade-grey?logo=data:image/svg%2bxml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgd2lkdGg9IjY2LjUzOCIKICAgaGVpZ2h0PSI3OC43ODIiCiAgIGZpbGw9Im5vbmUiCiAgIHZlcnNpb249IjEuMSIKICAgaWQ9InN2ZzMiCiAgIHNvZGlwb2RpOmRvY25hbWU9IndoaXRlLWxvZ28taGVhZDMuc3ZnIgogICBpbmtzY2FwZTp2ZXJzaW9uPSIxLjMuMiAoMDkxZTIwZWYwZiwgMjAyMy0xMS0yNSwgY3VzdG9tKSIKICAgeG1sbnM6aW5rc2NhcGU9Imh0dHA6Ly93d3cuaW5rc2NhcGUub3JnL25hbWVzcGFjZXMvaW5rc2NhcGUiCiAgIHhtbG5zOnNvZGlwb2RpPSJodHRwOi8vc29kaXBvZGkuc291cmNlZm9yZ2UubmV0L0RURC9zb2RpcG9kaS0wLmR0ZCIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9Im5hbWVkdmlldzMiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIgogICAgIGJvcmRlcmNvbG9yPSIjMDAwMDAwIgogICAgIGJvcmRlcm9wYWNpdHk9IjAuMjUiCiAgICAgaW5rc2NhcGU6c2hvd3BhZ2VzaGFkb3c9IjIiCiAgICAgaW5rc2NhcGU6cGFnZW9wYWNpdHk9IjAuMCIKICAgICBpbmtzY2FwZTpwYWdlY2hlY2tlcmJvYXJkPSIwIgogICAgIGlua3NjYXBlOmRlc2tjb2xvcj0iI2QxZDFkMSIKICAgICBpbmtzY2FwZTp6b29tPSIxMC43NjM4OCIKICAgICBpbmtzY2FwZTpjeD0iMzMuMjU5MzgxIgogICAgIGlua3NjYXBlOmN5PSIzOS4zOTA5OTkiCiAgICAgaW5rc2NhcGU6d2luZG93LXdpZHRoPSIxOTIwIgogICAgIGlua3NjYXBlOndpbmRvdy1oZWlnaHQ9IjEwNTIiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9IjAiCiAgICAgaW5rc2NhcGU6d2luZG93LXk9IjAiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJzdmczIiAvPgogIDxnCiAgICAgY2xpcC1wYXRoPSJ1cmwoI2EpIgogICAgIHN0eWxlPSJmaWxsOiNmZmZmZmYiCiAgICAgZmlsbD0iI2ZmZiIKICAgICBpZD0iZzIiPgogICAgPHBhdGgKICAgICAgIGQ9Ik01Mi42MTIgNzguNzgySDIzLjMzYTIuNTU5IDIuNTU5IDAgMCAxLTIuNTYtMi41NTl2LTcuNjc2aC03Ljk3M2EyLjU2IDIuNTYgMCAwIDEtMi41Ni0yLjU2VjU1LjMxNWwtOC44Mi00LjM5N2EyLjU1OSAyLjU1OSAwIDAgMS0uOTg2LTMuNzFsOS44MDctMTQuNzE0di00LjM1QzEwLjI0IDEyLjU5OSAyMi44NDMgMCAzOC4zODggMCA1My45MzIgMCA2Ni41MzQgMTIuNiA2Ni41MzggMjguMTQzYy0uNjMyIDI3LjgyNC0xMC43NiAyMy41MTYtMTEuMTggMzQuMDQ1bC0uMTg3IDE0LjAzNWEyLjU5IDIuNTkgMCAwIDEtLjc1IDEuODEgMi41NSAyLjU1IDAgMCAxLTEuODA5Ljc1em0tMjYuNzIzLTUuMTE3aDI0LjE2NGwuMjg2LTE0LjU0MmMtLjI2My02LjY1NiAxMS43MTYtOC4yNDMgMTEuMDgtMzAuNzM0LS4zNTgtMTIuNzEzLTEwLjMxMy0yMy4yNzEtMjMuMDMxLTIzLjI3MS0xMi43MTggMC0yMy4wMjkgMTAuMzA3LTIzLjAzMiAyMy4wMjV2NS4xMThjMCAuNTA1LS4xNS45OTktLjQzIDEuNDJsLTguNjMgMTIuOTQgNy42NDUgMy44MmEyLjU1OSAyLjU1OSAwIDAgMSAxLjQxNSAyLjI5MXY5LjY5N2g3Ljk3NGEyLjU1OSAyLjU1OSAwIDAgMSAyLjU2IDIuNTU5eiIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmYiCiAgICAgICBpZD0icGF0aDEiIC8+CiAgICA8cGF0aAogICAgICAgZD0iTTQwLjM3MiA1OC4yMjJWMzguOTM0Yy4xMTggMCAuMjM3LjAxOC4zNTUuMDE4IDkuNzY5LS4wMTIgMTcuMDUtOS4wMTIgMTUuMDIyLTE4LjU2N2EyLjM2NiAyLjM2NiAwIDAgMC0xLjgyMS0xLjgyMmMtOC4xMDYtMS43My0xNi4xMjEgMy4yOTItMTguMDk4IDExLjM0MS0uMDI0LS4wMjQtLjA0My0uMDUtLjA2Ni0uMDczYTE1LjMyMyAxNS4zMjMgMCAwIDAtMTQuMDYtNC4xNyAyLjM2NSAyLjM2NSAwIDAgMC0xLjgyMSAxLjgyYy0yLjAyOCA5LjU1NSA1LjI1MiAxOC41NTQgMTUuMDIgMTguNTY4LjIzNiAwIC40OTItLjAyOC43MzgtLjA0djEyLjIxM1ptMi44MzktMzIuMTQzYTEwLjY0NiAxMC42NDYgMCAwIDEgOC4xMjQtMy4xMDZjLjM1IDYuMzQtNC44ODggMTEuNTc3LTExLjIyOCAxMS4yM2ExMC41OCAxMC41OCAwIDAgMSAzLjEwNC04LjEyNHpNMjcuNDAzIDM4LjE5M2ExMC42MDcgMTAuNjA3IDAgMCAxLTMuMTE4LTguMTIzYzYuMzQ0LS4zNTggMTEuNTg3IDQuODg2IDExLjIyOCAxMS4yMy0zLjAyMy4xNjktNS45NzMtLjk2MS04LjExLTMuMTA3eiIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmYiCiAgICAgICBpZD0icGF0aDIiIC8+CiAgPC9nPgogIDxkZWZzCiAgICAgaWQ9ImRlZnMzIj4KICAgIDxjbGlwUGF0aAogICAgICAgaWQ9ImEiPgogICAgICA8cGF0aAogICAgICAgICBmaWxsPSIjZmZmIgogICAgICAgICBkPSJNMCAwaDI1NnY4MEgweiIKICAgICAgICAgaWQ9InBhdGgzIiAvPgogICAgPC9jbGlwUGF0aD4KICA8L2RlZnM+Cjwvc3ZnPgo=)](https://brainmade.org) [![Claude Code](https://img.shields.io/badge/Claude%20Code-grey?logo=claude&logoColor=d97757)](https://claude.com/product/claude-code)
[![GNU AGPLv3.0 License](https://img.shields.io/badge/License-GNU%20AGPLv3.0-dark_green?logo=gnu)](https://choosealicense.com/licenses/agpl-3.0) [![GNU AGPLv3.0 License](https://img.shields.io/badge/License-GNU%20AGPLv3.0-dark_green?logo=gnu)](https://choosealicense.com/licenses/agpl-3.0)
[![Buymeacoffee](https://img.shields.io/badge/Buymeacoffee-gray?logo=buymeacoffee)](https://buymeacoffee.com/nicolabelluti) [![Buymeacoffee](https://img.shields.io/badge/Buymeacoffee-gray?logo=buymeacoffee)](https://buymeacoffee.com/nicolabelluti)
<br> <br>
@@ -11,52 +11,16 @@
</div><br> </div><br>
> A reverse-engineered library to communicate with the > A reverse-engineered client for the
> [R701](https://ipsattendant.it/rilevatore-presenze-r701/) by [I.P.S. > [R701](https://ipsattendant.it/rilevatore-presenze-r701/) attendance reader by
> Informatica](https://ipsinformatica.info/), written in Rust > [I.P.S. Informatica](https://ipsinformatica.info/)
If you want to read about this reverse engineering attempt, check out This repository is a Cargo workspace with two members:
<https://nicolabelluti.me/series/attendance-reader/>.
## Library Usage - **[`r701/`](r701/README.md)** — the async Rust library that speaks the
device's TCP protocol.
- **[`oxibadge/`](oxibadge/README.md)** — Oxibadge, a minimal Tauri + Svelte
desktop client built on top of the library.
1. Add the library to you project: If you want to read about the reverse engineering attempt behind the library,
check out <https://nicolabelluti.me/series/attendance-reader/>.
```shell
cargo add r701 --git https://git.nicolabelluti.me/nicolabelluti/r701.git
```
2. Use the library in you project:
```rust
use r701::R701;
fn main() {
let mut r701 = R701::connect("127.0.0.1:5005").unwrap();
println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t");
r701.into_record_iter()
.unwrap()
.collect::<Vec<_>>()
.iter()
.rev()
.enumerate()
.for_each(|(id, record)| {
let name = r701
.get_name(record.employee_id)
.unwrap()
.unwrap_or(format!("user #{}", record.employee_id));
println!(
"{:0>6}\t{}\t{:0>9}\t{: <10}\t{}\t{}\t{}",
id + 1,
1,
record.employee_id,
name,
35,
record.clock as u8,
record.datetime.format("%Y/%m/%d %H:%M:%S"),
);
});
}
```
+10
View File
@@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+19
View File
@@ -0,0 +1,19 @@
<div align="center">
# Oxibadge 🪪
[![Rust](https://img.shields.io/badge/Rust-f74c00?logo=rust)](https://www.rust-lang.org)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-grey?logo=claude&logoColor=d97757)](https://claude.com/product/claude-code)
[![GNU AGPLv3.0 License](https://img.shields.io/badge/License-GNU%20AGPLv3.0-dark_green?logo=gnu)](https://choosealicense.com/licenses/agpl-3.0)
[![Buymeacoffee](https://img.shields.io/badge/Buymeacoffee-gray?logo=buymeacoffee)](https://buymeacoffee.com/nicolabelluti)
<br>
[![CI Badge](https://git.nicolabelluti.me/nicolabelluti/r701/actions/workflows/check-format-and-test.yaml/badge.svg)](https://git.nicolabelluti.me/nicolabelluti/r701/actions/?workflow=check-format-and-test.yaml)
</div><br>
> A reverse-engineered client for the
> [R701](https://ipsattendant.it/rilevatore-presenze-r701/) attendance reader by
> [I.P.S. Informatica](https://ipsinformatica.info/)
If you want to read about this reverse engineering attempt, check out
<https://nicolabelluti.me/series/attendance-reader/>.
+330
View File
@@ -0,0 +1,330 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "oxibadge",
"dependencies": {
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/geist": "^5.3.0",
"@fontsource/geist-mono": "^5.3.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.8.1",
"svelte-i18n": "^4.0.1",
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.65.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tauri-apps/cli": "^2",
"svelte": "^5.56.3",
"svelte-check": "^4.6.0",
"typescript": "~6.0.3",
"vite": "^8.0.16",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
"@fontsource/bricolage-grotesque": ["@fontsource/bricolage-grotesque@5.3.0", "", {}, "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw=="],
"@fontsource/geist": ["@fontsource/geist@5.3.0", "", {}, "sha512-u03ftlfVwb05uijq2jGDJ03/HKGqMKaNFk7KOhkpBUex8+BhrAOfMs/XciJ2UUNUX9fEsZbZQld/RWMA2oFU7g=="],
"@fontsource/geist-mono": ["@fontsource/geist-mono@5.3.0", "", {}, "sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg=="],
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="],
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="],
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
"@sveltejs/kit": ["@sveltejs/kit@2.70.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.3.1", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA=="],
"@tauri-apps/api": ["@tauri-apps/api@2.12.1", "", {}, "sha512-DeyFHa3wynpyoqTDikDEDGTJIq4LQ5USfolQGRmGIWT6JMADyxZBTDa5cAdT3tDg73rUXufPaCwN7aXBos4OnQ=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.12.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.12.1", "@tauri-apps/cli-darwin-x64": "2.12.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.1", "@tauri-apps/cli-linux-arm64-gnu": "2.12.1", "@tauri-apps/cli-linux-arm64-musl": "2.12.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.12.1", "@tauri-apps/cli-linux-x64-gnu": "2.12.1", "@tauri-apps/cli-linux-x64-musl": "2.12.1", "@tauri-apps/cli-win32-arm64-msvc": "2.12.1", "@tauri-apps/cli-win32-ia32-msvc": "2.12.1", "@tauri-apps/cli-win32-x64-msvc": "2.12.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg=="],
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.12.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw=="],
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.12.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg=="],
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.12.1", "", { "os": "linux", "cpu": "arm" }, "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ=="],
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.12.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw=="],
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.12.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg=="],
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.12.1", "", { "os": "linux", "cpu": "none" }, "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ=="],
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.12.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA=="],
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.12.1", "", { "os": "linux", "cpu": "x64" }, "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w=="],
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.12.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA=="],
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.12.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q=="],
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.12.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.8.1", "", { "dependencies": { "@tauri-apps/api": "^2.12.0" } }, "sha512-/DtE3B62JgpYuWFL5ATXM6KOXUq7pca7cJWOgSqULhZsgyjLk0PWKek1dbRDvhNBW6flNsOfWphsKnb+738Dmw=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"cli-color": ["cli-color@2.0.4", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.64", "es6-iterator": "^2.0.3", "memoizee": "^0.4.15", "timers-ext": "^0.1.7" } }, "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="],
"es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="],
"es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="],
"es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="],
"es6-weak-map": ["es6-weak-map@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.46", "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA=="],
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="],
"esrap": ["esrap@2.4.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="],
"ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"globalyzer": ["globalyzer@0.1.0", "", {}, "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="],
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
"is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"lru-queue": ["lru-queue@0.1.0", "", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"memoizee": ["memoizee@0.4.17", "", { "dependencies": { "d": "^1.0.2", "es5-ext": "^0.10.64", "es6-weak-map": "^2.0.3", "event-emitter": "^0.3.5", "is-promise": "^2.2.2", "lru-queue": "^0.1.0", "next-tick": "^1.1.0", "timers-ext": "^0.1.7" } }, "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="],
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"svelte": ["svelte@5.57.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA=="],
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
"svelte-i18n": ["svelte-i18n@4.0.1", "", { "dependencies": { "cli-color": "^2.0.3", "deepmerge": "^4.2.2", "esbuild": "^0.19.2", "estree-walker": "^2", "intl-messageformat": "^10.5.3", "sade": "^1.8.1", "tiny-glob": "^0.2.9" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5" }, "bin": { "svelte-i18n": "dist/cli.js" } }, "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ=="],
"timers-ext": ["timers-ext@0.1.8", "", { "dependencies": { "es5-ext": "^0.10.64", "next-tick": "^1.1.0" } }, "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww=="],
"tiny-glob": ["tiny-glob@0.2.9", "", { "dependencies": { "globalyzer": "0.1.0", "globrex": "^0.1.2" } }, "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"vite": ["vite@8.3.3", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="],
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "oxibadge",
"version": "0.1.0",
"description": "A reverse-engineered client for the R701 attendance reader by I.P.S. Informatica",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
},
"license": "AGPL-3.0-or-later",
"dependencies": {
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/geist": "^5.3.0",
"@fontsource/geist-mono": "^5.3.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.8.1",
"svelte-i18n": "^4.0.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.65.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "^5.56.3",
"svelte-check": "^4.6.0",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"@tauri-apps/cli": "^2"
}
}
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "oxibadge"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "oxibadge_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
toml = "0.9"
r701 = { path = "../../r701", features = ["serde"] }
chrono = { version = "0.4.38", default-features = false, features = ["clock"] }
tokio-stream = { version = "0.1.19", default-features = false }
tauri-plugin-dialog = "2.8.1"
spreadsheet-ods = "1.0.4"
zip = { version = "6", default-features = false, features = ["deflate"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-destroy",
"dialog:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+39
View File
@@ -0,0 +1,39 @@
use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
/// User-chosen employee names, by employee_id (as a string, to be a valid
/// TOML table).
pub type EmployeeNames = HashMap<String, String>;
/// `EmployeeNames` by reader index (as a string, same reason), since an
/// employee_id is only unique within one reader. Local to Oxibadge only:
/// the time clock is never written to.
pub type Cache = HashMap<String, EmployeeNames>;
fn path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
.map_err(|err| err.to_string())?
.join("employees.toml"))
}
pub fn load(app: &AppHandle) -> Cache {
let Ok(path) = path(app) else {
return Cache::new();
};
std::fs::read_to_string(path)
.ok()
.and_then(|contents| toml::from_str(&contents).ok())
.unwrap_or_default()
}
pub fn save(app: &AppHandle, cache: &Cache) -> Result<(), String> {
let dest = path(app)?;
if let Some(dir) = dest.parent() {
std::fs::create_dir_all(dir).map_err(|err| err.to_string())?;
}
let contents = toml::to_string_pretty(cache).map_err(|err| err.to_string())?;
std::fs::write(dest, contents).map_err(|err| err.to_string())
}
+383
View File
@@ -0,0 +1,383 @@
//! Builds the `.ods` timesheet file(s) the review screen's export button
//! produces, matching a specific layout/style the user already relies on
//! in their own spreadsheets (see the project history for the reference
//! file this was reverse-engineered from): a date column, four punch
//! columns (entry/exit for each shift) and a formula-driven "hours worked"
//! column with a monthly total. Column headers are plain text sent by the
//! frontend (translated via its own i18n), not hardcoded here.
//!
//! One employee gets one sheet in one `.ods` file. Exporting more than one
//! employee at once bundles one `.ods` per employee into a `.zip`.
use chrono::{Duration, NaiveDate};
use serde::Deserialize;
use spreadsheet_ods::color::Rgb;
use spreadsheet_ods::defaultstyles::DefaultFormat;
use spreadsheet_ods::style::units::{Border, FontSize, TextAlign};
use spreadsheet_ods::{
write_ods_buf, CellStyle, CellStyleRef, Length, Sheet, ValueFormatRef, ValueFormatTimeDuration,
WorkBook,
};
use std::collections::HashSet;
use std::io::Write;
use std::path::Path;
/// One employee's corrected timesheet for a month, as sent by the review
/// screen's export button. `days[d][c]` is the list of times punched in
/// slot `c` (entry/exit of the 1st/2nd shift) on day `d + 1`; usually 0 or
/// 1 entries, more if the day had stacked punches. A stacked slot's times
/// all end up in the same cell (one per line), matching how the review
/// screen itself stacks them — the export is a 1:1 copy of the app's data,
/// nothing is dropped.
#[derive(Debug, Deserialize)]
pub struct EmployeeTimesheet {
pub id: u32,
pub name: String,
pub days: Vec<Vec<Vec<String>>>,
}
/// The column header text, already translated by the frontend.
#[derive(Debug, Deserialize)]
pub struct ColumnHeaders {
pub date: String,
pub in1: String,
pub out1: String,
pub in2: String,
pub out2: String,
pub total: String,
}
#[derive(Debug, Deserialize)]
pub struct ExportPayload {
#[allow(dead_code)]
pub scope: String,
pub year: i32,
pub month: u32,
pub employees: Vec<EmployeeTimesheet>,
pub headers: ColumnHeaders,
}
// Column layout, 0-based. A and G are narrow blank spacer columns, matching
// the reference file.
const COL_DATE: u32 = 1;
const COL_IN1: u32 = 2;
const COL_OUT1: u32 = 3;
const COL_IN2: u32 = 4;
const COL_OUT2: u32 = 5;
const COL_TOTAL: u32 = 7;
const ROW_HEADER: u32 = 1;
const ROW_FIRST_DAY: u32 = 2;
struct Styles {
header_date: CellStyleRef,
header_time: CellStyleRef,
header_total: CellStyleRef,
date_a: CellStyleRef,
date_b: CellStyleRef,
time_a: CellStyleRef,
time_b: CellStyleRef,
total_a: CellStyleRef,
total_b: CellStyleRef,
}
/// A centered, bordered, 10pt Liberation Sans cell style, optionally tinted.
fn styled(name: &str, format: &ValueFormatRef, bg: Option<Rgb<u8>>) -> CellStyle {
let mut s = CellStyle::new(name, format);
s.set_font_name("Liberation Sans");
s.set_font_size(FontSize::from(Length::Pt(10.0)));
s.set_color(Rgb::new(0, 0, 0));
s.set_text_align(TextAlign::Center);
s.set_border(Length::Pt(0.74), Border::Solid, Rgb::new(0, 0, 0));
if let Some(rgb) = bg {
s.set_background_color(rgb);
}
s
}
fn build_styles(wb: &mut WorkBook) -> Styles {
let mut duration_fmt = ValueFormatTimeDuration::new_named("ox-duration");
duration_fmt.set_truncate_on_overflow(false);
duration_fmt.part_hours().long_style().build();
duration_fmt.part_text(":").build();
duration_fmt.part_minutes().long_style().build();
let duration_fmt = wb.add_timeduration_format(duration_fmt);
let plain = DefaultFormat::default();
Styles {
header_date: wb.add_cellstyle(styled(
"ox-header-date",
&plain,
Some(Rgb::new(0x3f, 0xaf, 0x46)),
)),
header_time: wb.add_cellstyle(styled(
"ox-header-time",
&plain,
Some(Rgb::new(0x59, 0x83, 0xb0)),
)),
header_total: wb.add_cellstyle(styled(
"ox-header-total",
&plain,
Some(Rgb::new(0xed, 0x4c, 0x05)),
)),
date_a: wb.add_cellstyle(styled("ox-date-a", &plain, None)),
date_b: wb.add_cellstyle(styled(
"ox-date-b",
&plain,
Some(Rgb::new(0xdd, 0xe8, 0xcb)),
)),
time_a: wb.add_cellstyle(styled("ox-time-a", &plain, None)),
time_b: wb.add_cellstyle(styled(
"ox-time-b",
&plain,
Some(Rgb::new(0xde, 0xe6, 0xef)),
)),
total_a: wb.add_cellstyle(styled("ox-total-a", &duration_fmt, None)),
total_b: wb.add_cellstyle(styled(
"ox-total-b",
&duration_fmt,
Some(Rgb::new(0xff, 0xdb, 0xb6)),
)),
}
}
fn parse_hhmm(s: &str) -> Option<i64> {
let (h, m) = s.split_once(':')?;
let h: i64 = h.parse().ok()?;
let m: i64 = m.parse().ok()?;
if !(0..24).contains(&h) || !(0..60).contains(&m) {
return None;
}
Some(h * 60 + m)
}
/// First punch of a slot, parsed to minutes since midnight, or 0 (midnight)
/// if missing/unparseable — same as how the embedded ODF formula treats a
/// blank cell in a subtraction.
fn first_minutes_or_zero(slot: &[String]) -> i64 {
slot.first().and_then(|s| parse_hhmm(s)).unwrap_or(0)
}
/// Worked minutes for a day: (out1 - in1) + (out2 - in2). Matches the
/// "hours worked" column's formula term for term (blank cells count as 0),
/// so e.g. a day with only an in1 and an out2 punched (no lunch break) still
/// nets out to the full span via cancellation, exactly like the live
/// formula would compute once the file is opened.
fn worked_minutes(day: &[Vec<String>]) -> i64 {
let at = |i: usize| {
day.get(i)
.map(Vec::as_slice)
.map(first_minutes_or_zero)
.unwrap_or(0)
};
(at(1) - at(0)) + (at(3) - at(2))
}
// The review screen already requires every employee to have a name before
// it lets the user continue (see NewEmployees.svelte), so `name` here is
// never genuinely empty. The `id` fallback below only guards the much
// narrower case of a name that sanitizes down to nothing (e.g. "***"):
// not a missing name, just one unusable verbatim as a sheet/file name.
fn sanitize_sheet_name(name: &str, id: u32) -> String {
let cleaned: String = name
.chars()
.map(|c| if "[]*?:/\\'".contains(c) { ' ' } else { c })
.collect();
let trimmed: String = cleaned.trim().chars().take(31).collect();
if trimmed.is_empty() {
id.to_string()
} else {
trimmed
}
}
fn sanitize_filename(name: &str, id: u32) -> String {
let cleaned: String = name
.chars()
.map(|c| {
if "/\\:*?\"<>|".contains(c) || c.is_control() {
'_'
} else {
c
}
})
.collect();
let trimmed = cleaned.trim().trim_matches('.').to_string();
if trimmed.is_empty() {
id.to_string()
} else {
trimmed
}
}
/// Builds the `.ods` bytes for one employee's month.
fn build_ods_bytes(
year: i32,
month: u32,
employee: &EmployeeTimesheet,
headers: &ColumnHeaders,
) -> Result<Vec<u8>, String> {
let mut wb = WorkBook::new_empty();
let styles = build_styles(&mut wb);
let mut sheet = Sheet::new(sanitize_sheet_name(&employee.name, employee.id));
sheet.set_col_width(0, Length::In(0.1799));
for col in [COL_DATE, COL_IN1, COL_OUT1, COL_IN2, COL_OUT2, COL_TOTAL] {
sheet.set_col_width(col, Length::In(0.8335));
}
sheet.set_col_width(6, Length::In(0.1665));
sheet.set_styled(
ROW_HEADER,
COL_DATE,
headers.date.as_str(),
&styles.header_date,
);
sheet.set_styled(
ROW_HEADER,
COL_IN1,
headers.in1.as_str(),
&styles.header_time,
);
sheet.set_styled(
ROW_HEADER,
COL_OUT1,
headers.out1.as_str(),
&styles.header_time,
);
sheet.set_styled(
ROW_HEADER,
COL_IN2,
headers.in2.as_str(),
&styles.header_time,
);
sheet.set_styled(
ROW_HEADER,
COL_OUT2,
headers.out2.as_str(),
&styles.header_time,
);
sheet.set_styled(
ROW_HEADER,
COL_TOTAL,
headers.total.as_str(),
&styles.header_total,
);
let mut row = ROW_FIRST_DAY;
let mut total_minutes = 0i64;
for (di, day) in employee.days.iter().enumerate() {
let empty = day.iter().all(|slot| slot.iter().all(|t| t.is_empty()));
if empty {
continue;
}
let zebra_b = (row - ROW_FIRST_DAY) % 2 == 1;
let (date_style, time_style, total_style) = if zebra_b {
(&styles.date_b, &styles.time_b, &styles.total_b)
} else {
(&styles.date_a, &styles.time_a, &styles.total_a)
};
let date = NaiveDate::from_ymd_opt(year, month, di as u32 + 1)
.map(|d| d.format("%d/%m/%Y").to_string())
.unwrap_or_default();
sheet.set_styled(row, COL_DATE, date, date_style);
for (col, slot_idx) in [(COL_IN1, 0), (COL_OUT1, 1), (COL_IN2, 2), (COL_OUT2, 3)] {
sheet.set_cellstyle(row, col, time_style);
if let Some(slot) = day.get(slot_idx) {
// A stacked slot (more than one punch) goes into the cell one time per
// line, so no punch the app shows is ever silently dropped from the export.
let joined = slot
.iter()
.filter(|t| !t.is_empty())
.cloned()
.collect::<Vec<_>>()
.join("\n");
if !joined.is_empty() {
sheet.set_value(row, col, joined);
}
}
}
let minutes = worked_minutes(day);
total_minutes += minutes;
let odf_row = row + 1; // ODF cell references are 1-based.
// spreadsheet-ods doesn't serialize negative durations correctly (emits per-component
// negative numbers, which Calc can't parse), so the cached value is clamped to 0 for an
// all-zero-or-negative day (unresolved/invalid source data); the live formula, once the
// file is recalculated, still shows the real (possibly negative or #VALUE!) result.
sheet.set_styled_value(
row,
COL_TOTAL,
Duration::minutes(minutes.max(0)),
total_style,
);
sheet.set_formula(
row,
COL_TOTAL,
format!("of:=[.F{odf_row}]-[.E{odf_row}]+[.D{odf_row}]-[.C{odf_row}]"),
);
row += 1;
}
let last_data_row = row - 1;
if last_data_row >= ROW_FIRST_DAY {
let total_row = row + 1; // one blank spacer row, like the reference file.
let first_odf_row = ROW_FIRST_DAY + 1;
let last_odf_row = last_data_row + 1;
sheet.set_styled_value(
total_row,
COL_TOTAL,
Duration::minutes(total_minutes.max(0)),
&styles.total_b,
);
sheet.set_formula(
total_row,
COL_TOTAL,
format!("of:=SUM([.H{first_odf_row}:.H{last_odf_row}])"),
);
}
wb.push_sheet(sheet);
write_ods_buf(&mut wb, Vec::new()).map_err(|err| err.to_string())
}
/// Writes the export to `path`: a single `.ods` for one employee, or a
/// `.zip` of one `.ods` per employee otherwise.
pub fn export_to_path(payload: ExportPayload, path: &Path) -> Result<(), String> {
if payload.employees.len() <= 1 {
let employee = payload
.employees
.first()
.ok_or_else(|| "no employees to export".to_string())?;
let bytes = build_ods_bytes(payload.year, payload.month, employee, &payload.headers)?;
std::fs::write(path, bytes).map_err(|err| err.to_string())
} else {
let file = std::fs::File::create(path).map_err(|err| err.to_string())?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
let mut used = HashSet::new();
for employee in &payload.employees {
let bytes = build_ods_bytes(payload.year, payload.month, employee, &payload.headers)?;
let base = sanitize_filename(&employee.name, employee.id);
let mut name = format!("{base}.ods");
let mut n = 2;
while !used.insert(name.clone()) {
name = format!("{base} ({n}).ods");
n += 1;
}
zip.start_file(&name, options)
.map_err(|err| err.to_string())?;
zip.write_all(&bytes).map_err(|err| err.to_string())?;
}
zip.finish().map_err(|err| err.to_string())?;
Ok(())
}
}
+159
View File
@@ -0,0 +1,159 @@
mod employees;
mod export;
mod preferences;
use chrono::{Local, TimeZone};
use export::ExportPayload;
use preferences::Settings;
use r701::{Record, R701};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use tauri::AppHandle;
use tokio_stream::StreamExt;
#[tauri::command]
fn load_settings(app: AppHandle) -> Settings {
preferences::load(&app)
}
#[tauri::command]
fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
preferences::save(&app, &settings)
}
#[tauri::command]
fn load_employee_cache(app: AppHandle) -> employees::Cache {
employees::load(&app)
}
#[tauri::command]
fn save_employee_cache(app: AppHandle, cache: employees::Cache) -> Result<(), String> {
employees::save(&app, &cache)
}
#[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 the last error
/// message as-is on failure.
async fn connect_with_retries(host: &str, port: u16) -> Result<R701, String> {
let mut last_error = String::new();
for _ in 0..3 {
match R701::connect((host, port)).await {
Ok(r701) => return Ok(r701),
Err(err) => last_error = err.to_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 settings = preferences::load(&app);
let Some(reader) = settings.readers.first() else {
return Err("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();
Ok(records)
}
/// Resolves the name of every distinct employee in `records`, skipping
/// `known` ones (already in the frontend's local cache) to avoid asking the
/// device for names it has already told us. Returns only the newly fetched
/// names, keyed by employee id.
#[tauri::command]
async fn load_employees(
app: AppHandle,
records: Vec<Record>,
known: Vec<u32>,
) -> Result<HashMap<u32, String>, String> {
let settings = preferences::load(&app);
let Some(reader) = settings.readers.first() else {
return Err("no reader configured".to_string());
};
let r701 = connect_with_retries(&reader.host, reader.port).await?;
let known: HashSet<u32> = known.into_iter().collect();
let mut employees = HashMap::new();
for record in &records {
if known.contains(&record.employee_id) || 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);
}
}
Ok(employees)
}
/// Writes the review screen's corrected timesheet to `path`, chosen by the
/// frontend via the native "save" dialog: a single `.ods` for one employee,
/// or a `.zip` of one `.ods` per employee for an "all employees" export.
#[tauri::command]
fn export_timesheet(payload: ExportPayload, path: String) -> Result<(), String> {
export::export_to_path(payload, &PathBuf::from(path))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
load_settings,
save_settings,
load_employee_cache,
save_employee_cache,
test_connection,
load_records,
load_employees,
export_timesheet
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
oxibadge_lib::run()
}
+72
View File
@@ -0,0 +1,72 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reader {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shifts {
pub in1: String,
pub out1: String,
pub in2: String,
pub out2: String,
pub round_step_min: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// The configured time clock (only the first one is used today, but
/// this stays a list to support multiple readers in the future).
#[serde(default)]
pub readers: Vec<Reader>,
pub shifts: Shifts,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
}
impl Default for Settings {
fn default() -> Self {
Self {
readers: Vec::new(),
shifts: Shifts {
in1: "07:30".to_string(),
out1: "12:00".to_string(),
in2: "13:30".to_string(),
out2: "17:00".to_string(),
round_step_min: 10,
},
lang: None,
}
}
}
fn path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
.map_err(|err| err.to_string())?
.join("preferences.toml"))
}
pub fn load(app: &AppHandle) -> Settings {
let Ok(path) = path(app) else {
return Settings::default();
};
std::fs::read_to_string(path)
.ok()
.and_then(|contents| toml::from_str(&contents).ok())
.unwrap_or_default()
}
pub fn save(app: &AppHandle, settings: &Settings) -> Result<(), String> {
let dest = path(app)?;
if let Some(dir) = dest.parent() {
std::fs::create_dir_all(dir).map_err(|err| err.to_string())?;
}
let contents = toml::to_string_pretty(settings).map_err(|err| err.to_string())?;
std::fs::write(dest, contents).map_err(|err| err.to_string())
}
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "oxibadge",
"version": "0.1.0",
"identifier": "me.nicolabelluti.oxibadge",
"build": {
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"title": "Oxibadge",
"width": 1200,
"height": 700
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all"
}
}
+92
View File
@@ -0,0 +1,92 @@
@import '@fontsource/geist/latin-400.css';
@import '@fontsource/geist/latin-500.css';
@import '@fontsource/geist/latin-600.css';
@import '@fontsource/bricolage-grotesque/latin-700.css';
@import '@fontsource/geist-mono/latin-400.css';
:root {
--ox-bg: #121212;
--ox-text: #f2f2f2;
--ox-text-strong: #ffffff;
--ox-text-muted: #a3a3a3;
--ox-text-subtle: #8a8a8a;
--ox-label: #9a9a9a;
--ox-placeholder: #5e5e5e;
--ox-border: #333333;
--ox-border-hover: #4a4a4a;
--ox-accent: #f74c00;
--ox-accent-soft: #ff7a3d;
--ox-accent-ring: rgba(247, 76, 0, 0.16);
--ox-on-accent: #1a0700;
--ox-error-bg: #261316;
--ox-error-border: #5a2229;
--ox-error-text: #f5b5b9;
--ox-error-title: #ffd6d8;
--ox-error-icon: #ff6b74;
--ox-radius: 12px;
--ox-control-h: 52px;
--ox-font-display: 'Bricolage Grotesque', 'Geist', system-ui, sans-serif;
--ox-font-body: 'Geist', system-ui, sans-serif;
--ox-font-mono: 'Geist Mono', ui-monospace, monospace;
}
body {
margin: 0;
background: var(--ox-bg);
color: var(--ox-text);
font-family: var(--ox-font-body);
}
svg {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
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);
}
.mono {
font-family: var(--ox-font-mono);
}
.red {
color: #ff9ea3;
}
.blue {
color: #8db8ff;
}
.orange {
color: #ff9a66;
}
.scroll {
scrollbar-width: thin;
scrollbar-color: #3a3a3a transparent;
}
.scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scroll::-webkit-scrollbar-thumb {
background: #333333;
border-radius: 4px;
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Oxibadge</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
export function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
@@ -0,0 +1,81 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
primary = false,
disabled = false,
onclick,
icon,
children
}: {
primary?: boolean;
disabled?: boolean;
onclick: () => void;
icon?: Snippet;
children: Snippet;
} = $props();
</script>
<button type="button" class="btn" class:primary {disabled} {onclick}>
{#if icon}{@render icon()}{/if}
<span>{@render children()}</span>
</button>
<style>
.btn {
all: unset;
box-sizing: border-box;
height: 44px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #333333;
border-radius: 12px;
color: #e6e6e6;
font-size: 14.5px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover:not(:disabled) {
background: #1e1e1e;
color: #ffffff;
}
.btn:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.5;
cursor: progress;
}
.btn.primary {
padding: 0 18px;
border: none;
background: var(--ox-accent);
color: var(--ox-on-accent);
font-weight: 600;
transition:
filter 0.15s,
transform 0.1s;
}
.btn.primary:hover:not(:disabled) {
background: var(--ox-accent);
filter: brightness(1.08);
}
.btn.primary:active:not(:disabled) {
transform: translateY(1px);
}
.btn.primary:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
let {
label,
type = 'button',
faded = false,
busy = false,
disabled = false,
onclick
}: {
label: string;
type?: 'button' | 'submit';
faded?: boolean;
busy?: boolean;
disabled?: boolean;
onclick?: () => void;
} = $props();
</script>
<button {type} class="btn" class:faded class:busy {disabled} {onclick}>
<span>{label}</span>
{#if busy}
<span class="spinner"></span>
{:else}
<svg width="18" height="18" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true"
><path d="M5 12h14M13 6l6 6-6 6" /></svg
>
{/if}
</button>
<style>
.btn {
all: unset;
box-sizing: border-box;
height: var(--ox-control-h);
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: var(--ox-radius);
background: var(--ox-accent);
color: var(--ox-on-accent);
font-family: var(--ox-font-body);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition:
filter 0.15s,
transform 0.1s,
opacity 0.2s;
}
.btn:not(:disabled):hover {
filter: brightness(1.08);
}
.btn:not(:disabled):active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
.btn:disabled {
cursor: not-allowed;
}
.btn.faded {
opacity: 0.35;
}
.btn.busy {
opacity: 0.7;
cursor: progress;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(26, 7, 0, 0.35);
border-top-color: var(--ox-on-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="column">
{@render children()}
</div>
<style>
.column {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 36px;
}
</style>
@@ -0,0 +1,83 @@
<script lang="ts">
import ModalFrame from './ModalFrame.svelte';
import ActionButton from './ActionButton.svelte';
let {
fixed = false,
title,
message,
confirmLabel,
cancelLabel,
onconfirm,
oncancel
}: {
/** See `ModalFrame`'s `fixed` prop. */
fixed?: boolean;
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
onconfirm: () => void;
oncancel: () => void;
} = $props();
// Unique per instance, so two ConfirmDialogs never clash if ever shown
// at once (e.g. this one plus the app-wide "quit with unsaved changes" one).
const uid = $props.id();
</script>
<ModalFrame
{fixed}
titleId="{uid}-title"
descId="{uid}-desc"
closeAriaLabel={cancelLabel}
onclose={oncancel}
>
{#snippet icon()}
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
d="M12 9.5v4M12 17h.01"
/></svg
>
{/snippet}
<div class="modal-text">
<h2 id="{uid}-title">{title}</h2>
<p id="{uid}-desc">{message}</p>
</div>
<div class="modal-actions">
<ActionButton onclick={oncancel}>{cancelLabel}</ActionButton>
<ActionButton primary onclick={onconfirm}>{confirmLabel}</ActionButton>
</div>
</ModalFrame>
<style>
.modal-text {
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-text h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 22px;
line-height: 1.2;
letter-spacing: -0.02em;
color: #ffffff;
}
.modal-text p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #a3a3a3;
}
.modal-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -0,0 +1,323 @@
<script lang="ts">
import { tick } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import { error, clearError } from '$lib/error';
import Column from './Column.svelte';
import Intro from './Intro.svelte';
import Button from './Button.svelte';
import ErrorBanner from './ErrorBanner.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;
clearError();
}
function pickYear(y: number) {
year = y;
if (y === currentYear && month > currentMonth) month = currentMonth;
open = null;
clearError();
}
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} />
{#if $error}
<ErrorBanner title={$_('errors.title')} message={$error} />
{/if}
</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-size: 16px;
}
.opt.sel,
.opt.sel:hover {
background: var(--ox-accent);
color: var(--ox-on-accent);
font-weight: 600;
}
</style>
@@ -0,0 +1,46 @@
<script lang="ts">
let { title, message }: { title: string; message: string } = $props();
</script>
<div class="banner" role="alert">
<svg width="20" height="20" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
<path
d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"
/><path d="M12 9.5v4M12 17h.01" />
</svg>
<div class="banner-text">
<strong>{title}</strong>
<span>{message}</span>
</div>
</div>
<style>
.banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-radius: var(--ox-radius);
font-size: 14px;
line-height: 1.45;
background: var(--ox-error-bg);
border: 1px solid var(--ox-error-border);
color: var(--ox-error-text);
}
.banner svg {
color: var(--ox-error-icon);
margin-top: 1px;
}
.banner-text {
display: flex;
flex-direction: column;
gap: 3px;
}
.banner-text strong {
font-weight: 600;
color: var(--ox-error-title);
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
id,
label,
value = $bindable(),
type = 'text',
placeholder = '',
maxlength,
inputmode,
err = false,
suffix,
oninput,
icon
}: {
id: string;
label: string;
value: string | number;
type?: string;
placeholder?: string;
maxlength?: number;
inputmode?: 'text' | 'numeric' | 'decimal' | 'none' | 'tel' | 'search' | 'email' | 'url';
err?: boolean;
suffix?: string;
oninput?: (e: Event & { currentTarget: HTMLInputElement }) => void;
icon?: Snippet;
} = $props();
</script>
<div class="field" class:err>
<label for={id}>{label}</label>
{#if icon}{@render icon()}{/if}
<input
{id}
{type}
{placeholder}
{maxlength}
{inputmode}
bind:value
{oninput}
autocomplete="off"
spellcheck="false"
/>
{#if suffix}<span class="suffix">{suffix}</span>{/if}
</div>
<style>
.field {
position: relative;
height: var(--ox-control-h);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px;
background: var(--ox-bg);
border: 1px solid var(--ox-border);
border-radius: var(--ox-radius);
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.field:hover {
border-color: var(--ox-border-hover);
}
.field:focus-within {
border-color: var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
}
.field.err,
.field.err:hover,
.field.err:focus-within {
border-color: #e5484d;
}
.field.err:focus-within {
box-shadow: 0 0 0 4px rgba(229, 72, 77, 0.18);
}
.field.err label {
color: #ff8a8f;
}
.field label {
position: absolute;
top: -9px;
left: 12px;
padding: 0 6px;
background: var(--ox-bg);
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--ox-label);
transition: color 0.15s;
}
.field:focus-within label {
color: var(--ox-accent-soft);
}
.field :global(svg) {
color: #6e6e6e;
}
.field input {
all: unset;
box-sizing: border-box;
min-width: 0;
width: 100%;
font-family: var(--ox-font-mono);
font-size: 16px;
color: var(--ox-text);
caret-color: var(--ox-accent);
}
.field input::placeholder {
color: var(--ox-placeholder);
}
.field input[type='number']::-webkit-outer-spin-button,
.field input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.suffix {
flex: none;
font-size: 14px;
color: var(--ox-text-subtle);
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let { text }: { text: string } = $props();
</script>
<p class="hint">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
</svg>
<span>{text}</span>
</p>
<style>
.hint {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
color: var(--ox-text-subtle);
text-align: center;
}
</style>
@@ -0,0 +1,103 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
size = 'sm',
accent = false,
danger = false,
dangerFilled = false,
title,
ariaLabel,
onclick,
icon
}: {
size?: 'sm' | 'lg';
accent?: boolean;
/** Subtle red tint on hover only — for a destructive icon among others, e.g. in a list row. */
danger?: boolean;
/** Permanent red box, like `accent` but red — for a standalone destructive action. */
dangerFilled?: boolean;
title?: string;
ariaLabel: string;
onclick: () => void;
icon: Snippet;
} = $props();
</script>
<button
type="button"
class="icon-btn"
class:lg={size === 'lg'}
class:accent
class:danger
class:danger-filled={dangerFilled}
{title}
aria-label={ariaLabel}
{onclick}
>
{@render icon()}
</button>
<style>
.icon-btn {
all: unset;
box-sizing: border-box;
width: 32px;
height: 32px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #8a8a8a;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.icon-btn.lg {
width: 40px;
height: 40px;
border-radius: 10px;
color: #a3a3a3;
}
.icon-btn:hover {
background: #1e1e1e;
color: #ffffff;
}
.icon-btn:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 1px;
}
.icon-btn.accent {
background: var(--ox-accent);
color: var(--ox-on-accent);
}
.icon-btn.accent:hover {
filter: brightness(1.08);
}
.icon-btn.danger:hover {
background: rgba(229, 72, 77, 0.14);
color: #ff8a8f;
}
.icon-btn.danger-filled {
background: rgba(229, 72, 77, 0.14);
color: #ff6b74;
}
.icon-btn.danger-filled:hover {
background: rgba(229, 72, 77, 0.22);
color: #ff8a8f;
}
.icon-btn.danger-filled:focus-visible {
outline-color: #e5484d;
}
</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>
@@ -0,0 +1,86 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
fixed = false,
titleId,
descId,
closeAriaLabel,
onclose,
icon,
children
}: {
/** Use for a modal that must sit above the whole app (e.g. shown from
* outside the screen it concerns), instead of within the nearest
* positioned ancestor. */
fixed?: boolean;
titleId: string;
descId?: string;
closeAriaLabel: string;
onclose: () => void;
icon?: Snippet;
children: Snippet;
} = $props();
</script>
<div class="modal-wrap" class:fixed>
<button type="button" class="modal-backdrop" aria-label={closeAriaLabel} onclick={onclose}></button>
<div class="modal" role="alertdialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descId}>
{#if icon}
<div class="modal-icon">{@render icon()}</div>
{/if}
{@render children()}
</div>
</div>
<style>
.modal-wrap {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.modal-wrap.fixed {
position: fixed;
z-index: 100;
}
.modal-backdrop {
all: unset;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
cursor: default;
}
.modal {
position: relative;
width: 100%;
max-width: 440px;
box-sizing: border-box;
padding: 24px;
background: #1a1a1a;
border: 1px solid #333333;
border-radius: 18px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
gap: 18px;
}
.modal-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(229, 72, 77, 0.14);
color: #ff6b74;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,276 @@
<script lang="ts">
import { untrack } from 'svelte';
import { _ } from 'svelte-i18n';
import { isEdited, isEmpty, isInvalid, type NewEmployee } from './new-employees/logic';
let {
employees,
oncontinue
}: {
employees: NewEmployee[];
oncontinue?: (list: { id: number; name: string; original: string }[]) => void;
} = $props();
// Working copy: the props only serve as initial values. `untrack` makes
// it explicit that we read `employees` only once, on purpose.
let names = $state(untrack(() => employees.map((e) => e.name)));
const count = $derived(employees.length);
const empties = $derived(names.filter((n) => isEmpty(n)).length);
const invalids = $derived(names.filter((n) => isInvalid(n)).length);
const renamed = $derived(names.filter((n, i) => isEdited(n, employees[i].name)).length);
const inputs: HTMLInputElement[] = [];
function submit() {
if (empties > 0 || invalids > 0) return;
oncontinue?.(employees.map((e, i) => ({ id: e.id, name: names[i].trim(), original: e.name })));
}
function onKeydown(e: KeyboardEvent, i: number) {
if (e.key !== 'Enter') return;
e.preventDefault();
if (i < inputs.length - 1) inputs[i + 1]?.focus();
else submit();
}
</script>
<main class="screen">
<div class="column">
<header class="intro">
<h1>{$_('new_employees.title')}</h1>
<p>
{$_('new_employees.subtitle', { values: { n: count } })}
{$_('new_employees.hint')}
</p>
</header>
<div class="list scroll">
{#each employees as emp, i (emp.id)}
{@const empty = isEmpty(names[i])}
{@const invalid = isInvalid(names[i])}
{@const edited = isEdited(names[i], emp.name)}
<div class="item">
<span class="uid mono">#{emp.id}</span>
<input
bind:this={inputs[i]}
class="name"
class:edit={edited}
class:empty
class:invalid
type="text"
placeholder={$_('new_employees.missing_placeholder')}
title={invalid ? $_('new_employees.invalid_name_title') : undefined}
aria-label={$_('new_employees.name_aria', { values: { id: emp.id } })}
aria-invalid={empty || invalid}
bind:value={names[i]}
onkeydown={(e) => onKeydown(e, i)}
/>
</div>
{/each}
</div>
<div class="footer">
<div class="summary">
<span>{$_('new_employees.renamed_count', { values: { n: renamed } })}</span>
{#if empties > 0}
<span class="red">{$_('new_employees.missing_count', { values: { n: empties } })}</span>
{/if}
{#if invalids > 0}
<span class="red">{$_('new_employees.invalid_count', { values: { n: invalids } })}</span>
{/if}
</div>
<button type="button" class="btn" disabled={empties > 0 || invalids > 0} onclick={submit}>
<span>{$_('new_employees.continue')}</span>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
</div>
</main>
<style>
.screen {
height: 100vh;
min-height: 640px;
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
background: var(--ox-bg);
color: var(--ox-text);
font-family: var(--ox-font-body);
}
.column {
width: 100%;
max-width: 440px;
max-height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 24px;
}
.intro {
flex: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
h1 {
margin: 0;
font-size: 44px;
}
.intro p {
margin: 0;
max-width: 380px;
font-size: 15px;
line-height: 1.5;
color: var(--ox-text-muted);
}
.list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 8px 0 20px;
display: flex;
flex-direction: column;
gap: 4px;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 18px, #000 calc(100% - 28px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, #000 18px, #000 calc(100% - 28px), transparent 100%);
}
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 0 4px;
}
.uid {
width: 40px;
flex: none;
text-align: right;
font-size: 14px;
font-weight: 300;
color: #6e6e6e;
}
/* The name looks like plain text; it becomes a field on hover/focus. */
.name {
all: unset;
box-sizing: border-box;
flex: 1;
min-width: 0;
height: 44px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 10px;
font-size: 16.5px;
color: var(--ox-text);
caret-color: var(--ox-accent);
cursor: text;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.name::placeholder {
color: #b85a60;
}
.name:hover {
border-color: var(--ox-border);
background: #161616;
}
.name:focus {
border-color: var(--ox-accent);
box-shadow: 0 0 0 3px var(--ox-accent-ring);
background: #161616;
}
.name.edit {
color: #ffb08c;
background: rgba(247, 76, 0, 0.09);
}
.name.edit:focus {
background: #161616;
}
.name.empty,
.name.invalid {
border: 1px dashed #a8383d;
background: rgba(229, 72, 77, 0.08);
}
.name.empty:focus,
.name.invalid:focus {
border-style: solid;
border-color: #e5484d;
box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.18);
}
.footer {
flex: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.summary {
display: flex;
justify-content: center;
gap: 16px;
font-size: 13px;
color: var(--ox-text-subtle);
}
.btn {
all: unset;
box-sizing: border-box;
height: var(--ox-control-h);
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: var(--ox-radius);
background: var(--ox-accent);
color: var(--ox-on-accent);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition:
filter 0.15s,
transform 0.1s,
opacity 0.2s;
}
.btn:not(:disabled):hover {
filter: brightness(1.08);
}
.btn:not(:disabled):active {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
</style>
@@ -0,0 +1,101 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { _ } from 'svelte-i18n';
import { error, setError, clearError } from '$lib/error';
import type { Settings } from '$lib/settings';
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';
import Hint from './Hint.svelte';
let { onconnected }: { onconnected: (cfg: { host: string; port: number }) => void } = $props();
let host = $state('');
let port = $state(5005);
let testing = $state(false);
const trimmedHost = $derived(host.trim());
const empty = $derived(trimmedHost === '');
const disabled = $derived(empty || testing);
const btnLabel = $derived(
testing ? $_('onboarding.button.testing') : $error ? $_('onboarding.button.retry') : $_('onboarding.button.idle')
);
async function testAndSave() {
if (disabled) return;
testing = true;
clearError();
try {
await invoke('test_connection', { host: trimmedHost, port });
const settings = await invoke<Settings>('load_settings');
await invoke('save_settings', { settings: { ...settings, readers: [{ host: trimmedHost, port }] } });
onconnected({ host: trimmedHost, port });
} catch (err) {
setError(err);
} finally {
testing = false;
}
}
</script>
<Column>
<Intro title={$_('onboarding.title')} subtitle={$_('onboarding.subtitle')} />
<form
class="form"
onsubmit={(event) => {
event.preventDefault();
testAndSave();
}}
>
<div class="row">
<Field
id="ox-host"
label={$_('onboarding.host_label')}
bind:value={host}
placeholder="192.168.1.100"
oninput={clearError}
>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="18" height="12" rx="2" /><path d="M8 20h8M12 16v4" />
</svg>
{/snippet}
</Field>
<Field
id="ox-port"
label={$_('onboarding.port_label')}
bind:value={port}
type="number"
placeholder="5005"
oninput={clearError}
/>
</div>
<Button type="submit" label={btnLabel} faded={empty} busy={testing} {disabled} />
{#if $error}
<ErrorBanner title={$_('errors.title')} message={$error} />
{/if}
<Hint text={$_('onboarding.hint')} />
</form>
</Column>
<style>
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) 108px;
gap: 12px;
align-items: start;
}
</style>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
disabled = false,
onclick,
icon,
children
}: {
disabled?: boolean;
onclick: () => void;
icon?: Snippet;
children: Snippet;
} = $props();
</script>
<button type="button" class="pill" {disabled} {onclick}>
{#if icon}{@render icon()}{/if}
<span>{@render children()}</span>
</button>
<style>
.pill {
all: unset;
box-sizing: border-box;
height: 36px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 7px;
border-radius: 9px;
border: 1px solid #333333;
color: #c9c9c9;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.pill:hover:not(:disabled) {
background: #1e1e1e;
color: #ffffff;
border-color: #4a4a4a;
}
.pill:disabled {
opacity: 0.5;
cursor: progress;
}
.pill:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<main class="screen">
{@render children()}
</main>
<style>
.screen {
position: relative;
min-height: 100vh;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { _, locale } from 'svelte-i18n';
import IconButton from './IconButton.svelte';
import ReaderCard from './settings/ReaderCard.svelte';
import ShiftsCard from './settings/ShiftsCard.svelte';
import RoundCard from './settings/RoundCard.svelte';
import LanguageSection from './settings/LanguageSection.svelte';
import { checkShifts, roundValid, type ShiftTimeKey, type Shifts } from './settings/logic';
import type { Settings as SettingsData } from '$lib/settings';
let { onback, onsettingschange }: { onback?: () => void; onsettingschange?: (s: SettingsData) => void } =
$props();
const SAVE_DELAY = 700;
let loaded = $state(false);
let host = $state('');
let port = $state(5005);
// Placeholder: overwritten by load_settings before the cards are rendered ({#if loaded}).
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
let roundStepStr = $state('');
let lang = $state<string | null>(null);
type SaveState = 'idle' | 'saving' | 'saved' | 'invalid' | 'failed';
let saveState = $state<SaveState>('idle');
let saveTimer: ReturnType<typeof setTimeout> | undefined;
onMount(async () => {
const settings = await invoke<SettingsData>('load_settings');
host = settings.readers[0]?.host ?? '';
port = settings.readers[0]?.port ?? 5005;
shifts = { ...settings.shifts };
roundStepStr = String(settings.shifts.round_step_min);
lang = settings.lang;
loaded = true;
});
function settingsValid() {
return !checkShifts(shifts).errorKey && roundValid(roundStepStr);
}
async function persist() {
saveState = 'saving';
const settings: SettingsData = {
readers: [{ host, port }],
shifts: { ...shifts, round_step_min: Number(roundStepStr) },
lang
};
try {
await invoke('save_settings', { settings });
onsettingschange?.(settings);
saveState = 'saved';
} catch {
saveState = 'failed';
}
}
function scheduleSave(delay: number) {
clearTimeout(saveTimer);
if (!settingsValid()) {
saveState = 'invalid';
return;
}
saveState = 'saving';
saveTimer = setTimeout(persist, delay);
}
$effect(() => () => clearTimeout(saveTimer));
function onShiftInput(key: ShiftTimeKey, value: string) {
shifts[key] = value;
scheduleSave(SAVE_DELAY);
}
function onRoundInput(value: string) {
roundStepStr = value;
scheduleSave(SAVE_DELAY);
}
function onLangChange(next: string) {
lang = next;
locale.set(next);
scheduleSave(0);
}
function onAddressSaved(newHost: string, newPort: number) {
host = newHost;
port = newPort;
}
const saveInfo = $derived(
{
idle: [$_('settings.save_idle'), '#6e6e6e'],
saving: [$_('settings.save_saving'), '#a3a3a3'],
saved: [$_('settings.save_saved'), '#4cc38a'],
invalid: [$_('settings.save_invalid'), '#ff9ea3'],
failed: [$_('settings.save_failed'), '#ff9ea3']
}[saveState]
);
</script>
<div class="screen">
<header class="topbar">
<div class="tb-left">
{#if onback}
<IconButton size="lg" ariaLabel={$_('review.back_to_month')} onclick={onback}>
{#snippet icon()}
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M15 6l-6 6 6 6" /></svg
>
{/snippet}
</IconButton>
{/if}
<h1>{$_('settings.title')}</h1>
</div>
<span class="save-state" role="status" aria-live="polite" style="color: {saveInfo[1]}">
{#if saveState === 'saving'}
<svg class="spin" width="15" height="15" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M12 3a9 9 0 1 0 9 9" /></svg
>
{:else if saveState === 'saved'}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.2"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
{:else if saveState === 'invalid' || saveState === 'failed'}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
>
{/if}
{saveInfo[0]}
</span>
</header>
<div class="content scroll">
<div class="column">
{#if loaded}
<section aria-labelledby="s-dev">
<div class="sec-head">
<h2 id="s-dev">{$_('settings.device_title')}</h2>
<p>{$_('settings.device_desc')}</p>
</div>
<ReaderCard {host} {port} onsave={onAddressSaved} />
</section>
<section aria-labelledby="s-shift">
<div class="sec-head">
<h2 id="s-shift">{$_('settings.shifts_title')}</h2>
<p>{$_('settings.shifts_desc')}</p>
</div>
<ShiftsCard {shifts} oninput={onShiftInput} />
<RoundCard roundStep={roundStepStr} oninput={onRoundInput} />
</section>
<LanguageSection lang={(lang ?? $locale ?? 'it').split('-')[0]} onchange={onLangChange} />
{/if}
</div>
</div>
</div>
<style>
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.screen {
position: relative;
height: 100vh;
min-height: 640px;
width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--ox-bg);
color: var(--ox-text);
font-family: var(--ox-font-body);
}
.topbar {
flex: none;
min-height: 76px;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid #222222;
}
.tb-left {
display: flex;
align-items: center;
gap: 14px;
}
h1 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 26px;
line-height: 1.1;
letter-spacing: -0.025em;
color: var(--ox-text-strong);
}
.save-state {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
}
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.column {
max-width: 720px;
margin: 0 auto;
box-sizing: border-box;
padding: 32px 24px 64px;
display: flex;
flex-direction: column;
gap: 48px;
}
section {
display: flex;
flex-direction: column;
gap: 20px;
}
.sec-head {
display: flex;
flex-direction: column;
gap: 6px;
}
.sec-head h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
}
.sec-head p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #8a8a8a;
}
</style>
@@ -0,0 +1,44 @@
<script lang="ts">
let { ariaLabel, onclick }: { ariaLabel: string; onclick: () => void } = $props();
</script>
<button type="button" class="gear" aria-label={ariaLabel} title={ariaLabel} {onclick}>
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"
/>
</svg>
</button>
<style>
.gear {
all: unset;
box-sizing: border-box;
position: absolute;
top: 16px;
right: 16px;
z-index: 4;
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
color: var(--ox-text-subtle);
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.gear:hover {
background: #1e1e1e;
color: var(--ox-text-strong);
}
.gear:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
</style>
@@ -0,0 +1,512 @@
<script lang="ts">
import { untrack } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import type { Shifts } from '$lib/shifts';
import {
type Day,
type Employee,
analyze,
assignToShifts,
dayWord,
formatTyped,
hm,
lunchSpan,
normDay,
roundT,
shiftT,
shortSummary,
summary
} from './review/analysis';
import TopBar from './review/TopBar.svelte';
import EmployeeSidebar from './review/EmployeeSidebar.svelte';
import EmployeeHeader from './review/EmployeeHeader.svelte';
import Toolbar from './review/Toolbar.svelte';
import TimesheetTable from './review/TimesheetTable.svelte';
import ExportModal from './review/ExportModal.svelte';
import Toast from './review/Toast.svelte';
import ActionButton from './ActionButton.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import ErrorBanner from './ErrorBanner.svelte';
import { error, setError, clearError } from '$lib/error';
let {
year,
month,
employees,
shift,
onexport,
onrename,
onback,
ondirtychange
}: {
year: number;
month: number;
employees: Employee[];
shift: Shifts;
/** Returns `false` if the user cancelled (e.g. the save dialog), in which case nothing changes. */
onexport?: (p: {
scope: 'all' | 'one';
year: number;
month: number;
employees: Employee[];
headers: { date: string; in1: string; out1: string; in2: string; out2: string; total: string };
}) => Promise<boolean>;
onrename?: (id: Employee['id'], name: string) => void;
onback?: () => void;
/** Called whenever there are (or stop being) edits not yet exported. */
ondirtychange?: (dirty: boolean) => void;
} = $props();
// Working copies: the component never mutates the props. Loading another
// month remounts it (already the case in this app, since this screen
// sits behind a {#if step === 'review'}). `untrack` makes it explicit
// that we read `employees` only once, on purpose.
const orig: Day[][] = untrack(() => employees.map((e) => e.days.map(normDay)));
let values = $state<Day[][]>(untrack(() => employees.map((e) => e.days.map(normDay))));
let names = $state<string[]>(untrack(() => employees.map((e) => e.name)));
/** "employee-day-slot-index" -> time produced by rounding */
let rounded = $state<Record<string, string>>({});
let hasUnexportedChanges = $state(false);
const daysInMonth = orig[0]?.length ?? 0;
$effect(() => {
ondirtychange?.(hasUnexportedChanges);
});
// ---- UI state ----
let emp = $state(0);
let query = $state('');
let onlyIssues = $state(false);
let editingName = $state(false);
let draft = $state('');
let menuOpen = $state(false);
let confirmScope = $state<'all' | 'one' | null>(null);
let confirmingUndoAll = $state(false);
let exporting = $state(false);
let toast = $state('');
let toastTimer: ReturnType<typeof setTimeout> | undefined;
const all = $derived(
names.map((_name, ei) => analyze(year, month, values[ei], orig[ei], $locale, $_, shift))
);
const cur = $derived(all[emp]);
const issuesAll = $derived(all.reduce((n, x) => n + x.issues, 0));
const infosAll = $derived(all.reduce((n, x) => n + x.infos, 0));
const visibleEmps = $derived(
names
.map((name, ei) => ({ name, ei, issues: all[ei].issues, infos: all[ei].infos }))
.filter((e) => !query.trim() || e.name.toLowerCase().includes(query.trim().toLowerCase()))
);
const visibleRows = $derived(cur.rows.filter((r) => !onlyIssues || r.issue || r.info));
// ---- Data actions ----
function onCellInput(di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget;
const cell = values[emp][di][ci];
const prev = cell[ii] ?? '';
const next = formatTyped(input.value, prev, (e as unknown as InputEvent).inputType ?? '');
if (next === '' && cell.length > 1) {
// One of the stacked punches was cleared by hand: the cell shrinks
// instead of leaving a dangling empty field.
cell.splice(ii, 1);
} else {
cell[ii] = next;
if (input.value !== next) input.value = next; // keep the field in sync even if the state doesn't change
}
hasUnexportedChanges = true;
}
/** Redistributes the day's punches into E1, U1, E2, U2 based on the expected shifts. */
function fixDays(dis: number[]) {
for (const di of dis) {
const times = values[emp][di].flat().filter((x) => x !== '');
values[emp][di] = assignToShifts(times, shift);
}
hasUnexportedChanges = true;
}
function addLunch(dis: number[]) {
for (const di of dis) {
const span = lunchSpan(values[emp][di], shift);
if (span) values[emp][di] = [[span[0]], [shift.out1], [shift.in2], [span[1]]];
}
hasUnexportedChanges = true;
}
function roundDaysOf(ei: number, dis: number[]) {
for (const di of dis) {
values[ei][di] = values[ei][di].map((c, ci) =>
c.map((x, ii) => {
const r = roundT(x, shift.round_step_min);
if (r !== x) rounded[`${ei}-${di}-${ci}-${ii}`] = r;
return r;
})
) as Day;
}
hasUnexportedChanges = true;
}
/** Applies all three automatic corrections to the current employee in order: shifts, then lunch, then rounding. */
function applyAllCorrections() {
const allDays = values[emp].map((_d, di) => di);
fixDays(cur.stackedDays);
addLunch(allDays);
roundDaysOf(emp, allDays);
}
/** Adds a fixed offset to every time of the chosen days, for the given employee(s) — e.g. to fix a
* daylight-saving change the time clock didn't follow. */
function applyShift(opts: { minutes: number; fromDay: number; toDay: number; scope: 'all' | 'one' }) {
const eis = opts.scope === 'all' ? names.map((_name, i) => i) : [emp];
const from = opts.fromDay - 1;
const to = opts.toDay - 1;
for (const ei of eis) {
for (let di = from; di <= to && di < values[ei].length; di++) {
values[ei][di] = values[ei][di].map((c) => c.map((x) => shiftT(x, opts.minutes))) as Day;
}
}
hasUnexportedChanges = true;
}
/** Reverts one day back to what the time clock originally reported. */
function undoDay(di: number) {
values[emp][di] = orig[emp][di].map((c) => [...c]) as Day;
hasUnexportedChanges = true;
}
function requestUndoAll() {
confirmingUndoAll = true;
}
/** Reverts every day of the current employee back to the time clock's original data. */
function confirmUndoAll() {
confirmingUndoAll = false;
values[emp] = orig[emp].map((day) => day.map((c) => [...c]) as Day);
hasUnexportedChanges = true;
}
// ---- Name ----
function renameCurrent(name: string) {
names[emp] = name;
onrename?.(employees[emp].id, name);
}
// ---- Export ----
function requestExport(scope: 'all' | 'one') {
menuOpen = false;
const pending = scope === 'all' ? issuesAll + infosAll : cur.issues + cur.infos;
if (pending > 0) confirmScope = scope;
else doExport(scope);
}
async function doExport(scope: 'all' | 'one') {
if (exporting) return;
const pick = scope === 'all' ? names.map((_name, i) => i) : [emp];
const snap = $state.snapshot(values);
const payload = {
scope,
year,
month,
employees: pick.map((ei) => ({
id: employees[ei].id,
name: names[ei],
days: snap[ei].map((d) => d.map((c) => c.filter((x) => x !== ''))) as Day[]
})),
headers: {
date: $_('export.col_date'),
in1: $_('export.col_in1'),
out1: $_('export.col_out1'),
in2: $_('export.col_in2'),
out2: $_('export.col_out2'),
total: $_('export.col_total')
}
};
exporting = true;
clearError();
try {
const exported = await onexport?.(payload);
if (!exported) return; // the user cancelled (e.g. closed the save dialog)
confirmScope = null;
menuOpen = false;
hasUnexportedChanges = false;
toast = $_('review.export_done', {
values: { who: scope === 'all' ? $_('review.everyone') : names[emp] }
});
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ''), 3200);
} catch (err) {
setError(err);
} finally {
exporting = false;
}
}
async function roundAndExport(scope: 'all' | 'one') {
const eis = scope === 'all' ? names.map((_name, i) => i) : [emp];
for (const ei of eis) roundDaysOf(ei, all[ei].roundDays);
await doExport(scope);
}
$effect(() => () => clearTimeout(toastTimer));
// Export warning dialog texts
const scopeIssues = $derived(confirmScope === 'all' ? issuesAll : cur.issues);
const scopeInfos = $derived(confirmScope === 'all' ? infosAll : cur.infos);
const confirmSummary = $derived(
scopeIssues
? summary(scopeIssues, scopeInfos, $_)
: $_('review.only_unrounded', { values: { days: dayWord(scopeInfos, $_) } })
);
const confirmTitle = $derived(
confirmScope === 'all'
? $_('review.confirm_title_all', { values: { summary: confirmSummary } })
: $_('review.confirm_title_one', { values: { name: names[emp], summary: confirmSummary } })
);
const confirmItems = $derived(
confirmScope === 'all'
? names
.map((name, ei) => ({ name, ei, iss: all[ei].issues, inf: all[ei].infos }))
.filter((x) => x.iss || x.inf)
.map((x) => ({
key: 'e' + x.ei,
label: x.name,
detail: shortSummary(x.iss, x.inf, $_),
red: x.iss > 0,
go: () => {
confirmScope = null;
emp = x.ei;
onlyIssues = true;
editingName = false;
}
}))
: cur.rows
.filter((r) => r.issue || r.info)
.map((r) => ({
key: 'd' + r.di,
label: r.dow + ' ' + (r.di + 1),
detail: r.issue || $_('review.to_round_cap'),
red: !!r.issue,
go: () => {
confirmScope = null;
onlyIssues = true;
}
}))
);
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (confirmScope) confirmScope = null;
else if (confirmingUndoAll) confirmingUndoAll = false;
else if (menuOpen) menuOpen = false;
else if (editingName) editingName = false;
}
</script>
<svelte:window onkeydown={onKey} />
<div class="screen">
{#if menuOpen}
<button type="button" class="backdrop" aria-label={$_('review.close_menu_aria')} onclick={() => (menuOpen = false)}
></button>
{/if}
{#if confirmScope}
<ExportModal
title={confirmTitle}
{scopeIssues}
{scopeInfos}
{shift}
items={confirmItems}
onclose={() => (confirmScope = null)}
onroundandexport={() => confirmScope && roundAndExport(confirmScope)}
onexportanyway={() => confirmScope && doExport(confirmScope)}
/>
{/if}
{#if confirmingUndoAll}
<ConfirmDialog
title={$_('review.undo_all_confirm_title')}
message={$_('review.undo_all_confirm_message')}
confirmLabel={$_('review.undo_all_confirm')}
cancelLabel={$_('review.cancel')}
onconfirm={confirmUndoAll}
oncancel={() => (confirmingUndoAll = false)}
/>
{/if}
<Toast message={toast} />
{#if names.length === 0}
<div class="empty-month">
<h1>{monthName($locale, month)} {year}</h1>
<p>{$_('review.no_records')}</p>
{#if onback}
<ActionButton onclick={onback}>{$_('review.back_to_month')}</ActionButton>
{/if}
</div>
{:else}
<TopBar
{year}
{month}
{issuesAll}
{infosAll}
{onback}
bind:menuOpen
employeeCount={names.length}
currentName={names[emp]}
currentIssues={cur.issues}
currentInfos={cur.infos}
onexportall={() => requestExport('all')}
onexportone={() => requestExport('one')}
/>
<div class="body">
<EmployeeSidebar
employees={visibleEmps}
selected={emp}
bind:query
onselect={(ei) => {
emp = ei;
editingName = false;
}}
/>
<main class="main">
<EmployeeHeader
name={names[emp]}
bind:editing={editingName}
bind:draft
onsave={renameCurrent}
worked={cur.worked}
totalLabel={hm(cur.minutes)}
issues={cur.issues}
infos={cur.infos}
edits={cur.edits}
/>
{#if $error}
<ErrorBanner title={$_('errors.export_title')} message={$error} />
{/if}
<div class="table-card">
<Toolbar
bind:onlyIssues
{year}
{month}
{daysInMonth}
currentName={names[emp]}
stackedCount={cur.stackedDays.length}
lunchCount={cur.lunchDays.length}
roundCount={cur.roundDays.length}
{shift}
onfixall={() => fixDays(cur.stackedDays)}
onaddlunchall={() => addLunch(cur.lunchDays)}
onroundall={() => roundDaysOf(emp, cur.roundDays)}
onapplyall={applyAllCorrections}
onshift={applyShift}
edits={cur.edits}
onundoall={requestUndoAll}
/>
<TimesheetTable
rows={visibleRows}
{rounded}
{emp}
{shift}
oncellinput={onCellInput}
onfixday={(di) => fixDays([di])}
onaddlunch={(di) => addLunch([di])}
onroundday={(di) => roundDaysOf(emp, [di])}
onundoday={undoDay}
/>
</div>
</main>
</div>
{/if}
</div>
<style>
.screen {
position: relative;
height: 100vh;
min-height: 640px;
width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--ox-bg);
color: var(--ox-text);
font-family: var(--ox-font-body);
}
.backdrop {
all: unset;
position: absolute;
inset: 0;
z-index: 20;
cursor: default;
}
.body {
flex: 1;
min-height: 0;
display: flex;
}
.main {
flex: 1;
min-width: 0;
min-height: 0;
box-sizing: border-box;
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.table-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid #262626;
border-radius: 14px;
background: #141414;
}
.empty-month {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
text-align: center;
padding: 24px;
}
.empty-month h1 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 32px;
letter-spacing: -0.025em;
color: var(--ox-text-strong);
}
.empty-month p {
margin: 0;
color: var(--ox-text-muted);
font-size: 15px;
}
@media (max-width: 760px) {
.body {
flex-direction: column;
}
}
</style>
@@ -0,0 +1,19 @@
export interface NewEmployee {
id: number;
name: string;
}
export function isEmpty(name: string): boolean {
return !name.trim();
}
/** True if `name` contains the Unicode replacement character — the device
* sent bytes that aren't valid UTF-8, which the backend lossily converts
* rather than fail the whole fetch over one bad name. */
export function isInvalid(name: string): boolean {
return name.includes('�');
}
export function isEdited(name: string, original: string): boolean {
return !isEmpty(name) && name.trim() !== original;
}
@@ -0,0 +1,163 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { autofocus } from '$lib/autofocus';
import IconButton from '../IconButton.svelte';
let {
name,
editing = $bindable(false),
draft = $bindable(''),
onsave,
worked,
totalLabel,
issues,
infos,
edits
}: {
name: string;
editing?: boolean;
draft?: string;
onsave: (name: string) => void;
worked: number;
totalLabel: string;
issues: number;
infos: number;
edits: number;
} = $props();
function startEdit() {
draft = name;
editing = true;
}
function saveName() {
const n = draft.trim();
if (n && n !== name) onsave(n);
editing = false;
}
</script>
<div class="main-head">
<div class="who">
{#if editing}
<div class="name-row">
<input
class="name-input"
type="text"
aria-label={$_('review.employee_name_aria')}
bind:value={draft}
use:autofocus
onkeydown={(e) => {
if (e.key === 'Enter') saveName();
}}
/>
<IconButton accent ariaLabel={$_('review.save_name')} title={$_('review.save')} onclick={saveName}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
{/snippet}
</IconButton>
<IconButton ariaLabel={$_('review.cancel')} title={$_('review.cancel')} onclick={() => (editing = false)}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M6 6l12 12M18 6L6 18" /></svg
>
{/snippet}
</IconButton>
</div>
{:else}
<div class="name-row">
<h2>{name}</h2>
<IconButton ariaLabel={$_('review.edit_name')} title={$_('review.edit_name')} onclick={startEdit}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
>
{/snippet}
</IconButton>
</div>
{/if}
<div class="stats">
<span><b>{worked}</b> {$_('review.days_worked')}</span>
<span><b>{totalLabel}</b> {$_('review.total_hours')}</span>
<span><b class:red={issues > 0}>{issues}</b> {$_('review.to_check')}</span>
<span><b class:blue={infos > 0}>{infos}</b> {$_('review.to_round')}</span>
<span><b class:orange={edits > 0}>{edits}</b> {$_('review.edited')}</span>
</div>
</div>
</div>
<style>
.main-head {
flex: none;
}
.who {
display: flex;
flex-direction: column;
gap: 6px;
}
.name-row {
display: flex;
align-items: center;
gap: 6px;
min-height: 38px;
}
.name-row h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
}
.name-input {
all: unset;
box-sizing: border-box;
height: 38px;
width: 300px;
max-width: 100%;
padding: 0 10px;
border: 1px solid var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
border-radius: 10px;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
caret-color: var(--ox-accent);
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
font-size: 13px;
color: #8a8a8a;
}
.stats b {
font-family: var(--ox-font-mono);
font-weight: 400;
color: #e6e6e6;
}
/* .stats b's own color above otherwise beats the global .red/.blue/.orange
utility on specificity, since it combines a class with a tag. */
.stats b.red {
color: #ff9ea3;
}
.stats b.blue {
color: #8db8ff;
}
.stats b.orange {
color: #ff9a66;
}
</style>
@@ -0,0 +1,204 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { initials } from './analysis';
let {
employees,
selected,
query = $bindable(''),
onselect
}: {
employees: { name: string; ei: number; issues: number; infos: number }[];
selected: number;
query?: string;
onselect: (ei: number) => void;
} = $props();
</script>
<nav class="side scroll" aria-label={$_('review.employees_nav_aria')}>
<div class="search">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="11" cy="11" r="6.5" /><path d="M20 20l-4-4" /></svg
>
<input
type="search"
placeholder={$_('review.search_employee')}
aria-label={$_('review.search_employee')}
bind:value={query}
/>
</div>
<div class="emp-list">
{#each employees as e (e.ei)}
<button
type="button"
class="emp"
class:on={e.ei === selected}
aria-current={e.ei === selected ? 'true' : undefined}
onclick={() => onselect(e.ei)}
>
<span class="avatar">{initials(e.name)}</span>
<span class="emp-name">{e.name}</span>
{#if e.issues}
<span class="badge red" aria-label={$_('review.days_to_check_aria', { values: { n: e.issues } })}
>{e.issues}</span
>
{:else if e.infos}
<span class="badge blue" aria-label={$_('review.days_to_round_aria', { values: { n: e.infos } })}
>{e.infos}</span
>
{:else}
<svg class="ok" width="16" height="16" viewBox="0 0 24 24" aria-label={$_('review.no_problem_aria')}
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
{/if}
</button>
{/each}
</div>
</nav>
<style>
.side {
width: 260px;
flex: none;
box-sizing: border-box;
padding: 16px 12px;
border-right: 1px solid #222222;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
}
.search {
height: 40px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 1px solid #333333;
border-radius: 10px;
color: #6e6e6e;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.search:focus-within {
border-color: var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
}
.search input {
all: unset;
box-sizing: border-box;
width: 100%;
min-width: 0;
font-size: 14px;
color: #f2f2f2;
caret-color: var(--ox-accent);
}
.search input::placeholder {
color: #5e5e5e;
}
.emp-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.emp {
all: unset;
box-sizing: border-box;
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
font-size: 14px;
color: #bdbdbd;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.emp:hover {
background: #1c1c1c;
color: #ffffff;
}
.emp:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.emp.on {
background: #222222;
color: #ffffff;
font-weight: 500;
}
.avatar {
width: 30px;
height: 30px;
flex: none;
border-radius: 50%;
background: #262626;
color: #c9c9c9;
display: flex;
align-items: center;
justify-content: center;
font-size: 11.5px;
font-weight: 600;
}
.emp-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
min-width: 22px;
height: 22px;
padding: 0 6px;
box-sizing: border-box;
border-radius: 11px;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.badge.red {
background: rgba(229, 72, 77, 0.16);
color: #ff9ea3;
}
.badge.blue {
background: rgba(77, 141, 255, 0.14);
color: #8db8ff;
}
.ok {
color: #4cc38a;
stroke-width: 2.2;
}
@media (max-width: 760px) {
.side {
width: auto;
max-height: 220px;
border-right: none;
border-bottom: 1px solid #222222;
}
}
</style>
@@ -0,0 +1,174 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Shifts } from '$lib/shifts';
import ModalFrame from '../ModalFrame.svelte';
import FixButton from './FixButton.svelte';
import ActionButton from '../ActionButton.svelte';
let {
title,
scopeIssues,
scopeInfos,
shift,
items,
onclose,
onroundandexport,
onexportanyway
}: {
title: string;
scopeIssues: number;
scopeInfos: number;
shift: Shifts;
items: { key: string; label: string; detail: string; red: boolean; go: () => void }[];
onclose: () => void;
onroundandexport: () => void;
onexportanyway: () => void;
} = $props();
</script>
<ModalFrame
titleId="ox-exp-title"
descId="ox-exp-desc"
closeAriaLabel={$_('review.cancel_export_aria')}
{onclose}
>
{#snippet icon()}
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
d="M12 9.5v4M12 17h.01"
/></svg
>
{/snippet}
<div class="modal-text">
<h2 id="ox-exp-title">{title}</h2>
<p id="ox-exp-desc">
{#if scopeIssues}
{$_('review.export_warning_issues')}
{:else}
{$_('review.export_warning_unrounded', { values: { step: shift.round_step_min } })}
{/if}
</p>
</div>
<div class="modal-list scroll">
{#each items as it (it.key)}
<button type="button" class="menu-item row-item" onclick={it.go}>
<span class="mi-label">{it.label}</span>
<span class="mi-detail" class:red={it.red} class:blue={!it.red}>{it.detail}</span>
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" class="mi-chev"
><path d="M9 6l6 6-6 6" /></svg
>
</button>
{/each}
</div>
<div class="modal-actions">
<ActionButton onclick={onclose}>{$_('review.back_to_review')}</ActionButton>
{#if scopeIssues === 0 && scopeInfos > 0}
<FixButton size="tall" blue onclick={onroundandexport}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
>
{/snippet}
{$_('review.round_and_export')}
</FixButton>
{/if}
<ActionButton primary onclick={onexportanyway}>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
>
{/snippet}
{$_('review.export_anyway')}
</ActionButton>
</div>
</ModalFrame>
<style>
.modal-text {
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-text h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 22px;
line-height: 1.2;
letter-spacing: -0.02em;
color: #ffffff;
}
.modal-text p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #a3a3a3;
}
.modal-list {
max-height: 196px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px;
border: 1px solid #2a2a2a;
border-radius: 12px;
background: #151515;
}
.menu-item {
all: unset;
box-sizing: border-box;
width: 100%;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
font-size: 14px;
color: #bdbdbd;
cursor: pointer;
transition: background 0.15s;
}
.menu-item:hover {
background: #252525;
}
.menu-item:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.row-item {
align-items: center;
padding: 8px 10px;
min-height: 40px;
}
.mi-label {
flex: 1;
min-width: 0;
color: #e6e6e6;
font-weight: 500;
}
.mi-detail {
font-size: 12.5px;
white-space: nowrap;
}
.mi-chev {
color: #6e6e6e;
}
.modal-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -0,0 +1,96 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
size = 'sm',
blue = false,
title,
ariaLabel,
onclick,
icon,
children
}: {
size?: 'sm' | 'lg' | 'tall';
blue?: boolean;
title?: string;
ariaLabel?: string;
onclick: () => void;
icon: Snippet;
children: Snippet;
} = $props();
</script>
<button
type="button"
class="fix"
class:lg={size === 'lg'}
class:tall={size === 'tall'}
class:blue
{title}
aria-label={ariaLabel}
{onclick}
>
{@render icon()}
<span>{@render children()}</span>
</button>
<style>
.fix {
all: unset;
box-sizing: border-box;
height: 30px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 8px;
border: 1px solid #5c3320;
color: #ff9a66;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s;
}
.fix:hover {
background: rgba(247, 76, 0, 0.12);
border-color: var(--ox-accent);
color: #ffb08c;
}
.fix:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.fix.lg {
height: 40px;
padding: 0 14px;
font-size: 13px;
border-radius: 10px;
}
.fix.tall {
height: 44px;
border-radius: 12px;
font-size: 14.5px;
}
.fix.blue {
border-color: #27406a;
color: #8db8ff;
}
.fix.blue:hover {
background: rgba(77, 141, 255, 0.12);
border-color: #4d8dff;
color: #b3ceff;
}
.fix.blue:focus-visible {
outline-color: #4d8dff;
}
</style>
@@ -0,0 +1,434 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Shifts } from '$lib/shifts';
import { COLS_KEYS, type Row, cellClass, dayLabel, hm, isRnd, noteOf } from './analysis';
import FixButton from './FixButton.svelte';
let {
rows,
rounded,
emp,
shift,
oncellinput,
onfixday,
onaddlunch,
onroundday,
onundoday
}: {
rows: Row[];
rounded: Record<string, string>;
emp: number;
shift: Shifts;
oncellinput: (di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) => void;
onfixday: (di: number) => void;
onaddlunch: (di: number) => void;
onroundday: (di: number) => void;
onundoday: (di: number) => void;
} = $props();
</script>
<div class="table-box scroll">
<table>
<thead>
<tr>
<th style="width: 92px"></th>
<th class="grp" colspan="2"><span>{$_('review.shift_1')}</span></th>
<th class="grp" colspan="2"><span>{$_('review.shift_2')}</span></th>
<th></th>
<th></th>
</tr>
<tr>
<th class="first center">{$_('review.col_day')}</th>
<th class="center">{$_('review.col_in')}</th>
<th class="center">{$_('review.col_out')}</th>
<th class="center">{$_('review.col_in')}</th>
<th class="center">{$_('review.col_out')}</th>
<th class="center">{$_('review.col_hours')}</th>
<th style="width: 100%">{$_('review.col_notes')}</th>
</tr>
</thead>
<tbody>
{#each rows as r (r.di)}
{@const rnd = r.v.reduce(
(n, c, ci) => n + c.filter((x, ii) => isRnd(rounded, emp, r.di, ci, ii, x)).length,
0
)}
{@const manual = r.rowEdits - rnd}
{@const note = noteOf(r, rnd, manual, $_, shift.round_step_min)}
{@const label = dayLabel(r)}
<tr class:we={r.weekend} class:anom={!!r.issue}>
<td class="first center"><span class="dow">{r.dow}</span><span class="mono">{r.di + 1}</span></td>
{#each r.v as list, ci}
{@const many = list.filter((x) => x !== '').length > 1}
<td class="center">
<div class="slot" class:stack={many} class:gone={r.stacked && r.miss[ci]}>
{#each list as val, ii}
<input
class={cellClass(r, ci, ii, isRnd(rounded, emp, r.di, ci, ii, val))}
type="text"
inputmode="numeric"
maxlength="5"
placeholder="--:--"
value={val}
aria-label={list.length > 1
? $_('review.cell_aria_stacked', {
values: { label, col: $_(COLS_KEYS[ci]), index: ii + 1, total: list.length }
})
: $_('review.cell_aria', { values: { label, col: $_(COLS_KEYS[ci]) } })}
oninput={(e) => oncellinput(r.di, ci, ii, e)}
/>
{/each}
</div>
</td>
{/each}
<td class="center mono" class:dim={!r.mins}>{r.mins ? hm(r.mins) : '—'}</td>
<td class="note {note.kind}">
<span class="note-in">
{#if r.rowEdits > 0}
<button
type="button"
class="undo-btn"
aria-label={$_('review.undo_day_aria', { values: { label } })}
title={$_('review.undo_short')}
onclick={() => onundoday(r.di)}
>
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M9 14l-5-5 5-5" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></svg
>
</button>
{/if}
{#if note.kind === 'issue'}
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
>
{:else if note.kind === 'info'}
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
>
{/if}
<span>{note.text}</span>
{#if r.stacked}
<FixButton ariaLabel={$_('review.fix_shifts_day_aria', { values: { label } })} onclick={() => onfixday(r.di)}>
{#snippet icon()}
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4" /></svg
>
{/snippet}
{$_('review.fix_shifts_short')}
</FixButton>
{/if}
{#if r.lunch}
<FixButton
ariaLabel={$_('review.add_lunch_day_aria', { values: { start: shift.out1, end: shift.in2, label } })}
onclick={() => onaddlunch(r.di)}
>
{#snippet icon()}
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 3.5-3 7h3" /></svg
>
{/snippet}
{$_('review.lunch_short')} <span class="mono">{shift.out1}–{shift.in2}</span>
</FixButton>
{/if}
{#if r.info}
<FixButton blue ariaLabel={$_('review.round_day_aria', { values: { label } })} onclick={() => onroundday(r.di)}>
{#snippet icon()}
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
>
{/snippet}
{$_('review.round_short')}
</FixButton>
{/if}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{#if rows.length === 0}
<div class="empty">
<svg width="28" height="28" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M8 12.5l2.5 2.5L16 9.5" /></svg
>
<span>{$_('review.no_rows')}</span>
</div>
{/if}
</div>
<style>
.table-box {
flex: 1;
min-height: 0;
overflow: auto;
border-radius: 0 0 14px 14px;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 14px;
}
th {
position: sticky;
background: #161616;
font-weight: 500;
color: #8a8a8a;
text-align: left;
white-space: nowrap;
z-index: 2;
}
thead tr:first-child th {
top: 0;
height: 30px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #6e6e6e;
padding: 0 8px;
}
thead tr:last-child th {
top: 30px;
height: 34px;
font-size: 12.5px;
padding: 0 8px;
border-bottom: 1px solid #2a2a2a;
}
th.grp {
text-align: center;
}
th.grp span {
display: block;
border-bottom: 1px solid #2e2e2e;
padding-bottom: 6px;
margin: 0 6px;
}
th.first,
td.first {
padding-left: 16px;
}
.center {
text-align: center;
}
td {
height: 44px;
padding: 5px 8px;
border-bottom: 1px solid #1f1f1f;
white-space: nowrap;
}
td.dim {
color: #4a4a4a;
}
tr:hover td {
background: #171717;
}
tr.we td {
color: #5e5e5e;
}
tr.anom td {
background: rgba(229, 72, 77, 0.05);
}
tr.anom:hover td {
background: rgba(229, 72, 77, 0.08);
}
.dow {
display: inline-block;
width: 34px;
color: #6e6e6e;
font-size: 13px;
}
/* Time cells */
.slot {
display: flex;
flex-direction: column;
gap: 3px;
width: max-content;
margin: 0 auto;
}
.slot.stack {
padding: 3px;
border: 1px dashed #a8383d;
border-radius: 11px;
background: rgba(229, 72, 77, 0.06);
}
.slot.stack .cell {
height: 30px;
color: #ffc2c5;
}
.slot.gone .cell {
border: 1px dashed #a8383d;
background: rgba(229, 72, 77, 0.08);
}
.slot.gone .cell::placeholder {
color: #a8383d;
}
.cell {
all: unset;
box-sizing: border-box;
width: 84px;
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid transparent;
font-family: var(--ox-font-mono);
font-size: 14px;
color: #e6e6e6;
caret-color: var(--ox-accent);
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.cell::placeholder {
color: #3e3e3e;
}
.cell:hover {
border-color: #333333;
}
.cell:focus {
border-color: var(--ox-accent);
box-shadow: 0 0 0 3px var(--ox-accent-ring);
background: #121212;
}
tr.we .cell {
color: #6e6e6e;
}
.cell.edit {
color: #ffb08c;
background: rgba(247, 76, 0, 0.09);
}
.cell.miss {
border: 1px dashed #a8383d;
background: rgba(229, 72, 77, 0.08);
}
.cell.miss::placeholder {
color: #a8383d;
}
.cell.bad {
border-color: #e5484d;
color: #ff8a8f;
}
.cell.unr,
tr.we .cell.unr {
color: #d6e4ff;
border: 1px dashed #3a5f99;
background: transparent;
}
.cell.unr:hover {
border-color: #4d8dff;
}
.cell.unr:focus {
border-style: solid;
border-color: var(--ox-accent);
}
.cell.rnd,
tr.we .cell.rnd {
color: #9cc2ff;
background: rgba(77, 141, 255, 0.12);
}
/* Notes */
td.note {
font-size: 13px;
color: #6e6e6e;
}
td.note.issue {
color: #ff9ea3;
}
td.note.info,
td.note.round {
color: #8db8ff;
}
td.note.edit {
color: #ff9a66;
}
.note-in {
display: inline-flex;
align-items: center;
gap: 8px;
}
.undo-btn {
all: unset;
box-sizing: border-box;
width: 22px;
height: 22px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: #6e6e6e;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.undo-btn:hover {
background: #262626;
color: #e6e6e6;
}
.undo-btn:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 1px;
}
.empty {
padding: 48px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
color: #8a8a8a;
font-size: 14px;
}
.empty svg {
color: #4cc38a;
}
</style>
@@ -0,0 +1,33 @@
<script lang="ts">
let { message }: { message: string } = $props();
</script>
{#if message}
<div class="toast" role="status">
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M8 12.5l2.5 2.5L16 9.5" /></svg
>
<span>{message}</span>
</div>
{/if}
<style>
.toast {
position: absolute;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 45;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 12px;
background: #13221a;
border: 1px solid #1f4a31;
color: #8be0ae;
font-size: 14px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
white-space: nowrap;
}
</style>
@@ -0,0 +1,778 @@
<script lang="ts">
import { untrack } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { weekdayName } from '$lib/month';
import type { Shifts } from '$lib/shifts';
import { shiftT, signedHm } from './analysis';
import ActionButton from '../ActionButton.svelte';
import IconButton from '../IconButton.svelte';
let {
onlyIssues = $bindable(false),
year,
month,
daysInMonth,
currentName,
stackedCount,
lunchCount,
roundCount,
shift,
onfixall,
onaddlunchall,
onroundall,
onapplyall,
onshift,
edits,
onundoall
}: {
onlyIssues?: boolean;
year: number;
month: number;
daysInMonth: number;
currentName: string;
stackedCount: number;
lunchCount: number;
roundCount: number;
shift: Shifts;
onfixall: () => void;
onaddlunchall: () => void;
onroundall: () => void;
onapplyall: () => void;
onshift: (opts: { minutes: number; fromDay: number; toDay: number; scope: 'all' | 'one' }) => void;
edits: number;
onundoall: () => void;
} = $props();
// Unique per instance, mainly so the shift panel's aria-labelledby never
// risks clashing with anything else on the page.
const uid = $props.id();
let fixMenu = $state(false);
let shiftOpen = $state(false);
const toolPopOpen = $derived(fixMenu || shiftOpen);
function closeToolPops() {
fixMenu = false;
shiftOpen = false;
}
const fixTotal = $derived(stackedCount + lunchCount + roundCount);
const firstName = $derived(currentName.split(' ')[0]);
// ---- "Shift times" panel state ----
let shiftMin = $state(-60);
let shiftFrom = $state(1);
let shiftTo = $state(untrack(() => daysInMonth));
let shiftScope = $state<'all' | 'one'>('all');
const dayOptions = $derived(
Array.from({ length: daysInMonth }, (_v, i) => ({
v: i + 1,
label: `${weekdayName($locale, new Date(year, month - 1, i + 1).getDay())} ${i + 1}`
}))
);
function decShiftMin() {
shiftMin = Math.max(-720, shiftMin - shift.round_step_min);
}
function incShiftMin() {
shiftMin = Math.min(720, shiftMin + shift.round_step_min);
}
function applyShift() {
if (!shiftMin) return;
onshift({
minutes: shiftMin,
fromDay: Math.min(shiftFrom, shiftTo),
toDay: Math.max(shiftFrom, shiftTo),
scope: shiftScope
});
shiftOpen = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && toolPopOpen) closeToolPops();
}
</script>
<svelte:window onkeydown={onKey} />
{#if toolPopOpen}
<button type="button" class="pop-backdrop" aria-label={$_('review.close_menu_aria')} onclick={closeToolPops}
></button>
{/if}
<div class="toolbar">
<div class="seg" role="group" aria-label={$_('review.day_filter_aria')}>
<button type="button" class:on={!onlyIssues} aria-pressed={!onlyIssues} onclick={() => (onlyIssues = false)}
>{$_('review.all_days')}</button
>
<button type="button" class:on={onlyIssues} aria-pressed={onlyIssues} onclick={() => (onlyIssues = true)}
>{$_('review.to_check_cap')}</button
>
</div>
<div class="tb-actions">
<div class="anchor">
<button
type="button"
class="tool"
class:open={shiftOpen}
aria-haspopup="dialog"
aria-expanded={shiftOpen}
onclick={() => {
shiftOpen = !shiftOpen;
fixMenu = false;
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><circle cx="12" cy="12" r="8.5" /><path d="M12 7.5V12l3 2" /></svg
>
<span>{$_('review.shift_times')}</span>
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg
>
</button>
{#if shiftOpen}
<div class="pop shift-pop" role="dialog" aria-labelledby="{uid}-shift-title">
<div class="pop-head">
<span id="{uid}-shift-title" class="pop-title">{$_('review.shift_panel_title')}</span>
<span class="pop-desc">{$_('review.shift_panel_desc')}</span>
</div>
<div class="pop-group">
<span class="pop-label">{$_('review.shift_amount_label')}</span>
<div class="stepper">
<button type="button" class="step-btn" aria-label={$_('review.shift_minus_aria')} onclick={decShiftMin}>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.2"
><path d="M5 12h14" /></svg
>
</button>
<div class="step-value mono" class:zero={shiftMin === 0}>{signedHm(shiftMin)}</div>
<button type="button" class="step-btn" aria-label={$_('review.shift_plus_aria')} onclick={incShiftMin}>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.2"
><path d="M12 5v14M5 12h14" /></svg
>
</button>
</div>
<div class="chips">
<button type="button" class="chip" class:on={shiftMin === -60} onclick={() => (shiftMin = -60)}
>{$_('review.shift_minus_hour')}</button
>
<button type="button" class="chip" class:on={shiftMin === 60} onclick={() => (shiftMin = 60)}
>{$_('review.shift_plus_hour')}</button
>
</div>
</div>
<div class="pop-group">
<span class="pop-label">{$_('review.shift_days_label')}</span>
<div class="range">
<label for="{uid}-shift-from">{$_('review.shift_from')}</label>
<div class="mini-select">
<select id="{uid}-shift-from" bind:value={shiftFrom}>
{#each dayOptions as o (o.v)}<option value={o.v}>{o.label}</option>{/each}
</select>
<svg class="chev" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg
>
</div>
<label for="{uid}-shift-to">{$_('review.shift_to')}</label>
<div class="mini-select">
<select id="{uid}-shift-to" bind:value={shiftTo}>
{#each dayOptions as o (o.v)}<option value={o.v}>{o.label}</option>{/each}
</select>
<svg class="chev" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg
>
</div>
</div>
</div>
<div class="pop-group">
<span class="pop-label">{$_('review.shift_who_label')}</span>
<div class="seg full" role="group" aria-label={$_('review.shift_who_label')}>
<button type="button" class:on={shiftScope === 'all'} aria-pressed={shiftScope === 'all'}
onclick={() => (shiftScope = 'all')}>{$_('review.shift_scope_all')}</button
>
<button type="button" class:on={shiftScope === 'one'} aria-pressed={shiftScope === 'one'}
onclick={() => (shiftScope = 'one')}
>{$_('review.only_name', { values: { name: firstName } })}</button
>
</div>
</div>
<div class="preview">
{@html $_('review.shift_preview', { values: { from: '08:02', to: shiftT('08:02', shiftMin) } })}
</div>
<div class="pop-actions">
<ActionButton onclick={() => (shiftOpen = false)}>{$_('review.cancel')}</ActionButton>
<ActionButton primary disabled={shiftMin === 0} onclick={applyShift}>{$_('review.apply')}</ActionButton>
</div>
</div>
{/if}
</div>
<div class="anchor">
<button
type="button"
class="tool"
class:open={fixMenu}
aria-haspopup="menu"
aria-expanded={fixMenu}
onclick={() => {
fixMenu = !fixMenu;
shiftOpen = false;
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path
d="M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8l1.4 1.4M17.8 6.2l1.4-1.4M12.2 6.2l-1.4-1.4M3 21l9-9"
/></svg
>
<span>{$_('review.corrections')}</span>
{#if fixTotal}<span class="tool-badge">{fixTotal}</span>{/if}
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg
>
</button>
{#if fixMenu}
<div class="pop fix-pop" role="menu" aria-label={$_('review.corrections')}>
<button
type="button"
role="menuitem"
class="menu-item"
disabled={!stackedCount}
onclick={() => {
onfixall();
fixMenu = false;
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon orange"
><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4" /></svg
>
<span class="mi-col grow">
<span class="mi-title">{$_('review.fix_shifts_short')}</span>
<span class="mi-sub">{$_('review.fix_shifts_title')}</span>
</span>
<span class="count">{stackedCount}</span>
</button>
<button
type="button"
role="menuitem"
class="menu-item"
disabled={!lunchCount}
onclick={() => {
onaddlunchall();
fixMenu = false;
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon orange"
><path d="M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 3.5-3 7h3" /></svg
>
<span class="mi-col grow">
<span class="mi-title">{$_('review.add_lunch_action')}</span>
<span class="mi-sub"
>{$_('review.add_lunch_title', { values: { start: shift.out1, end: shift.in2 } })}</span
>
</span>
<span class="count">{lunchCount}</span>
</button>
<button
type="button"
role="menuitem"
class="menu-item"
disabled={!roundCount}
onclick={() => {
onroundall();
fixMenu = false;
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon blue"
><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M10 2h4" /></svg
>
<span class="mi-col grow">
<span class="mi-title">{$_('review.round_action', { values: { step: shift.round_step_min } })}</span>
<span class="mi-sub">{$_('review.round_title')}</span>
</span>
<span class="count">{roundCount}</span>
</button>
<div class="pop-sep"></div>
<button
type="button"
role="menuitem"
class="menu-item center"
disabled={!fixTotal}
onclick={() => {
onapplyall();
fixMenu = false;
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" class="mi-icon accent" style="stroke-width: 2.2"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
<span class="grow apply-all">{$_('review.apply_all_corrections')}</span>
</button>
</div>
{/if}
</div>
{#if edits}
<span class="tb-sep" aria-hidden="true"></span>
<IconButton
size="lg"
dangerFilled
ariaLabel={$_('review.undo_all')}
title={$_('review.undo_all_title')}
onclick={onundoall}
>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M9 14l-5-5 5-5" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></svg
>
{/snippet}
</IconButton>
{/if}
</div>
</div>
<style>
.pop-backdrop {
all: unset;
position: fixed;
inset: 0;
z-index: 25;
cursor: default;
}
.toolbar {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #262626;
}
.seg {
display: flex;
gap: 2px;
padding: 3px;
border: 1px solid #2a2a2a;
border-radius: 10px;
}
.seg button {
all: unset;
box-sizing: border-box;
height: 32px;
padding: 0 12px;
display: flex;
align-items: center;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
cursor: pointer;
}
.seg button:hover {
color: #ffffff;
}
.seg button:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.seg button.on {
background: #2a2a2a;
color: #ffffff;
}
.seg.full {
width: 100%;
}
.seg.full button {
flex: 1;
justify-content: center;
}
.tb-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.anchor {
position: relative;
}
.tool {
all: unset;
box-sizing: border-box;
height: 36px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 7px;
border-radius: 9px;
border: 1px solid #333333;
color: #c9c9c9;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.tool:hover,
.tool.open {
background: #1e1e1e;
color: #ffffff;
border-color: #4a4a4a;
}
.tool:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.tool .chev {
color: #6e6e6e;
}
.tool-badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
box-sizing: border-box;
border-radius: 9px;
background: rgba(247, 76, 0, 0.18);
color: #ffb08c;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.tb-sep {
width: 1px;
height: 24px;
background: #2a2a2a;
}
/* Popovers */
.pop {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
box-sizing: border-box;
padding: 14px;
background: #1a1a1a;
border: 1px solid #333333;
border-radius: 16px;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
gap: 14px;
}
.shift-pop {
width: 320px;
}
.fix-pop {
width: 320px;
padding: 6px;
gap: 2px;
}
.pop-head {
display: flex;
flex-direction: column;
gap: 4px;
}
.pop-title {
font-size: 14px;
font-weight: 600;
color: #f2f2f2;
}
.pop-desc {
font-size: 12.5px;
line-height: 1.4;
color: #8a8a8a;
}
.pop-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.pop-label {
font-size: 12px;
font-weight: 500;
color: #8a8a8a;
}
.stepper {
display: flex;
align-items: stretch;
gap: 8px;
}
.step-btn {
all: unset;
box-sizing: border-box;
width: 44px;
height: 44px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #333333;
border-radius: 12px;
color: #c9c9c9;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.step-btn:hover {
background: #1e1e1e;
border-color: #4a4a4a;
color: #ffffff;
}
.step-btn:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.step-value {
flex: 1;
min-width: 0;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #2a2a2a;
border-radius: 12px;
background: #161616;
font-size: 16px;
font-weight: 600;
color: #f2f2f2;
}
.step-value.zero {
color: #6e6e6e;
}
.chips {
display: flex;
gap: 6px;
}
.chip {
all: unset;
box-sizing: border-box;
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
border-radius: 8px;
border: 1px solid #2a2a2a;
font-size: 12.5px;
color: #a3a3a3;
cursor: pointer;
}
.chip:hover {
border-color: #4a4a4a;
color: #ffffff;
}
.chip.on {
background: rgba(247, 76, 0, 0.14);
border-color: var(--ox-accent);
color: #ffb08c;
}
.range {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.range label {
font-size: 12.5px;
color: #a3a3a3;
}
.mini-select {
position: relative;
display: inline-flex;
}
.mini-select select {
all: unset;
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
display: flex;
align-items: center;
height: 36px;
padding: 0 26px 0 12px;
border: 1px solid #2a2a2a;
border-radius: 9px;
font-size: 13px;
font-weight: 500;
color: #e6e6e6;
background: #1b1b1b;
cursor: pointer;
}
.mini-select select:hover {
border-color: #4a4a4a;
}
.mini-select select:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 1px;
}
.mini-select .chev {
position: absolute;
top: 50%;
right: 9px;
transform: translateY(-50%);
color: #6e6e6e;
pointer-events: none;
}
.preview {
padding: 10px;
border-radius: 10px;
background: #151515;
font-size: 13px;
color: #c9c9c9;
text-align: center;
}
.preview :global(b) {
color: #ffb08c;
font-weight: 600;
}
.pop-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Corrections menu */
.menu-item {
all: unset;
box-sizing: border-box;
width: 100%;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 10px;
border-radius: 10px;
font-size: 13.5px;
color: #bdbdbd;
cursor: pointer;
transition: background 0.15s;
}
.menu-item:hover:not(:disabled) {
background: #252525;
}
.menu-item:disabled {
opacity: 0.4;
cursor: default;
}
.menu-item:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.menu-item.center {
align-items: center;
justify-content: center;
text-align: center;
}
.mi-icon {
margin-top: 1px;
flex: none;
}
.mi-icon.orange {
color: #ff9a66;
}
.mi-icon.blue {
color: #8db8ff;
}
.mi-icon.accent {
color: var(--ox-accent);
}
.mi-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.grow {
flex: 1;
min-width: 0;
}
.mi-title {
font-weight: 500;
color: #e6e6e6;
}
.mi-sub {
font-size: 11.5px;
color: #7a7a7a;
}
.count {
flex: none;
font-family: var(--ox-font-mono);
font-size: 12.5px;
color: #6e6e6e;
}
.pop-sep {
height: 1px;
margin: 4px 6px;
background: #2a2a2a;
}
.apply-all {
font-weight: 600;
color: var(--ox-accent);
}
</style>
@@ -0,0 +1,314 @@
<script lang="ts">
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import { dayWord, shortSummary } from './analysis';
import IconButton from '../IconButton.svelte';
let {
year,
month,
issuesAll,
infosAll,
onback,
menuOpen = $bindable(false),
employeeCount,
currentName,
currentIssues,
currentInfos,
onexportall,
onexportone
}: {
year: number;
month: number;
issuesAll: number;
infosAll: number;
onback?: () => void;
menuOpen?: boolean;
employeeCount: number;
currentName: string;
currentIssues: number;
currentInfos: number;
onexportall: () => void;
onexportone: () => void;
} = $props();
</script>
<header class="topbar">
<div class="tb-left">
{#if onback}
<IconButton size="lg" ariaLabel={$_('review.back_to_month')} onclick={onback}>
{#snippet icon()}
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2"
><path d="M15 6l-6 6 6 6" /></svg
>
{/snippet}
</IconButton>
{/if}
<div class="tb-title">
<span>{$_('review.title')}</span>
<h1>{monthName($locale, month)} {year}</h1>
</div>
</div>
<div class="tb-right">
{#if issuesAll}
<span class="tb-count red">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><path d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path
d="M12 9.5v4M12 17h.01"
/></svg
>
{dayWord(issuesAll, $_)} {$_('review.to_check')}
</span>
{/if}
{#if infosAll}
<span class="tb-count blue">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
>
{dayWord(infosAll, $_)} {$_('review.to_round')}
</span>
{/if}
<!-- Split export button -->
<div class="split-wrap">
<div class="split">
<button type="button" class="split-main" onclick={onexportall}>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg
>
<span>{$_('review.export')}</span>
</button>
<span class="split-sep" aria-hidden="true"></span>
<button
type="button"
class="split-arrow"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={$_('review.export_options_aria')}
onclick={() => (menuOpen = !menuOpen)}
>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
</button>
</div>
{#if menuOpen}
<div class="dropdown" role="menu" aria-label={$_('review.export')}>
<button type="button" role="menuitem" class="menu-item" onclick={onexportall}>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c.8-3.3 3.3-5 6.5-5s5.7 1.7 6.5 5" /><path
d="M16 4.5a3.5 3.5 0 0 1 0 7M18 15.2c1.8.7 3 2.3 3.5 4.8"
/></svg
>
<span class="mi-col">
<span class="mi-title">{$_('review.all_employees')}</span>
<span class="mi-sub" class:red={issuesAll > 0} class:blue={!issuesAll && infosAll > 0}>
{$_('review.employee_count', { values: { n: employeeCount } })} ·
{shortSummary(issuesAll, infosAll, $_) || $_('review.all_good')}
</span>
</span>
</button>
<button type="button" role="menuitem" class="menu-item" onclick={onexportone}>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="8" r="3.5" /><path d="M5 20c.8-3.3 3.6-5 7-5s6.2 1.7 7 5" /></svg
>
<span class="mi-col">
<span class="mi-title">{$_('review.only_name', { values: { name: currentName } })}</span>
<span class="mi-sub" class:red={currentIssues > 0} class:blue={!currentIssues && currentInfos > 0}>
{shortSummary(currentIssues, currentInfos, $_) || $_('review.all_good_cap')}
</span>
</span>
</button>
</div>
{/if}
</div>
</div>
</header>
<style>
.topbar {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid #222222;
}
.tb-left {
display: flex;
align-items: center;
gap: 14px;
}
.tb-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.tb-title span {
font-size: 12.5px;
color: #8a8a8a;
}
.tb-title h1 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 26px;
line-height: 1.1;
letter-spacing: -0.025em;
color: var(--ox-text-strong);
}
.tb-right {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.tb-count {
display: flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
}
/* Split button */
.split-wrap {
position: relative;
z-index: 21;
}
.split {
display: flex;
align-items: stretch;
height: 44px;
border-radius: 12px;
background: var(--ox-accent);
color: var(--ox-on-accent);
}
.split-main,
.split-arrow {
all: unset;
box-sizing: border-box;
display: flex;
align-items: center;
font-size: 14.5px;
font-weight: 600;
color: var(--ox-on-accent);
cursor: pointer;
transition: background 0.15s;
}
.split-main {
gap: 10px;
padding: 0 16px 0 18px;
border-radius: 12px 0 0 12px;
}
.split-arrow {
width: 40px;
justify-content: center;
border-radius: 0 12px 12px 0;
}
.split-arrow svg {
stroke-width: 2.2;
}
.split-main:hover,
.split-arrow:hover {
background: rgba(255, 255, 255, 0.12);
}
.split-main:active,
.split-arrow:active {
background: rgba(0, 0, 0, 0.08);
}
.split-main:focus-visible,
.split-arrow:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.split-sep {
width: 1px;
margin: 10px 0;
background: rgba(26, 7, 0, 0.28);
}
.dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 300px;
box-sizing: border-box;
padding: 6px;
background: #1a1a1a;
border: 1px solid #333333;
border-radius: 14px;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
gap: 2px;
}
.menu-item {
all: unset;
box-sizing: border-box;
width: 100%;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
font-size: 14px;
color: #bdbdbd;
cursor: pointer;
transition: background 0.15s;
}
.menu-item:hover {
background: #252525;
}
.menu-item:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: -2px;
}
.menu-item > svg {
margin-top: 1px;
}
.mi-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.mi-title {
font-weight: 500;
color: #f2f2f2;
}
.mi-sub {
font-size: 12.5px;
color: #8a8a8a;
}
.mi-sub.red {
color: #ff9ea3;
}
.mi-sub.blue {
color: #8db8ff;
}
</style>
@@ -0,0 +1,299 @@
// Pure logic for analyzing a month's attendance records, extracted from
// the review component to keep it readable. Translated strings are
// produced by passing the `t` function (usually svelte-i18n's `$_`) and,
// where Intl needs the language, `locale` ($locale).
import { weekdayName } from '$lib/month';
import type { Shifts } from '$lib/shifts';
import { initials } from '$lib/text';
import { fmt, formatTyped, hm, parseT } from '$lib/time';
export { formatTyped, hm, initials, parseT };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches svelte-i18n's own loosely-typed `values`
export type Translate = (key: string, options?: { values?: Record<string, any> }) => string;
/** [In1[], Out1[], In2[], Out2[]] — each slot is a list of "HH:MM" times,
* to also represent stacked punches. */
export type Day = [string[], string[], string[], string[]];
export interface Employee {
id: number;
name: string;
days: Day[];
}
export interface Row {
di: number;
v: Day;
dow: string;
weekend: boolean;
issue: string;
info: boolean;
miss: [boolean, boolean, boolean, boolean];
bad: boolean[][];
unr: boolean[][];
changed: boolean[][];
mins: number;
empty: boolean;
rowEdits: number;
stacked: boolean;
wasStacked: boolean;
lunch: boolean;
wasLunch: boolean;
}
export interface Analysis {
rows: Row[];
minutes: number;
worked: number;
issues: number;
infos: number;
edits: number;
stackedDays: number[];
lunchDays: number[];
roundDays: number[];
}
export const COLS_KEYS = ['review.col_in_1', 'review.col_out_1', 'review.col_in_2', 'review.col_out_2'];
export function normDay(day: Day): Day {
return [0, 1, 2, 3].map((i) => {
const c = (day?.[i] ?? []).filter((x) => x !== '');
return c.length ? c.slice() : [''];
}) as Day;
}
export function isRounded(t: string, roundStep: number): boolean {
const m = parseT(t);
return m === null || m % roundStep === 0;
}
export function roundT(t: string, roundStep: number): string {
const m = parseT(t);
if (m === null) return t;
return fmt(Math.min(Math.round(m / roundStep) * roundStep, 24 * 60 - roundStep));
}
/** Adds `delta` minutes to `t`, clamped to the same day (00:00-23:59). */
export function shiftT(t: string, delta: number): string {
const m = parseT(t);
if (m === null) return t;
return fmt(Math.max(0, Math.min(24 * 60 - 1, m + delta)));
}
/** Formats a signed minute offset as e.g. "+1:30", "−0:45", "0:00". */
export function signedHm(m: number): string {
const sign = m > 0 ? '+' : m < 0 ? '−' : '';
const abs = Math.abs(m);
return sign + Math.floor(abs / 60) + ':' + String(abs % 60).padStart(2, '0');
}
export function dayWord(n: number, t: Translate): string {
return t('review.day_word', { values: { n } });
}
/** [in, out] if the day has exactly 2 punches spanning the lunch break. */
export function lunchSpan(day: Day, shift: Shifts): [string, string] | null {
const all = day.flat().filter((x) => x !== '');
if (all.length !== 2) return null;
const t = all.map(parseT);
if (t.some((x) => x === null)) return null;
const [a, b] = (t[0] as number) <= (t[1] as number) ? [all[0], all[1]] : [all[1], all[0]];
return (parseT(a) as number) < (parseT(shift.out1) as number) &&
(parseT(b) as number) > (parseT(shift.in2) as number)
? [a, b]
: null;
}
/**
* Redistributes punches that all ended up in the same shift (e.g. from a
* misconfigured time clock) into the correct 4 slots, picking for each one
* the closest expected shift time (E1/U1/E2/U2). Since both the punches and
* the expected shifts are sorted, it's enough to compare each punch against
* the midpoint between two consecutive shifts: that's exactly the boundary
* between "closer to the shift before" and "closer to the shift after".
*/
export function assignToShifts(times: string[], shift: Shifts): Day {
const targets = [shift.in1, shift.out1, shift.in2, shift.out2].map((x) => parseT(x) as number);
const bounds = [(targets[0] + targets[1]) / 2, (targets[1] + targets[2]) / 2, (targets[2] + targets[3]) / 2];
const day: Day = [[], [], [], []];
const sorted = [...times].sort((a, b) => (parseT(a) ?? Infinity) - (parseT(b) ?? Infinity));
for (const time of sorted) {
const m = parseT(time);
const slot = m === null ? 3 : m < bounds[0] ? 0 : m < bounds[1] ? 1 : m < bounds[2] ? 2 : 3;
day[slot].push(time);
}
for (let i = 0; i < 4; i++) {
if (day[i].length === 0) day[i] = [''];
}
return day;
}
export function analyze(
year: number,
month: number,
values: Day[],
orig: Day[],
locale: string | null | undefined,
t: Translate,
shift: Shifts
): Analysis {
const rows: Row[] = [];
let minutes = 0;
let worked = 0;
let issues = 0;
let infos = 0;
let edits = 0;
const stackedDays: number[] = [];
const lunchDays: number[] = [];
const roundDays: number[] = [];
for (let di = 0; di < values.length; di++) {
const v = values[di];
const o = orig[di];
const dow = new Date(year, month - 1, di + 1).getDay();
const weekend = dow === 0 || dow === 6;
const lists = v.map((c) => c.filter((x) => x !== ''));
const filled = lists.map((c) => c.length > 0);
const stacked = lists.some((c) => c.length > 1);
const bad = v.map((c) => c.map((x) => x !== '' && parseT(x) === null));
const miss: [boolean, boolean, boolean, boolean] = [false, false, false, false];
let issue = '';
// 1. Wrong shift
if (stacked) {
const total = lists.reduce((n, c) => n + c.length, 0);
const shiftLabel = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2');
issue = t('review.issue_stacked', { values: { total, shift: shiftLabel } });
[0, 1, 2, 3].forEach((i) => {
if (!filled[i]) miss[i] = true;
});
}
// 2. Invalid time
if (!issue && bad.some((c) => c.some(Boolean))) issue = t('review.issue_invalid_time');
// 3. Unpunched lunch break
const lunch = !stacked && !issue && lunchSpan(v, shift) !== null;
if (lunch) {
issue = t('review.issue_lunch_missing');
[0, 1, 2, 3].forEach((i) => {
if (!filled[i]) miss[i] = true;
});
}
// 4-5. Incomplete pairs / clock-out before clock-in
if (!stacked && !lunch) {
const p = lists.map((c) => (c.length ? parseT(c[0]) : null));
for (const [a, b, shiftKey] of [
[0, 1, 'review.shift_1'],
[2, 3, 'review.shift_2']
] as const) {
if (filled[a] !== filled[b]) {
miss[filled[a] ? b : a] = true;
if (!issue) {
issue = t(filled[a] ? 'review.issue_missing_out' : 'review.issue_missing_in', {
values: { shift: t(shiftKey) }
});
}
} else if (filled[a] && p[a] !== null && p[b] !== null && (p[b] as number) <= (p[a] as number) && !issue) {
issue = t('review.issue_out_before_in', { values: { shift: t(shiftKey) } });
}
}
}
// Hours: pairs by position within each shift
let mins = 0;
for (const [a, b] of [
[0, 1],
[2, 3]
] as const) {
const n = Math.min(lists[a].length, lists[b].length);
for (let k = 0; k < n; k++) {
const s = parseT(lists[a][k]);
const e = parseT(lists[b][k]);
if (s !== null && e !== null && e > s) mins += e - s;
}
}
const empty = !filled.some(Boolean);
const changed = v.map((c, ci) => c.map((x, ii) => x !== (o[ci][ii] ?? '')));
const rowEdits = changed.reduce((n, c) => n + c.filter(Boolean).length, 0);
const wasStacked = o.some((c) => c.length > 1);
const wasLunch = !lunch && lunchSpan(o, shift) !== null && v[1][0] === shift.out1 && v[2][0] === shift.in2;
// 6. Info: unrounded times
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x, shift.round_step_min)));
const hasUnr = unr.some((c) => c.some(Boolean));
const info = !issue && hasUnr;
if (issue) issues++;
if (info) infos++;
if (hasUnr) roundDays.push(di);
if (stacked) stackedDays.push(di);
if (lunch) lunchDays.push(di);
if (!empty) worked++;
minutes += mins;
edits += rowEdits;
rows.push({
di, v, dow: weekdayName(locale, dow), weekend, issue, info, miss, bad, unr, changed,
mins, empty, rowEdits, stacked, wasStacked, lunch, wasLunch
});
}
return { rows, minutes, worked, issues, infos, edits, stackedDays, lunchDays, roundDays };
}
export function dayLabel(r: Row): string {
return `${r.dow} ${r.di + 1}`;
}
/** "employee-day-slot-index" -> is the current time the result of a rounding? */
export function isRnd(
rounded: Record<string, string>,
emp: number,
di: number,
ci: number,
ii: number,
val: string
): boolean {
return val !== '' && rounded[`${emp}-${di}-${ci}-${ii}`] === val;
}
export function noteOf(
r: Row,
roundedCount: number,
manualCount: number,
t: Translate,
roundStep: number
): { text: string; kind: string } {
if (r.issue) return { text: r.issue, kind: 'issue' };
if (r.info) return { text: t('review.info_unrounded', { values: { step: roundStep } }), kind: 'info' };
if (r.empty && !r.weekend) return { text: t('review.note_empty'), kind: 'muted' };
if (r.wasLunch) return { text: t('review.note_lunch_added'), kind: 'edit' };
if (r.wasStacked) return { text: t('review.note_shifts_fixed'), kind: 'edit' };
if (manualCount > 0) return { text: t('review.note_edited', { values: { n: manualCount } }), kind: 'edit' };
if (roundedCount > 0) return { text: t('review.note_rounded', { values: { n: roundedCount } }), kind: 'round' };
return { text: '', kind: 'muted' };
}
export function cellClass(r: Row, ci: number, ii: number, rnd: boolean): string {
let c = 'cell';
if (r.bad[ci][ii]) c += ' bad';
else if (!r.stacked && r.miss[ci]) c += ' miss';
if (r.unr[ci][ii] && !r.bad[ci][ii]) c += ' unr';
else if (rnd) c += ' rnd';
else if (r.changed[ci][ii] && !r.bad[ci][ii]) c += ' edit';
return c;
}
export function summary(iss: number, inf: number, t: Translate): string {
return [iss ? `${dayWord(iss, t)} ${t('review.to_check')}` : '', inf ? `${dayWord(inf, t)} ${t('review.to_round')}` : '']
.filter(Boolean)
.join(' · ');
}
export function shortSummary(iss: number, inf: number, t: Translate): string {
return [iss ? `${iss} ${t('review.to_check')}` : '', inf ? `${inf} ${t('review.to_round')}` : '']
.filter(Boolean)
.join(' · ');
}
@@ -0,0 +1,55 @@
import type { Day, Employee } from './analysis';
type Clock = 'FirstIn' | 'FirstOut' | 'SecondIn' | 'SecondOut';
/** A record as returned by the `load_records` Tauri command. */
export interface AttendanceRecord {
employee_id: number;
clock: Clock;
datetime: string;
}
const CLOCK_INDEX: Record<Clock, number> = {
FirstIn: 0,
FirstOut: 1,
SecondIn: 2,
SecondOut: 3
};
function timeOf(datetime: string): string {
const d = new Date(datetime);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/**
* Groups records by employee and day into the Day shape expected by
* TimesheetReview. Only employees with at least one record this month are
* included — `names` is just used to resolve a display name, not to decide
* who shows up (it may hold every employee ever cached, across all months).
*/
export function buildEmployees(
year: number,
month: number,
records: AttendanceRecord[],
names: Record<string, string>
): Employee[] {
const daysInMonth = new Date(year, month, 0).getDate();
const byEmployee = new Map<number, Day[]>();
for (const record of records) {
let days = byEmployee.get(record.employee_id);
if (!days) {
days = Array.from({ length: daysInMonth }, () => [[], [], [], []] as Day);
byEmployee.set(record.employee_id, days);
}
const date = new Date(record.datetime);
const day = days[date.getDate() - 1];
day[CLOCK_INDEX[record.clock]].push(timeOf(record.datetime));
}
return [...byEmployee.entries()].map(([id, days]) => ({
id,
name: names[String(id)] ?? String(id),
days
}));
}
@@ -0,0 +1,407 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { autofocus } from '$lib/autofocus';
import { initials } from '$lib/text';
import IconButton from '../IconButton.svelte';
let {
employees,
total,
query = $bindable(''),
onclose,
onrename,
ondelete
}: {
employees: { id: string; name: string }[];
total: number;
query?: string;
onclose: () => void;
onrename: (id: string, name: string) => void;
ondelete: (id: string) => void;
} = $props();
let editId = $state<string | null>(null);
let draft = $state('');
let delId = $state<string | null>(null);
function startEdit(id: string, name: string) {
editId = id;
draft = name;
delId = null;
}
function saveEdit() {
const id = editId;
const name = draft.trim();
editId = null;
if (!id || !name) return;
onrename(id, name);
}
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (editId) editId = null;
else if (delId) delId = null;
else onclose();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="drawer-wrap">
<button type="button" class="backdrop" aria-label={$_('settings.close_aria')} onclick={onclose}></button>
<div class="drawer" role="dialog" aria-modal="true" aria-labelledby="employees-title">
<div class="drawer-head">
<div>
<h2 id="employees-title">{$_('settings.employees_title')}</h2>
<span class="drawer-sub">{$_('settings.employees_count', { values: { n: total } })}</span>
</div>
<IconButton ariaLabel={$_('settings.close_aria')} onclick={onclose}>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
{/snippet}
</IconButton>
</div>
<div class="drawer-tools">
<div class="search">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="11" cy="11" r="6.5" /><path d="M20 20l-4-4" /></svg
>
<input
type="search"
placeholder={$_('review.search_employee')}
aria-label={$_('review.search_employee')}
bind:value={query}
/>
</div>
</div>
<div class="list scroll">
{#each employees as emp (emp.id)}
<div class="row" class:del={delId === emp.id}>
<span class="avatar">{initials(emp.name)}</span>
{#if editId === emp.id}
<input
class="name-in"
type="text"
aria-label={$_('settings.employee_name_aria', { values: { id: emp.id } })}
bind:value={draft}
use:autofocus
onkeydown={(e) => {
if (e.key === 'Enter') saveEdit();
}}
/>
<IconButton accent ariaLabel={$_('review.save_name')} onclick={saveEdit}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg
>
{/snippet}
</IconButton>
<IconButton ariaLabel={$_('settings.cancel')} onclick={() => (editId = null)}>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><path d="M6 6l12 12M18 6L6 18" /></svg
>
{/snippet}
</IconButton>
{:else if delId === emp.id}
<span class="del-text"
>{$_('settings.employee_remove_confirm', { values: { name: emp.name } })}</span
>
<button type="button" class="ghost" onclick={() => (delId = null)}>{$_('settings.cancel')}</button>
<button type="button" class="danger" onclick={() => ondelete(emp.id)}>{$_('settings.remove')}</button>
{:else}
<div class="info">
<span class="name">{emp.name}</span>
<span class="uid mono">#{emp.id}</span>
</div>
<IconButton
ariaLabel={$_('settings.edit_name_aria', { values: { name: emp.name } })}
onclick={() => startEdit(emp.id, emp.name)}
>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
>
{/snippet}
</IconButton>
<IconButton
danger
ariaLabel={$_('settings.remove_name_aria', { values: { name: emp.name } })}
onclick={() => {
delId = emp.id;
editId = null;
}}
>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><path
d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"
/></svg
>
{/snippet}
</IconButton>
{/if}
</div>
{:else}
<div class="empty">{$_('settings.no_employees_found')}</div>
{/each}
</div>
<div class="drawer-foot">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg
>
<span>{$_('settings.employees_local_note')}</span>
</div>
</div>
</div>
<style>
.drawer-wrap {
position: absolute;
inset: 0;
z-index: 30;
display: flex;
justify-content: flex-end;
}
.backdrop {
all: unset;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
cursor: default;
}
.drawer {
position: relative;
width: 100%;
max-width: 460px;
height: 100%;
box-sizing: border-box;
background: #171717;
border-left: 1px solid #2e2e2e;
box-shadow: -24px 0 60px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
}
.drawer-head {
flex: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 16px;
}
.drawer-head h2 {
margin: 0 0 4px;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
}
.drawer-sub {
font-size: 13px;
color: #8a8a8a;
}
.drawer-tools {
flex: none;
padding: 0 20px 12px;
}
.search {
height: 44px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 1px solid #333333;
border-radius: 12px;
color: #6e6e6e;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.search:focus-within {
border-color: var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
}
.search input {
all: unset;
box-sizing: border-box;
width: 100%;
min-width: 0;
font-size: 14px;
color: #f2f2f2;
caret-color: var(--ox-accent);
}
.search input::placeholder {
color: #5e5e5e;
}
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 12px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 8px 8px 8px 12px;
border-radius: 12px;
transition: background 0.15s;
}
.row:hover {
background: #1c1c1c;
}
.row.del {
background: rgba(229, 72, 77, 0.08);
}
.avatar {
width: 34px;
height: 34px;
flex: none;
border-radius: 50%;
background: #262626;
color: #c9c9c9;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.info {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
}
.name {
min-width: 0;
font-size: 14.5px;
color: #f2f2f2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.uid {
flex: none;
font-size: 13px;
font-weight: 300;
color: #6e6e6e;
}
.del-text {
flex: 1;
min-width: 0;
font-size: 13.5px;
color: #ffb3b8;
}
.name-in {
all: unset;
box-sizing: border-box;
flex: 1;
min-width: 0;
height: 36px;
padding: 0 10px;
border: 1px solid var(--ox-accent);
box-shadow: 0 0 0 3px var(--ox-accent-ring);
border-radius: 9px;
font-size: 14.5px;
color: #ffffff;
caret-color: var(--ox-accent);
}
.ghost,
.danger {
all: unset;
box-sizing: border-box;
height: 32px;
padding: 0 12px;
display: inline-flex;
align-items: center;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.ghost {
border: 1px solid #333333;
color: #e6e6e6;
}
.ghost:hover {
background: #1e1e1e;
color: #ffffff;
}
.danger {
background: #e5484d;
color: #ffffff;
font-weight: 600;
transition: filter 0.15s;
}
.danger:hover {
filter: brightness(1.08);
}
.ghost:focus-visible,
.danger:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
}
.empty {
padding: 40px 16px;
text-align: center;
font-size: 14px;
color: #8a8a8a;
}
.drawer-foot {
flex: none;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 20px 18px;
border-top: 1px solid #262626;
font-size: 12.5px;
line-height: 1.5;
color: #8a8a8a;
}
.drawer-foot svg {
margin-top: 1px;
}
</style>
@@ -0,0 +1,103 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
let { lang, onchange }: { lang: string; onchange: (lang: string) => void } = $props();
</script>
<section class="lang-sec">
<div class="sec-head">
<h2><label for="ox-lang">{$_('settings.language_title')}</label></h2>
<p>{$_('settings.language_desc')}</p>
</div>
<div class="select-wrap">
<select id="ox-lang" class="select" value={lang} onchange={(e) => onchange(e.currentTarget.value)}>
<option value="it">Italiano</option>
<option value="en">English</option>
</select>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" class="select-chev"
><path d="M6 9l6 6 6-6" /></svg
>
</div>
</section>
<style>
.lang-sec {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 24px;
}
.sec-head {
display: flex;
flex-direction: column;
gap: 6px;
}
.sec-head h2 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.02em;
color: #ffffff;
}
.sec-head p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #8a8a8a;
}
.select-wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.select {
appearance: none;
-webkit-appearance: none;
box-sizing: border-box;
height: 44px;
min-width: 180px;
padding: 0 42px 0 14px;
border: 1px solid #333333;
border-radius: 12px;
background: #161616;
color: #f2f2f2;
font-family: inherit;
font-size: 14.5px;
font-weight: 500;
cursor: pointer;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.select:hover {
border-color: #4a4a4a;
}
.select:focus {
outline: none;
border-color: var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
}
.select option {
background: #1a1a1a;
color: #f2f2f2;
}
.select-chev {
position: absolute;
right: 14px;
pointer-events: none;
color: #8a8a8a;
stroke-width: 2.2;
}
</style>
@@ -0,0 +1,384 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { error, setError, clearError } from '$lib/error';
import type { Settings } from '$lib/settings';
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
import Field from '../Field.svelte';
import ErrorBanner from '../ErrorBanner.svelte';
import ActionButton from '../ActionButton.svelte';
import PillButton from '../PillButton.svelte';
import EmployeesDrawer from './EmployeesDrawer.svelte';
import { isValidHost } from './logic';
let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } =
$props();
let editing = $state(false);
let draftHost = $state('');
let draftPort = $state(0);
let ipError = $state(false);
let confirming = $state(false);
let status = $state<'' | 'testing' | 'ok'>('');
let fullEmployeeCache = $state<EmployeeCache>({});
let employeeCache = $state<EmployeeNames>({});
let employeesOpen = $state(false);
let employeeQuery = $state('');
onMount(async () => {
fullEmployeeCache = await invoke<EmployeeCache>('load_employee_cache');
employeeCache = fullEmployeeCache[PRIMARY_READER_INDEX] ?? {};
});
const employeesList = $derived(
Object.entries(employeeCache)
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name))
);
const employeeCount = $derived(employeesList.length);
const visibleEmployees = $derived(
employeesList.filter(
(e) =>
!employeeQuery.trim() ||
e.name.toLowerCase().includes(employeeQuery.trim().toLowerCase()) ||
e.id.includes(employeeQuery.trim())
)
);
async function persistEmployees(next: EmployeeNames) {
employeeCache = next;
const nextFullCache = { ...fullEmployeeCache, [PRIMARY_READER_INDEX]: next };
fullEmployeeCache = nextFullCache;
await invoke('save_employee_cache', { cache: nextFullCache });
}
function renameEmployee(id: string, name: string) {
persistEmployees({ ...employeeCache, [id]: name });
}
function removeEmployee(id: string) {
const next = { ...employeeCache };
delete next[id];
persistEmployees(next);
}
function startEdit() {
draftHost = host;
draftPort = port;
ipError = false;
clearError();
editing = true;
}
function cancelEdit() {
editing = false;
ipError = false;
}
async function confirmEdit() {
const v = draftHost.trim();
if (!isValidHost(v)) {
ipError = true;
return;
}
const nextPort = draftPort || 5005;
confirming = true;
clearError();
try {
await invoke('test_connection', { host: v, port: nextPort });
const settings = await invoke<Settings>('load_settings');
await invoke('save_settings', { settings: { ...settings, readers: [{ host: v, port: nextPort }] } });
editing = false;
status = 'ok';
onsave(v, nextPort);
} catch (err) {
status = '';
setError(err);
} finally {
confirming = false;
}
}
async function test() {
status = 'testing';
clearError();
try {
await invoke('test_connection', { host, port });
status = 'ok';
} catch (err) {
status = '';
setError(err);
}
}
function onPortInput(e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget;
const next = input.value.replace(/\D/g, '').slice(0, 5);
if (input.value !== next) input.value = next;
draftPort = Number(next);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && editing && !confirming) cancelEdit();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="card" class:active={editing}>
{#if !editing}
<div class="dev-row">
<div class="dev-icon">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 1.8"
><rect x="5" y="3" width="14" height="18" rx="2.5" /><rect x="8" y="6" width="8" height="5" rx="1" /><path
d="M9 15h.01M12 15h.01M15 15h.01M9 18h6"
/></svg
>
</div>
<div class="dev-text">
<span class="dev-label">{$_('settings.device_label')}</span>
<span class="dev-endpoint mono">{host}:{port}</span>
</div>
<span class="dev-status">
{#if status === 'testing'}
<svg class="spin" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M12 3a9 9 0 1 0 9 9" /></svg
>
{$_('settings.status_testing')}
{:else if status === 'ok'}
<span class="dot ok" aria-hidden="true"></span>
{$_('settings.status_ok')}
{:else}
<span class="dot" aria-hidden="true"></span>
{$_('settings.status_idle')}
{/if}
</span>
</div>
<div class="dev-actions">
<PillButton onclick={startEdit}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
>
{/snippet}
{$_('settings.edit_address')}
</PillButton>
<PillButton onclick={test} disabled={status === 'testing'}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01" /></svg
>
{/snippet}
{$_('settings.test_connection')}
</PillButton>
<PillButton onclick={() => (employeesOpen = true)}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c.8-3.3 3.3-5 6.5-5s5.7 1.7 6.5 5" /><path
d="M16 4.5a3.5 3.5 0 0 1 0 7M18 15.2c1.8.7 3 2.3 3.5 4.8"
/></svg
>
{/snippet}
{$_('settings.manage_employees')}
<span class="count mono">{employeeCount}</span>
</PillButton>
</div>
{:else}
<form
class="dev-edit"
onsubmit={(e) => {
e.preventDefault();
confirmEdit();
}}
>
<div class="grid-ip">
<div class="stack">
<Field
id="dev-ip"
label={$_('settings.ip_label')}
bind:value={draftHost}
placeholder="192.168.1.100"
err={ipError}
oninput={() => (ipError = false)}
/>
{#if ipError}<span class="field-error">{$_('settings.ip_invalid')}</span>{/if}
</div>
<Field
id="dev-port"
label={$_('settings.port_label')}
value={draftPort}
maxlength={5}
inputmode="numeric"
placeholder="5005"
oninput={onPortInput}
/>
</div>
<div class="row-end">
<ActionButton disabled={confirming} onclick={cancelEdit}>{$_('settings.cancel')}</ActionButton>
<ActionButton primary disabled={confirming} onclick={confirmEdit}>
{#snippet icon()}
{#if confirming}
<svg class="spin" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M12 3a9 9 0 1 0 9 9" /></svg
>
{/if}
{/snippet}
{confirming ? $_('settings.status_testing') : $_('settings.confirm')}
</ActionButton>
</div>
</form>
{/if}
</div>
{#if $error}
<ErrorBanner title={$_('errors.title')} message={$error} />
{/if}
{#if employeesOpen}
<EmployeesDrawer
employees={visibleEmployees}
total={employeeCount}
bind:query={employeeQuery}
onclose={() => (employeesOpen = false)}
onrename={renameEmployee}
ondelete={removeEmployee}
/>
{/if}
<style>
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.card {
border: 1px solid #262626;
border-radius: 14px;
background: #161616;
}
.card.active {
border-color: #3a3a3a;
}
.dev-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
padding: 16px 16px 16px 18px;
}
.dev-icon {
width: 44px;
height: 44px;
flex: none;
border-radius: 11px;
background: #1f1f1f;
color: #bdbdbd;
display: flex;
align-items: center;
justify-content: center;
}
.dev-text {
flex: 1;
min-width: 160px;
display: flex;
flex-direction: column;
gap: 3px;
}
.dev-label {
font-size: 12.5px;
color: #8a8a8a;
}
.dev-endpoint {
font-size: 16px;
color: #f2f2f2;
}
.dev-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6e6e6e;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.dot.ok {
background: #4cc38a;
}
.dev-status:has(.ok) {
color: #4cc38a;
}
.dev-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px 14px 18px;
border-top: 1px solid #222222;
}
.count {
margin-left: 6px;
font-size: 12.5px;
color: #6e6e6e;
}
.dev-edit {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px 16px 16px;
}
.grid-ip {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 12px;
align-items: start;
}
.stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.row-end {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.field-error {
padding-left: 4px;
font-size: 12.5px;
color: #ff8a8f;
}
@media (max-width: 560px) {
.grid-ip {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { fmt } from '$lib/time';
import Field from '../Field.svelte';
import { roundValid } from './logic';
let { roundStep, oninput }: { roundStep: string; oninput: (value: string) => void } = $props();
function onRoundInput(e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget;
const next = input.value.replace(/\D/g, '').slice(0, 2);
if (input.value !== next) input.value = next;
oninput(next);
}
const example = $derived.by(() => {
if (!roundValid(roundStep)) return '';
const step = Number(roundStep);
const rounded = Math.round((8 * 60 + 7) / step) * step;
return $_('settings.round_example', { values: { from: '08:07', to: fmt(rounded) } });
});
</script>
<div class="card">
<div class="round-text">
<span class="round-title">{$_('settings.round_title')}</span>
<span class="round-desc">{$_('settings.round_desc')} {example}</span>
</div>
<div class="round-box">
<Field
id="ox-round"
label={$_('settings.round_label')}
value={roundStep}
maxlength={2}
inputmode="numeric"
placeholder="10"
suffix={$_('settings.minutes_short')}
err={!roundValid(roundStep)}
oninput={onRoundInput}
/>
{#if !roundValid(roundStep)}<span class="field-error">{$_('settings.round_range')}</span>{/if}
</div>
</div>
<style>
.card {
border: 1px solid #262626;
border-radius: 14px;
background: #161616;
padding: 22px 18px 18px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 16px 24px;
}
.round-text {
flex: 1;
min-width: 240px;
display: flex;
flex-direction: column;
gap: 4px;
}
.round-title {
font-size: 14.5px;
font-weight: 500;
color: #e6e6e6;
}
.round-desc {
font-size: 13px;
line-height: 1.5;
color: #8a8a8a;
}
.round-box {
width: 140px;
display: flex;
flex-direction: column;
gap: 8px;
}
.field-error {
padding-left: 4px;
font-size: 12.5px;
color: #ff8a8f;
}
</style>
@@ -0,0 +1,126 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { formatTyped, hm } from '$lib/time';
import Field from '../Field.svelte';
import { SHIFT_ROWS, checkShifts, type ShiftTimeKey, type Shifts } from './logic';
let { shifts, oninput }: { shifts: Shifts; oninput: (key: ShiftTimeKey, value: string) => void } = $props();
const check = $derived(checkShifts(shifts));
function onShiftInput(key: ShiftTimeKey, e: Event & { currentTarget: HTMLInputElement }) {
const input = e.currentTarget;
const next = formatTyped(input.value, shifts[key], (e as unknown as InputEvent).inputType ?? '');
if (input.value !== next) input.value = next;
oninput(key, next);
}
</script>
<div class="card">
{#each SHIFT_ROWS as row (row.a)}
<div class="shift-row">
<span class="shift-title">{$_(row.titleKey)}</span>
<Field
id="sh-{row.a}"
label={$_('settings.shift_in')}
value={shifts[row.a]}
maxlength={5}
inputmode="numeric"
placeholder="--:--"
err={!!check.bad[row.a]}
oninput={(e) => onShiftInput(row.a, e)}
/>
<Field
id="sh-{row.b}"
label={$_('settings.shift_out')}
value={shifts[row.b]}
maxlength={5}
inputmode="numeric"
placeholder="--:--"
err={!!check.bad[row.b]}
oninput={(e) => onShiftInput(row.b, e)}
/>
</div>
{/each}
<div class="summary">
{#if check.errorKey}
<span class="summary-err">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5.5M12 16.5h.01" /></svg
>
{$_(check.errorKey)}
</span>
{:else if check.minutes}
<span
>{$_('settings.hours_per_day_label')}
<b>{hm(check.minutes.out1 - check.minutes.in1 + (check.minutes.out2 - check.minutes.in2))}</b>
{$_('settings.per_day')}</span
>
<span
>{$_('settings.break_label')} <b>{shifts.out1}–{shifts.in2}</b>
({check.minutes.in2 - check.minutes.out1} {$_('settings.minutes_short')})</span
>
{/if}
</div>
</div>
<style>
.card {
border: 1px solid #262626;
border-radius: 14px;
background: #161616;
display: flex;
flex-direction: column;
gap: 14px;
padding: 22px 18px 18px;
}
.shift-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: center;
padding-top: 8px;
}
.shift-title {
font-size: 14px;
font-weight: 500;
color: #c9c9c9;
}
.summary {
margin-top: 6px;
padding-top: 16px;
border-top: 1px solid #242424;
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
font-size: 13.5px;
color: #8a8a8a;
}
.summary b {
font-family: var(--ox-font-mono);
font-weight: 400;
color: #e6e6e6;
}
.summary-err {
display: inline-flex;
align-items: center;
gap: 8px;
color: #ff9ea3;
}
@media (max-width: 560px) {
.shift-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.shift-title {
grid-column: 1 / -1;
}
}
</style>
@@ -0,0 +1,50 @@
import { parseT } from '$lib/time';
import type { Shifts } from '$lib/shifts';
export type { Shifts };
const IPV4 = /^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
// A DNS label: alphanumeric, hyphens allowed in the middle, 1-63 chars.
const LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?';
const HOSTNAME = new RegExp(`^${LABEL}(?:\\.${LABEL})*$`);
/** Whether `v` is a usable time clock address: an IPv4 address, a bare
* hostname (e.g. "timbratore") or a FQDN (e.g. "timbratore.lan"). */
export const isValidHost = (v: string): boolean => IPV4.test(v) || HOSTNAME.test(v);
/** The 4 time fields of `Shifts` — everything but `round_step_min`, which isn't a time. */
export type ShiftTimeKey = 'in1' | 'out1' | 'in2' | 'out2';
export const SHIFT_ROWS: { titleKey: string; a: ShiftTimeKey; b: ShiftTimeKey }[] = [
{ titleKey: 'settings.shift_1', a: 'in1', b: 'out1' },
{ titleKey: 'settings.shift_2', a: 'in2', b: 'out2' }
];
export interface ShiftCheck {
minutes: Record<ShiftTimeKey, number> | null;
bad: Partial<Record<ShiftTimeKey, true>>;
errorKey: string | null;
}
export function checkShifts(shifts: Shifts): ShiftCheck {
const t: Record<ShiftTimeKey, number | null> = {
in1: parseT(shifts.in1),
out1: parseT(shifts.out1),
in2: parseT(shifts.in2),
out2: parseT(shifts.out2)
};
const bad: Partial<Record<ShiftTimeKey, true>> = {};
for (const key of Object.keys(t) as ShiftTimeKey[]) {
if (t[key] === null) bad[key] = true;
}
if (Object.keys(bad).length) return { minutes: null, bad, errorKey: 'settings.shift_error_format' };
const n = t as Record<ShiftTimeKey, number>;
if (n.out1 <= n.in1) return { minutes: null, bad: { out1: true }, errorKey: 'settings.shift_error_out1' };
if (n.in2 < n.out1) return { minutes: null, bad: { in2: true }, errorKey: 'settings.shift_error_in2' };
if (n.out2 <= n.in2) return { minutes: null, bad: { out2: true }, errorKey: 'settings.shift_error_out2' };
return { minutes: n, bad: {}, errorKey: null };
}
export const roundValid = (v: string): boolean => /^\d{1,2}$/.test(v) && Number(v) >= 1 && Number(v) <= 60;
+10
View File
@@ -0,0 +1,10 @@
/** Employee names chosen by the user, by employee_id. */
export type EmployeeNames = Record<string, string>;
/** `EmployeeNames` by reader index, since an employee_id is only unique
* within one reader. */
export type EmployeeCache = Record<string, EmployeeNames>;
/** Only the first reader is used today (see `Settings.readers`), so this is
* the only bucket of the cache ever read or written. */
export const PRIMARY_READER_INDEX = '0';
+11
View File
@@ -0,0 +1,11 @@
import { writable } from 'svelte/store';
export const error = writable('');
export function setError(err: unknown) {
error.set(String(err));
}
export function clearError() {
error.set('');
}
+222
View File
@@ -0,0 +1,222 @@
{
"loading": "Loading...",
"onboarding": {
"title": "Welcome!",
"subtitle": "Connect Oxibadge to your time clock by entering its address and port.",
"host_label": "Address",
"port_label": "Port",
"hint": "You can find the address in the time clock's network settings.",
"button": {
"idle": "Test connection",
"testing": "Connecting...",
"retry": "Retry"
}
},
"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."
},
"errors": {
"title": "Could not reach the time clock",
"export_title": "Export failed"
},
"review": {
"title": "Clock-in review",
"day_word": "{n, plural, one {1 day} other {{n} days}}",
"to_check": "to check",
"to_round": "to round",
"to_check_cap": "To check",
"to_round_cap": "To round",
"shift_1": "1st shift",
"shift_2": "2nd shift",
"issue_stacked": "{total} punches all on the {shift}",
"issue_invalid_time": "Invalid time (use HH:MM)",
"issue_lunch_missing": "Lunch break not punched",
"issue_missing_out": "Missing clock-out for the {shift}",
"issue_missing_in": "Missing clock-in for the {shift}",
"issue_out_before_in": "Clock-out before clock-in ({shift})",
"info_unrounded": "Times not rounded to {step} min",
"note_empty": "No punches",
"note_lunch_added": "Lunch break added",
"note_shifts_fixed": "Shifts fixed",
"note_edited": "{n, plural, one {1 time edited} other {{n} times edited}}",
"note_rounded": "{n, plural, one {1 time rounded} other {{n} times rounded}}",
"export_done": "Export completed: {who}",
"everyone": "all employees",
"only_unrounded": "{days} with unrounded times",
"confirm_title_all": "There are {summary}",
"confirm_title_one": "{name} has {summary}",
"close_menu_aria": "Close menu",
"no_records": "No punches found for this month.",
"back_to_month": "Back to month selection",
"export": "Export",
"export_options_aria": "More export options",
"all_employees": "All employees",
"employee_count": "{n} employees",
"all_good": "all good",
"all_good_cap": "All good",
"only_name": "Only {name}",
"employees_nav_aria": "Employees",
"search_employee": "Search employee",
"days_to_check_aria": "{n} days to check",
"days_to_round_aria": "{n} days to round",
"no_problem_aria": "No problem",
"employee_name_aria": "Employee name",
"save_name": "Save name",
"save": "Save",
"cancel": "Cancel",
"edit_name": "Edit name",
"days_worked": "days worked",
"total_hours": "total hours",
"edited": "edited",
"fix_shifts_title": "Sorts and redistributes punches that all ended up on the same shift",
"fix_shifts": "Fix shifts ({days})",
"fix_shifts_short": "Fix shifts",
"add_lunch_title": "Adds the {start}–{end} break on days with only a morning clock-in and an evening clock-out",
"add_lunch": "Add lunch break ({days})",
"lunch_short": "Lunch",
"round_title": "Round to the nearest time",
"round_all": "Round to {step} min ({days})",
"round_short": "Round",
"day_filter_aria": "Day filter",
"all_days": "All days",
"col_day": "Day",
"col_in": "In",
"col_out": "Out",
"col_hours": "Hours",
"col_notes": "Notes",
"col_in_1": "1st shift clock-in",
"col_out_1": "1st shift clock-out",
"col_in_2": "2nd shift clock-in",
"col_out_2": "2nd shift clock-out",
"cell_aria": "{label}, {col}",
"cell_aria_stacked": "{label}, {col} ({index} of {total})",
"fix_shifts_day_aria": "Fix the shifts for {label}",
"add_lunch_day_aria": "Add the {start}–{end} lunch break to {label}",
"round_day_aria": "Round the times for {label}",
"no_rows": "No days to check for this employee.",
"cancel_export_aria": "Cancel export",
"export_warning_issues": "If you export now, these days will end up in the file with missing, unverified, or unrounded times. You can go back and fix them, or export anyway.",
"export_warning_unrounded": "The times are complete, but some aren't rounded to {step} minutes. You can round them automatically before exporting, or export them as they are.",
"back_to_review": "Back to review",
"round_and_export": "Round and export",
"export_anyway": "Export anyway",
"leave_confirm_title": "Unexported changes",
"leave_confirm_message": "If you leave now, the changes made in this review will be lost.",
"leave_anyway": "Leave without exporting",
"undo_day_aria": "Undo the changes to {label}",
"undo_short": "Undo",
"undo_all": "Undo changes",
"undo_all_title": "Restore this employee's times to what the time clock originally reported",
"undo_all_confirm_title": "Undo all changes?",
"undo_all_confirm_message": "All edited times for this employee will revert to what the time clock reported. This can't be undone.",
"undo_all_confirm": "Undo all changes",
"corrections": "Corrections",
"add_lunch_action": "Add lunch break",
"round_action": "Round to {step} min",
"apply_all_corrections": "Apply all corrections",
"shift_times": "Shift times",
"shift_panel_title": "Shift all the times",
"shift_panel_desc": "For when the time clock didn't follow a daylight-saving time change.",
"shift_amount_label": "By how much",
"shift_minus_aria": "Minus 15 minutes",
"shift_plus_aria": "Plus 15 minutes",
"shift_minus_hour": "−1 hour",
"shift_plus_hour": "+1 hour",
"shift_days_label": "Which days",
"shift_from": "From",
"shift_to": "to",
"shift_who_label": "For whom",
"shift_scope_all": "Everyone",
"shift_preview": "E.g. {from} → <b>{to}</b>",
"apply": "Apply"
},
"settings": {
"title": "Settings",
"device_title": "Time clock",
"device_desc": "The time clock Oxibadge downloads punches from.",
"device_label": "Address",
"status_testing": "Checking…",
"status_ok": "Reachable",
"status_idle": "Not verified",
"edit_address": "Edit address",
"test_connection": "Test connection",
"ip_label": "Address",
"port_label": "Port",
"ip_invalid": "Invalid format, e.g. 192.168.1.100 or timbratore.lan",
"cancel": "Cancel",
"confirm": "Confirm",
"shifts_title": "Expected shifts",
"shifts_desc": "The standard work schedule. The break between the two shifts is the one used by \"Add lunch break\" in the review.",
"shift_1": "1st shift",
"shift_2": "2nd shift",
"shift_in": "In",
"shift_out": "Out",
"shift_error_format": "Enter all times in HH:MM format.",
"shift_error_out1": "The 1st shift's clock-out must be after its clock-in.",
"shift_error_in2": "The 2nd shift must start after the 1st one ends.",
"shift_error_out2": "The 2nd shift's clock-out must be after its clock-in.",
"hours_per_day_label": "Expected hours",
"per_day": "per day",
"break_label": "Break",
"round_title": "Time rounding",
"round_desc": "Times are rounded to the nearest multiple.",
"round_example": "E.g. {from} → {to}.",
"round_label": "Minutes",
"minutes_short": "min",
"round_range": "From 1 to 60 minutes",
"language_title": "Language",
"language_desc": "Oxibadge's interface language.",
"save_idle": "Changes are saved automatically",
"save_saving": "Saving…",
"save_saved": "Changes saved",
"save_invalid": "Not saved: fix the error",
"save_failed": "Save failed",
"employees_title": "Employees",
"employees_count": "{n, plural, one {1 employee} other {{n} employees}}",
"manage_employees": "Manage employees",
"close_aria": "Close",
"employee_name_aria": "Name of employee #{id}",
"edit_name_aria": "Edit {name}'s name",
"remove_name_aria": "Remove {name} from the cache",
"employee_remove_confirm": "Remove {name}? You'll be asked for their name again next time.",
"remove": "Remove",
"no_employees_found": "No employees found.",
"employees_local_note": "Edited names only apply within Oxibadge: the time clock is never written to."
},
"new_employees": {
"title": "New employees",
"subtitle": "{n, plural, one {The time clock found 1 new employee.} other {The time clock found {n} new employees.}}",
"hint": "Fix the names if needed: they only apply within Oxibadge.",
"missing_placeholder": "missing name",
"name_aria": "Name of employee #{id}",
"renamed_count": "{n, plural, one {1 name edited} other {{n} names edited}}",
"missing_count": "{n, plural, one {1 name missing} other {{n} names missing}}",
"invalid_count": "{n, plural, one {1 invalid name} other {{n} invalid names}}",
"invalid_name_title": "The time clock sent unreadable characters: fix the name",
"continue": "Continue"
},
"export": {
"col_date": "Date",
"col_in1": "Entry 1",
"col_out1": "Exit 1",
"col_in2": "Entry 2",
"col_out2": "Exit 2",
"col_total": "Hours worked"
}
}
+222
View File
@@ -0,0 +1,222 @@
{
"loading": "Caricamento...",
"onboarding": {
"title": "Benvenuto!",
"subtitle": "Collega Oxibadge al tuo timbratore inserendo il suo indirizzo e la porta.",
"host_label": "Indirizzo",
"port_label": "Porta",
"hint": "Trovi l'indirizzo nelle impostazioni di rete del timbratore.",
"button": {
"idle": "Testa connessione",
"testing": "Connessione in corso...",
"retry": "Riprova"
}
},
"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."
},
"errors": {
"title": "Impossibile raggiungere il timbratore",
"export_title": "Esportazione non riuscita"
},
"review": {
"title": "Revisione timbrature",
"day_word": "{n, plural, one {1 giorno} other {{n} giorni}}",
"to_check": "da controllare",
"to_round": "da arrotondare",
"to_check_cap": "Da controllare",
"to_round_cap": "Da arrotondare",
"shift_1": "1° turno",
"shift_2": "2° turno",
"issue_stacked": "{total} timbrature tutte sul {shift}",
"issue_invalid_time": "Orario non valido (usa HH:MM)",
"issue_lunch_missing": "Pausa pranzo non timbrata",
"issue_missing_out": "Manca l’uscita del {shift}",
"issue_missing_in": "Manca l’entrata del {shift}",
"issue_out_before_in": "Uscita prima dell’entrata ({shift})",
"info_unrounded": "Orari non arrotondati a {step} min",
"note_empty": "Nessuna timbratura",
"note_lunch_added": "Pausa pranzo aggiunta",
"note_shifts_fixed": "Turni sistemati",
"note_edited": "{n, plural, one {1 orario modificato} other {{n} orari modificati}}",
"note_rounded": "{n, plural, one {1 orario arrotondato} other {{n} orari arrotondati}}",
"export_done": "Esportazione completata: {who}",
"everyone": "tutti i dipendenti",
"only_unrounded": "{days} con orari non arrotondati",
"confirm_title_all": "Ci sono {summary}",
"confirm_title_one": "{name} ha {summary}",
"close_menu_aria": "Chiudi menu",
"no_records": "Nessuna timbratura trovata per questo mese.",
"back_to_month": "Torna alla scelta del mese",
"export": "Esporta",
"export_options_aria": "Altre opzioni di esportazione",
"all_employees": "Tutti i dipendenti",
"employee_count": "{n} dipendenti",
"all_good": "tutto a posto",
"all_good_cap": "Tutto a posto",
"only_name": "Solo {name}",
"employees_nav_aria": "Dipendenti",
"search_employee": "Cerca dipendente",
"days_to_check_aria": "{n} giorni da controllare",
"days_to_round_aria": "{n} giorni da arrotondare",
"no_problem_aria": "Nessun problema",
"employee_name_aria": "Nome del dipendente",
"save_name": "Salva nome",
"save": "Salva",
"cancel": "Annulla",
"edit_name": "Modifica nome",
"days_worked": "giorni lavorati",
"total_hours": "ore totali",
"edited": "modificati",
"fix_shifts_title": "Riordina in ordine di orario le timbrature finite tutte nello stesso turno",
"fix_shifts": "Sistema turni ({days})",
"fix_shifts_short": "Sistema turni",
"add_lunch_title": "Inserisce la pausa {start}–{end} nei giorni con solo entrata del mattino e uscita della sera",
"add_lunch": "Aggiungi pausa pranzo ({days})",
"lunch_short": "Pausa",
"round_title": "Arrotonda all'orario più vicino",
"round_all": "Arrotonda a {step} min ({days})",
"round_short": "Arrotonda",
"day_filter_aria": "Filtro giorni",
"all_days": "Tutti i giorni",
"col_day": "Giorno",
"col_in": "Entrata",
"col_out": "Uscita",
"col_hours": "Ore",
"col_notes": "Note",
"col_in_1": "Entrata 1° turno",
"col_out_1": "Uscita 1° turno",
"col_in_2": "Entrata 2° turno",
"col_out_2": "Uscita 2° turno",
"cell_aria": "{label}, {col}",
"cell_aria_stacked": "{label}, {col} ({index} di {total})",
"fix_shifts_day_aria": "Sistema i turni di {label}",
"add_lunch_day_aria": "Aggiungi pausa pranzo {start}–{end} a {label}",
"round_day_aria": "Arrotonda gli orari di {label}",
"no_rows": "Nessun giorno da controllare per questo dipendente.",
"cancel_export_aria": "Annulla esportazione",
"export_warning_issues": "Se esporti ora, questi giorni finiranno nel file con orari mancanti, da verificare o non arrotondati. Puoi tornare indietro e sistemarli, oppure esportare comunque.",
"export_warning_unrounded": "Gli orari sono completi, ma alcuni non sono arrotondati a {step} minuti. Puoi arrotondarli automaticamente prima di esportare, oppure esportarli così come sono.",
"back_to_review": "Torna alla revisione",
"round_and_export": "Arrotonda ed esporta",
"export_anyway": "Esporta comunque",
"leave_confirm_title": "Modifiche non esportate",
"leave_confirm_message": "Se esci ora, le modifiche fatte in questa revisione andranno perse.",
"leave_anyway": "Esci senza esportare",
"undo_day_aria": "Annulla le modifiche di {label}",
"undo_short": "Annulla",
"undo_all": "Annulla modifiche",
"undo_all_title": "Ripristina gli orari di questo dipendente a quelli letti dal timbratore",
"undo_all_confirm_title": "Annullare tutte le modifiche?",
"undo_all_confirm_message": "Tutti gli orari modificati per questo dipendente torneranno a quelli letti dal timbratore. L'operazione non si può annullare.",
"undo_all_confirm": "Annulla tutte le modifiche",
"corrections": "Correzioni",
"add_lunch_action": "Aggiungi pausa pranzo",
"round_action": "Arrotonda a {step} min",
"apply_all_corrections": "Applica tutte le correzioni",
"shift_times": "Sposta orari",
"shift_panel_title": "Sposta tutti gli orari",
"shift_panel_desc": "Per quando il timbratore non ha seguito il cambio dell'ora legale.",
"shift_amount_label": "Di quanto",
"shift_minus_aria": "Meno 15 minuti",
"shift_plus_aria": "Più 15 minuti",
"shift_minus_hour": "−1 ora",
"shift_plus_hour": "+1 ora",
"shift_days_label": "Quali giorni",
"shift_from": "Dal",
"shift_to": "al",
"shift_who_label": "Per chi",
"shift_scope_all": "Tutti",
"shift_preview": "Es. {from} → <b>{to}</b>",
"apply": "Applica"
},
"settings": {
"title": "Impostazioni",
"device_title": "Timbratore",
"device_desc": "Il timbratore da cui Oxibadge scarica le timbrature.",
"device_label": "Indirizzo",
"status_testing": "Verifica…",
"status_ok": "Raggiungibile",
"status_idle": "Non verificato",
"edit_address": "Modifica indirizzo",
"test_connection": "Testa connessione",
"ip_label": "Indirizzo",
"port_label": "Porta",
"ip_invalid": "Formato non valido, es. 192.168.1.100 o timbratore.lan",
"cancel": "Annulla",
"confirm": "Conferma",
"shifts_title": "Turni previsti",
"shifts_desc": "L'orario di lavoro standard. La pausa tra i due turni è quella usata da \"Aggiungi pausa pranzo\" nella revisione.",
"shift_1": "1° turno",
"shift_2": "2° turno",
"shift_in": "Entrata",
"shift_out": "Uscita",
"shift_error_format": "Inserisci tutti gli orari nel formato HH:MM.",
"shift_error_out1": "L'uscita del 1° turno deve essere dopo l'entrata.",
"shift_error_in2": "Il 2° turno deve iniziare dopo la fine del 1°.",
"shift_error_out2": "L'uscita del 2° turno deve essere dopo l'entrata.",
"hours_per_day_label": "Ore previste",
"per_day": "al giorno",
"break_label": "Pausa",
"round_title": "Arrotondamento orari",
"round_desc": "Gli orari vengono arrotondati al multiplo più vicino.",
"round_example": "Es. {from} → {to}.",
"round_label": "Minuti",
"minutes_short": "min",
"round_range": "Da 1 a 60 minuti",
"language_title": "Lingua",
"language_desc": "La lingua dell'interfaccia di Oxibadge.",
"save_idle": "Le modifiche vengono salvate automaticamente",
"save_saving": "Salvataggio…",
"save_saved": "Modifiche salvate",
"save_invalid": "Non salvato: correggi l'errore",
"save_failed": "Salvataggio non riuscito",
"employees_title": "Dipendenti",
"employees_count": "{n, plural, one {1 dipendente} other {{n} dipendenti}}",
"manage_employees": "Gestisci dipendenti",
"close_aria": "Chiudi",
"employee_name_aria": "Nome del dipendente #{id}",
"edit_name_aria": "Modifica nome di {name}",
"remove_name_aria": "Rimuovi {name} dalla cache",
"employee_remove_confirm": "Rimuovere {name}? Al prossimo controllo verrà chiesto di nuovo il nome.",
"remove": "Rimuovi",
"no_employees_found": "Nessun dipendente trovato.",
"employees_local_note": "I nomi modificati valgono solo in Oxibadge: il timbratore non viene mai scritto."
},
"new_employees": {
"title": "Nuovi dipendenti",
"subtitle": "{n, plural, one {Sul timbratore c'è 1 dipendente nuovo.} other {Sul timbratore ci sono {n} dipendenti nuovi.}}",
"hint": "Correggi i nomi se serve: valgono solo in Oxibadge.",
"missing_placeholder": "nome mancante",
"name_aria": "Nome del dipendente #{id}",
"renamed_count": "{n, plural, one {1 nome modificato} other {{n} nomi modificati}}",
"missing_count": "{n, plural, one {1 nome mancante} other {{n} nomi mancanti}}",
"invalid_count": "{n, plural, one {1 nome non valido} other {{n} nomi non validi}}",
"invalid_name_title": "Il timbratore ha inviato caratteri non leggibili: correggi il nome",
"continue": "Continua"
},
"export": {
"col_date": "Data",
"col_in1": "Ingresso 1",
"col_out1": "Uscita 1",
"col_in2": "Ingresso 2",
"col_out2": "Uscita 2",
"col_total": "Ore lavorate"
}
}
+16
View File
@@ -0,0 +1,16 @@
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)));
}
/** `dow` is 0 (Sunday) - 6 (Saturday), matching `Date.getDay()`. */
export function weekdayName(loc: string | null | undefined, dow: number, format: 'short' | 'long' = 'short') {
const formatter = new Intl.DateTimeFormat(loc ?? 'it', { weekday: format });
// 2000-01-02 is a Sunday, so 2000-01-(2+dow) lands on the requested weekday.
return capitalize(formatter.format(new Date(2000, 0, 2 + dow)));
}
+14
View File
@@ -0,0 +1,14 @@
import type { Shifts } from './shifts';
export interface Reader {
host: string;
port: number;
}
export interface Settings {
/** The configured time clock (only the first one is used today, but this
* stays a list to support multiple readers in the future). */
readers: Reader[];
shifts: Shifts;
lang: string | null;
}
+7
View File
@@ -0,0 +1,7 @@
export interface Shifts {
in1: string;
out1: string;
in2: string;
out2: string;
round_step_min: number;
}
+9
View File
@@ -0,0 +1,9 @@
export function initials(n: string): string {
return n
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
+31
View File
@@ -0,0 +1,31 @@
export function parseT(t: string): number | null {
const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(t);
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
}
export function fmt(m: number): string {
return String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
}
export function hm(m: number): string {
return Math.floor(m / 60) + ':' + String(m % 60).padStart(2, '0');
}
/** Formats a time as it's typed, without needing to type ":". */
export function formatTyped(raw: string, prev: string, inputType: string): string {
const deleting = inputType ? inputType.startsWith('delete') : raw.length < prev.length && prev.startsWith(raw);
const digits = raw.replace(/\D/g, '').slice(0, 4);
if (!digits) return '';
let h: string;
let m: string;
if (digits[0] >= '3' || (digits[0] === '2' && digits.length > 1 && digits[1] > '3')) {
h = '0' + digits[0];
m = digits.slice(1, 3);
} else {
h = digits.slice(0, 2);
m = digits.slice(2, 4);
}
if (h.length < 2) return h;
if (!m) return deleting ? h : h + ':';
return h + ':' + m;
}
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
import '../app.css';
import { invoke } from '@tauri-apps/api/core';
import { locale, waitLocale } from 'svelte-i18n';
import type { Snippet } from 'svelte';
import type { Settings } from '$lib/settings';
let { children }: { children: Snippet } = $props();
let ready = $state(false);
invoke<Settings>('load_settings')
.catch(() => null)
.then((settings) => {
if (settings?.lang) locale.set(settings.lang);
return waitLocale();
})
.then(() => {
ready = true;
});
</script>
{#if ready}
{@render children()}
{/if}
+15
View File
@@ -0,0 +1,15 @@
import { getLocaleFromNavigator, init, register } from 'svelte-i18n';
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;
register('it', () => import('$lib/locales/it.json'));
register('en', () => import('$lib/locales/en.json'));
init({
fallbackLocale: 'it',
initialLocale: getLocaleFromNavigator()
});
+222
View File
@@ -0,0 +1,222 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { save } from '@tauri-apps/plugin-dialog';
import { onMount } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { monthName } from '$lib/month';
import { setError, clearError } from '$lib/error';
import type { Employee } from '$lib/components/review/analysis';
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';
import TimesheetReview from '$lib/components/TimesheetReview.svelte';
import SettingsButton from '$lib/components/SettingsButton.svelte';
import Settings from '$lib/components/Settings.svelte';
import NewEmployees from '$lib/components/NewEmployees.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { buildEmployees, type AttendanceRecord } from '$lib/components/review/fromRecords';
import type { NewEmployee } from '$lib/components/new-employees/logic';
import type { Shifts } from '$lib/shifts';
import type { Settings as SettingsData } from '$lib/settings';
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
type Step =
| 'loading'
| 'setup'
| 'confirm-month'
| 'loading-records'
| 'loading-employees'
| 'new-employees'
| 'review'
| 'settings';
let step = $state<Step>('loading');
let selectedYear = $state(0);
let selectedMonth = $state(0);
let records = $state<AttendanceRecord[]>([]);
let fullEmployeeCache = $state<EmployeeCache>({});
let employeeCache = $state<EmployeeNames>({});
let employeeNames = $state<Record<string, string>>({});
let newEmployees = $state<NewEmployee[]>([]);
// Placeholder: overwritten by load_settings before the review screen is reachable.
let shifts = $state<Shifts>({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 });
let reviewDirty = $state(false);
let pendingLeaveAction = $state<null | (() => void)>(null);
const period = $derived(`${monthName($locale, selectedMonth)} ${selectedYear}`);
const reviewEmployees = $derived(buildEmployees(selectedYear, selectedMonth, records, employeeNames));
/** Runs `action` immediately, unless the review screen has unexported
* edits, in which case it asks for confirmation first. */
function requestLeaveReview(action: () => void) {
if (step === 'review' && reviewDirty) {
pendingLeaveAction = action;
} else {
action();
}
}
function confirmLeaveReview() {
const action = pendingLeaveAction;
pendingLeaveAction = null;
action?.();
}
function cancelLeaveReview() {
pendingLeaveAction = null;
}
onMount(async () => {
const settings = await invoke<SettingsData>('load_settings');
shifts = settings.shifts;
step = settings.readers[0] ? 'confirm-month' : 'setup';
// Lives for the whole app, so there's no need to ever unlisten.
const appWindow = getCurrentWindow();
await appWindow.onCloseRequested((event) => {
if (step === 'review' && reviewDirty) {
event.preventDefault();
requestLeaveReview(() => appWindow.destroy());
}
});
});
function openSettings() {
clearError();
step = 'settings';
}
function closeSettings() {
clearError();
step = 'confirm-month';
}
async function loadMonth(selection: { year: number; month: number }) {
selectedYear = selection.year;
selectedMonth = selection.month;
step = 'loading-records';
clearError();
try {
records = await invoke('load_records', selection);
step = 'loading-employees';
fullEmployeeCache = await invoke<EmployeeCache>('load_employee_cache');
employeeCache = fullEmployeeCache[PRIMARY_READER_INDEX] ?? {};
const distinctIds = [...new Set(records.map((r: AttendanceRecord) => r.employee_id))];
const knownIds = distinctIds.filter((id) => String(id) in employeeCache);
const newIds = distinctIds.filter((id) => !(String(id) in employeeCache));
if (newIds.length > 0) {
const fetched = await invoke<Record<string, string>>('load_employees', {
records,
known: knownIds
});
newEmployees = newIds.map((id) => ({ id, name: fetched[String(id)] ?? '' }));
step = 'new-employees';
} else {
employeeNames = employeeCache;
step = 'review';
}
} catch (err) {
setError(err);
step = 'confirm-month';
}
}
/** Merges `updates` into the employee cache (local state + on disk) and returns the merged names. */
async function mergeEmployeeNames(updates: Record<string, string>): Promise<Record<string, string>> {
const merged = { ...employeeCache, ...updates };
employeeCache = merged;
employeeNames = merged;
const nextFullCache = { ...fullEmployeeCache, [PRIMARY_READER_INDEX]: merged };
fullEmployeeCache = nextFullCache;
await invoke('save_employee_cache', { cache: nextFullCache });
return merged;
}
async function onNewEmployeesContinue(list: { id: number; name: string }[]) {
const updates = Object.fromEntries(list.map((e) => [String(e.id), e.name]));
step = 'review';
await mergeEmployeeNames(updates);
}
async function onEmployeeRenamed(id: number, name: string) {
await mergeEmployeeNames({ [String(id)]: name });
}
/** Lets the user pick a destination (native "save" dialog) and writes the export there.
* One employee exports a single `.ods`; more than one bundles a `.zip` of `.ods` files.
* Returns `false` without writing anything if the user cancels the dialog. */
async function onExport(payload: {
scope: 'all' | 'one';
year: number;
month: number;
employees: Employee[];
headers: { date: string; in1: string; out1: string; in2: string; out2: string; total: string };
}): Promise<boolean> {
const multi = payload.employees.length > 1;
const extension = multi ? 'zip' : 'ods';
const who = multi ? $_('review.all_employees') : payload.employees[0].name;
const path = await save({
defaultPath: `${monthName($locale, payload.month)} ${payload.year} - ${who}.${extension}`,
filters: [{ name: extension.toUpperCase(), extensions: [extension] }]
});
if (!path) return false;
await invoke('export_timesheet', { payload, path });
return true;
}
</script>
{#if step === 'review'}
<TimesheetReview
year={selectedYear}
month={selectedMonth}
employees={reviewEmployees}
shift={shifts}
onback={() => requestLeaveReview(() => (step = 'confirm-month'))}
onexport={onExport}
onrename={onEmployeeRenamed}
ondirtychange={(dirty) => (reviewDirty = dirty)}
/>
{:else if step === 'new-employees'}
<NewEmployees employees={newEmployees} oncontinue={onNewEmployeesContinue} />
{:else if step === 'settings'}
<Settings
onback={closeSettings}
onsettingschange={(s: SettingsData) => {
shifts = s.shifts;
}}
/>
{:else}
<Screen>
{#if step === 'loading'}
<p>{$_('loading')}</p>
{:else if step === 'setup'}
<Onboarding onconnected={() => (step = 'confirm-month')} />
{:else if step === 'confirm-month'}
<SettingsButton ariaLabel={$_('settings.title')} onclick={openSettings} />
<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')} />
{/if}
</Screen>
{/if}
{#if pendingLeaveAction}
<ConfirmDialog
fixed
title={$_('review.leave_confirm_title')}
message={$_('review.leave_confirm_message')}
confirmLabel={$_('review.leave_anyway')}
cancelLabel={$_('review.cancel')}
onconfirm={confirmLeaveReview}
oncancel={cancelLeaveReview}
/>
{/if}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<rect id="Badge" width="856" height="539.8" x="72" y="230.1" fill="#f74c00" rx="34.8"/>
<g id="Oxi." fill="#fff">
<path id="." d="M734.101 594.267q-7.733 0-12-4-4-4.266-4-12v-4.533q0-7.733 4-11.733 4.267-4.267 12-4.267h3.467q7.733 0 11.733 4.267 4.267 4 4.267 11.733v4.533q0 7.734-4.267 12-4 4-11.733 4z"/>
<path id="i" d="M672.254 578.267q0 6.934-4.533 11.734-4.534 4.533-11.467 4.533t-11.467-4.533q-4.533-4.8-4.533-11.734V468.134q0-6.933 4.533-11.467 4.534-4.8 11.467-4.8t11.467 4.8q4.533 4.534 4.533 11.467zm-16.267-143.733q-9.066 0-12.8-2.933-3.733-2.934-3.733-10.4v-5.067q0-7.733 4-10.4 4.267-2.933 12.8-2.933 9.333 0 13.067 2.933t3.733 10.4v5.067q0 7.733-4 10.666-4 2.667-13.067 2.667"/>
<path id="x" d="M488.807 451.867q7.733 0 13.066 6.667L590.94 569.2q3.467 4.266 3.467 9.6 0 6.666-5.067 11.466-5.066 4.8-11.2 4.8-7.733 0-13.066-6.933l-89.067-110.667q-3.467-4.266-3.467-9.6 0-6.933 4.8-11.466 5.067-4.534 11.467-4.534m.266 142.934q-5.333 0-10.666-4-5.067-4.267-5.067-10.134 0-5.333 4-10.133l40.8-49.867 16 26.667-32.267 40.8q-5.066 6.667-12.8 6.667m88.8-142.934q6.4 0 10.667 4.534 4.534 4.266 4.534 9.866 0 2.667-.8 5.334t-2.934 5.066l-40.266 49.067-16.8-23.2 32.8-43.467q5.333-7.2 12.8-7.2"/>
<path id="O" d="M427.497 501.2q0 20-6.667 37.333t-18.933 30.667q-12 13.066-28.8 20.533-16.534 7.467-36.267 7.467T300.563 589.733t-28.8-20.533q-12-13.334-18.667-30.667-6.666-17.333-6.666-37.333t6.666-37.334 18.667-30.4q12.267-13.333 28.8-20.8 16.533-7.466 36.267-7.466 19.733 0 36.266 7.466 16.8 7.467 28.8 20.8 12.267 13.067 18.934 30.4 6.667 17.334 6.667 37.334m-34.667 0q0-17.867-7.2-32.267-7.2-14.667-19.734-23.2-12.533-8.533-29.066-8.533T307.763 445.733t-19.733 22.933q-6.934 14.4-6.934 32.534 0 17.866 6.934 32.533 7.2 14.4 19.733 22.933 12.533 8.534 29.067 8.534t29.066-8.534 19.734-22.933q7.2-14.667 7.2-32.533"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+18
View File
@@ -0,0 +1,18 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: "index.html",
}),
},
};
export default config;
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
// @ts-expect-error type error without @types/node package
import process from "node:process";
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(() => ({
plugins: [sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || "127.0.0.1",
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "r701"
version = "0.2.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
async-stream = "0.3.6"
chrono = { version = "0.4.38", default-features = false, features = ["clock"] }
serde = { version = "1.0.203", default-features = false, features = ["derive"], optional = true }
tokio = { version = "1.53.1", default-features = false, features = ["net", "io-util", "sync"] }
tokio-stream = { version = "0.1.19", default-features = false }
[features]
serde = ["chrono/serde", "dep:serde"]
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
unwrap_used = "deny"
enum_glob_use = { level = "deny", priority = 1 }
pedantic = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
[dev-dependencies]
tokio = { version = "1.53.1", features = ["rt", "macros", "net"] }
+68
View File
@@ -0,0 +1,68 @@
<div align="center">
# R701 🕰️
[![Rust](https://img.shields.io/badge/Rust-f74c00?logo=rust)](https://www.rust-lang.org)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-grey?logo=claude&logoColor=d97757)](https://claude.com/product/claude-code)
[![GNU AGPLv3.0 License](https://img.shields.io/badge/License-GNU%20AGPLv3.0-dark_green?logo=gnu)](https://choosealicense.com/licenses/agpl-3.0)
[![Buymeacoffee](https://img.shields.io/badge/Buymeacoffee-gray?logo=buymeacoffee)](https://buymeacoffee.com/nicolabelluti)
<br>
[![CI Badge](https://git.nicolabelluti.me/nicolabelluti/r701/actions/workflows/check-format-and-test.yaml/badge.svg)](https://git.nicolabelluti.me/nicolabelluti/r701/actions/?workflow=check-format-and-test.yaml)
</div><br>
> A reverse-engineered library to communicate with the
> [R701](https://ipsattendant.it/rilevatore-presenze-r701/) by [I.P.S.
> Informatica](https://ipsinformatica.info/), written in Rust
If you want to read about this reverse engineering attempt, check out
<https://nicolabelluti.me/series/attendance-reader/>.
## Library Usage
1. Add the library to you project:
```shell
cargo add r701 --git https://git.nicolabelluti.me/nicolabelluti/r701.git
```
2. Use the library in you project:
This library is async and built on [tokio](https://tokio.rs); add it to your
own `[dependencies]` (e.g. `tokio = { version = "1", features =
["rt-multi-thread", "macros"] }`) to run the example below.
```rust
use r701::R701;
use tokio_stream::StreamExt;
#[tokio::main]
async fn main() {
let r701 = R701::connect("127.0.0.1:5005").await.unwrap();
let mut record_stream = r701.into_record_iter().await.unwrap();
let mut id = 0;
println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t");
while let Some(record) = record_stream.next().await {
let record = record.unwrap();
let name = match r701.get_name(record.employee_id).await.unwrap() {
Some(Ok(name)) => name,
Some(Err(error)) => String::from_utf8_lossy(error.as_bytes()).into_owned(),
None => format!("user #{}", record.employee_id),
};
println!(
"{:0>6}\t{}\t{:0>9}\t{: <10}\t{}\t{}\t{}",
id + 1,
1,
record.employee_id,
name,
35,
record.clock as u8,
record.datetime.format("%Y/%m/%d %H:%M:%S"),
);
id += 1;
}
}
```
+13
View File
@@ -0,0 +1,13 @@
//! An async client library for the R701 attendance reader by I.P.S.
//! Informatica, reverse-engineered from its TCP protocol.
//!
//! Connect with [`R701::connect`], then read attendance records with
//! [`R701::into_record_iter`] and resolve employee names with
//! [`R701::get_name`].
mod r701;
mod record;
mod record_iterator;
pub use r701::R701;
pub use record::{Clock, Record};
pub use record_iterator::RecordIterator;
+232
View File
@@ -0,0 +1,232 @@
use crate::RecordIterator;
use std::io::{Error, ErrorKind::InvalidData, Result};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, ToSocketAddrs};
use tokio::sync::Mutex;
#[derive(Debug)]
struct ConnectionState {
tcp_stream: TcpStream,
sequence_number: u16,
}
/// A connection to an R701 attendance reader.
///
/// Create one with [`R701::connect`].
#[derive(Debug)]
pub struct R701 {
state: Mutex<ConnectionState>,
}
impl R701 {
/// Connects to an R701 device at `connection_info` and pings it to
/// make sure it's responding correctly.
///
/// # Errors
///
/// Returns `Err` if the TCP connection can't be established, or if the
/// initial ping doesn't get the expected response.
pub async fn connect(connection_info: impl ToSocketAddrs + Send) -> Result<Self> {
// Create a new R701 struct
let new = Self {
state: Mutex::new(ConnectionState {
tcp_stream: TcpStream::connect(connection_info).await?,
sequence_number: 0,
}),
};
// Try to ping the endpoint
new.ping().await?;
Ok(new)
}
/// Wraps `payload` in the device's request envelope (header and
/// sequence number) and sends it, then reads back exactly
/// `response_len` bytes of response.
///
/// # Errors
///
/// Returns `Err` if writing the request or reading the response fails,
/// including if the connection closes before `response_len` bytes have
/// been received.
// The lock must stay held for the whole write+read round-trip, so that
// concurrent callers can't interleave their requests on the wire
#[allow(clippy::significant_drop_tightening)]
pub async fn request(&self, payload: &[u8; 12], response_len: usize) -> Result<Vec<u8>> {
let mut state = self.state.lock().await;
// Create a blank request
let mut request = [0x55, 0xaa, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
// Insert the payload
request[2..14].clone_from_slice(payload);
// Insert the sequence number
request[14..].clone_from_slice(&state.sequence_number.to_le_bytes());
state.sequence_number = state.sequence_number.wrapping_add(1);
// Send the request
state.tcp_stream.write_all(&request).await?;
// Read exactly response_len bytes, looping over multiple reads if
// the response arrives split across several TCP packets
let mut buffer = vec![0u8; response_len];
state.tcp_stream.read_exact(&mut buffer).await?;
Ok(buffer)
}
/// Pings the device to check that it's connected and responding.
///
/// # Errors
///
/// Returns `Err` if the underlying request fails, or if the device
/// responds with anything other than the expected acknowledgement.
pub async fn ping(&self) -> Result<()> {
// Create a request with a payload of `01 80 00 00 00 00 00 00 00 00 00
// 00` and get the response
let response = self
.request(&[0x01, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 10)
.await?;
// If the response is not `aa 55 01 01 00 00 00 00 00 00` then return an
// error
if response != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0] {
return Err(Error::new(InvalidData, "Malformed response"));
}
Ok(())
}
/// Looks up the name of the employee with the given `id`.
///
/// Returns `Ok(None)` if no employee with that ID is registered on the
/// device. If one is registered but its name isn't valid UTF-8, the
/// inner `Err` carries the original bytes (via
/// `FromUtf8Error::as_bytes`), letting the caller choose to fail or
/// fall back to a lossy rendering.
///
/// # Errors
///
/// Returns `Err` if the underlying request fails, or if the response
/// has an unexpected header.
pub async fn get_name(
&self,
id: u32,
) -> Result<Option<std::result::Result<String, std::string::FromUtf8Error>>> {
// Create a request with a payload of `01 c7 xx xx xx xx 00 00 00 00 14
// 00`, where `xx xx xx xx` is the employee ID in little endian
let mut request = [0x01, 0xc7, 0, 0, 0, 0, 0, 0, 0, 0, 0x14, 0];
request[2..6].clone_from_slice(&id.to_le_bytes());
// Get the response
let response = self.request(&request, 34).await?;
// If the header is `01 00 00 00 00 00 00 00` then the request has
// been succesful but the name was not found
if response[..12] == [0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa] {
return Ok(None);
}
// If the response header is wrong return an error
if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa] {
return Err(Error::new(InvalidData, "Malformed response"));
}
// Get the name as a UTF-8 string and delete the `\0` at the end
let name = match String::from_utf8(response[12..22].to_vec()) {
Ok(name) => name.trim_end_matches('\0').to_string(),
Err(error) => return Ok(Some(Err(error))),
};
// Return None if the name is empty, else return the name wrapped into a
// Some
Ok(Some(name).filter(|name| !name.is_empty()).map(Ok))
}
/// Returns the total number of attendance records currently stored on
/// the device.
///
/// # Errors
///
/// Returns `Err` if the underlying request fails, or if the response
/// has an unexpected header.
pub async fn get_total_record_count(&self) -> Result<u16> {
// Create a request with a payload of `01 b4 08 00 00 00 00 00 ff ff 00
// 00` and get the response
let response = self
.request(&[0x01, 0xb4, 0x08, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0], 10)
.await?;
// If one between the response header or the trailing bytes is wrong
// return an error
if response[..4] != [0xaa, 0x55, 0x01, 0x01] || response[6..] != [0u8; 4] {
return Err(Error::new(InvalidData, "Malformed response"));
}
// Convert the payload into a u16 and return it
let mut record_count_le = [0u8; 2];
record_count_le.clone_from_slice(&response[4..6]);
Ok(u16::from_le_bytes(record_count_le))
}
/// Fetches one 1024-byte block of raw record data.
///
/// `sequence_number` selects which block to fetch, except that `0`
/// always fetches the first block regardless of `total_records`,
/// following the device's own request convention. Used internally by
/// [`RecordIterator`] — most callers should use
/// [`R701::into_record_iter`] instead.
///
/// # Errors
///
/// Returns `Err` if the underlying request fails, or if the response
/// has an unexpected header or trailing bytes.
pub async fn get_record_bytes(
&self,
total_records: u16,
sequence_number: u16,
) -> Result<Vec<u8>> {
// Create a request with a payload of `01 a4 00 00 00 00 xx xx xx xx 00
// 04`, where `xx xx xx xx` changes meaning based on the sequence_number
// value
let mut request: [u8; 12] = [0x01, 0xa4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x04];
// If sequence_number is 0 then `xx xx xx xx` is `xx xx 00 00`, where
// `xx xx` is total_records in little endian
//
// Else `xx xx xx xx` is `00 00 xx xx`, where `xx xx` is sequence_number
// in little endian
if sequence_number == 0 {
request[6..8].clone_from_slice(&total_records.to_le_bytes());
} else {
request[8..10].clone_from_slice(&sequence_number.to_le_bytes());
}
// Get the response
let response = self.request(&request, 1038).await?;
// If one between the response header or the last two bits is wrong
// return an error
if response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|| response[1036..] != [0, 0]
{
return Err(Error::new(InvalidData, "Malformed response"));
}
// Return only the payload bits as a vector
Ok(response[12..1036].to_vec())
}
/// Starts streaming all attendance records currently stored on the
/// device, newest first — fetching the most recent records first is
/// faster when you don't need the full history; reverse the order
/// yourself if you need chronological output.
///
/// # Errors
///
/// Returns `Err` if fetching the total record count or the first block
/// of record data fails.
pub async fn into_record_iter(&self) -> Result<RecordIterator<'_>> {
RecordIterator::from(self).await
}
}
+21 -9
View File
@@ -2,33 +2,45 @@ use chrono::{DateTime, Local, TimeZone};
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] /// Which clock an employee punched: entering or leaving, for one of the
/// device's two configured time slots.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum Clock { pub enum Clock {
#[default] /// Entering during the first time slot
FirstIn, FirstIn,
/// Leaving during the first time slot
FirstOut, FirstOut,
/// Entering during the second time slot
SecondIn, SecondIn,
/// Leaving during the second time slot
SecondOut, SecondOut,
} }
// unsafe impl Send for Clock {} /// A single attendance record read from the device.
// unsafe impl Sync for Clock {} ///
/// Identifies the employee who punched, whether they were entering or
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] /// leaving, and when.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Record { pub struct Record {
/// The employee's numeric ID, as configured on the device
pub employee_id: u32, pub employee_id: u32,
/// Whether the employee was entering or leaving
pub clock: Clock, pub clock: Clock,
/// When the record was punched
pub datetime: DateTime<Local>, pub datetime: DateTime<Local>,
} }
// unsafe impl Send for Record {}
// unsafe impl Sync for Record {}
impl TryFrom<&[u8]> for Record { impl TryFrom<&[u8]> for Record {
type Error = &'static str; type Error = &'static str;
/// Parses a single 12-byte record as read from the device.
///
/// # Errors
///
/// Returns `Err` if `record_bytes` isn't exactly 12 bytes long, or if
/// the packed date/time bits don't form a valid calendar date.
fn try_from(record_bytes: &[u8]) -> Result<Self, Self::Error> { fn try_from(record_bytes: &[u8]) -> Result<Self, Self::Error> {
// Return an error if the slice length is less than 12 // Return an error if the slice length is less than 12
if record_bytes.len() != 12 { if record_bytes.len() != 12 {
+95
View File
@@ -0,0 +1,95 @@
use crate::{Record, R701};
use std::io::{Error, ErrorKind::InvalidData, Result};
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio_stream::Stream;
/// An async stream of [`Record`]s read from an [`R701`] device.
///
/// Create one with [`R701::into_record_iter`]. Implements [`Stream`], so
/// consume it with [`tokio_stream::StreamExt::next`] (or any other
/// `Stream` combinator).
pub struct RecordIterator<'a> {
inner: Pin<Box<dyn Stream<Item = Result<Record>> + Send + 'a>>,
}
impl<'a> RecordIterator<'a> {
/// Builds a new record stream, immediately fetching the device's total
/// record count and the first block of record data.
///
/// # Errors
///
/// Returns `Err` if fetching the total record count or the first block
/// of record data fails.
pub async fn from(r701: &'a R701) -> Result<Self> {
// Get the total number of records
let total_records = r701.get_total_record_count().await?;
// Calculate the sequence number on which the last record resides and
// the index of the first `ff` byte. The multiplication is done in
// u32 to avoid overflowing total_records * 12 (up to 786_420); the
// division result (at most 786_420 / 1024 = 768) always fits back
// into a u16, so the truncating cast below can never actually lose
// data
#[allow(clippy::cast_possible_truncation)]
let mut sequence_number = (u32::from(total_records) * 12 / 1024) as u16;
let first_useless_byte_index = total_records as usize * 12 % 1024;
// The endpoint expects the first block of records to be sent first
r701.get_record_bytes(total_records, 0).await?;
// Get the last records and cut out all the trailing `ff` bytes
let mut input_buffer = r701
.get_record_bytes(total_records, sequence_number)
.await?;
input_buffer.truncate(first_useless_byte_index);
let mut record_count = total_records;
// Build the record stream, yielding an `Err` both on network errors and
// on malformed records, so the caller can fail
let inner = async_stream::try_stream! {
while record_count != 0 {
record_count -= 1;
// If the input buffer is almost empty, make another request to
// the endpoint asking for more data
if input_buffer.len() < 12 {
// If the buffer is almost empty but the sequence number is
// already zero, stop iterating
if sequence_number == 0 {
break;
}
sequence_number -= 1;
// Request new bytes
let bytes = r701.get_record_bytes(total_records, sequence_number).await?;
// Put the bytes at the start of the vector
input_buffer.splice(0..0, bytes);
}
// Yield a new Record
yield input_buffer
.drain(input_buffer.len() - 12..)
.as_slice()
.try_into()
.map_err(|error: &str| Error::new(InvalidData, error))?;
}
};
// Return a new Iterator
Ok(Self {
inner: Box::pin(inner),
})
}
}
impl Stream for RecordIterator<'_> {
type Item = Result<Record>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.inner.as_mut().poll_next(cx)
}
}
+62
View File
@@ -0,0 +1,62 @@
//! End-to-end tests exercising R701's public API against the emulated
//! device in `mock`.
mod mock;
use r701::R701;
use tokio_stream::StreamExt;
#[tokio::test]
async fn connects_and_pings_successfully() {
let addr = mock::spawn().await;
assert!(R701::connect(addr).await.is_ok());
}
#[tokio::test]
#[allow(clippy::unwrap_used)]
async fn gets_employee_name() {
let addr = mock::spawn().await;
let r701 = R701::connect(addr).await.unwrap();
let expected_name = mock::fake_users()[&1].clone();
match r701.get_name(1).await.unwrap() {
Some(Ok(name)) => assert_eq!(name, expected_name),
other => panic!("expected Some(Ok({expected_name:?})), got {other:?}"),
}
}
#[tokio::test]
#[allow(clippy::unwrap_used)]
async fn gets_none_for_unknown_employee() {
let addr = mock::spawn().await;
let r701 = R701::connect(addr).await.unwrap();
assert_eq!(r701.get_name(999).await.unwrap(), None);
}
#[tokio::test]
#[allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
async fn gets_total_record_count() {
let addr = mock::spawn().await;
let r701 = R701::connect(addr).await.unwrap();
let expected_count = mock::fake_records().len() as u16;
assert_eq!(r701.get_total_record_count().await.unwrap(), expected_count);
}
#[tokio::test]
#[allow(clippy::unwrap_used)]
async fn streams_records() {
let addr = mock::spawn().await;
let r701 = R701::connect(addr).await.unwrap();
// The device streams newest first, the reverse of fake_records' order
let expected_records: Vec<_> = mock::fake_records().into_iter().rev().collect();
let mut records = r701.into_record_iter().await.unwrap();
for expected in expected_records {
let record = records.next().await.unwrap().unwrap();
assert_eq!(record, expected);
}
assert!(records.next().await.is_none());
}
+174
View File
@@ -0,0 +1,174 @@
//! Emulates an R701 device so `tests/e2e.rs` can exercise the real `R701`
//! client against it, grounded in the protocol documented at
//! <https://nicolabelluti.me/posts/2024/05/studying-a-communication-protocol/>.
use chrono::{Datelike, Duration, Local, TimeZone, Timelike};
use r701::{Clock, Record};
use std::collections::HashMap;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
/// The employee names the fake device reports
pub fn fake_users() -> HashMap<u32, String> {
HashMap::from([(1, "alice".to_string()), (2, "bob".to_string())])
}
/// The attendance records the fake device reports: a full year of classic
/// workday punches (09:00 - 13:00, 14:00 - 18:00) for the employees in
/// `fake_users()`, staggered by punch time rather than grouped by employee.
#[allow(clippy::unwrap_used)]
pub fn fake_records() -> Vec<Record> {
let first_day = Local
.with_ymd_and_hms(2026, 1, 1, 9, 0, 0)
.single()
.unwrap();
let mut employee_ids: Vec<u32> = fake_users().into_keys().collect();
employee_ids.sort_unstable();
let mut records = Vec::new();
for day in 0..365 {
let morning_in = first_day + Duration::days(day);
let morning_out = morning_in + Duration::hours(4);
let afternoon_in = morning_out + Duration::hours(1);
let afternoon_out = afternoon_in + Duration::hours(4);
for (clock, datetime) in [
(Clock::FirstIn, morning_in),
(Clock::FirstOut, morning_out),
(Clock::SecondIn, afternoon_in),
(Clock::SecondOut, afternoon_out),
] {
for &employee_id in &employee_ids {
records.push(Record {
employee_id,
clock,
datetime,
});
}
}
}
records
}
/// Starts a fake R701 device in the background and returns the address to
/// connect to.
#[allow(clippy::unwrap_used)]
pub async fn spawn() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
loop {
let mut request = [0u8; 16];
let Ok(bytes_read) = socket.read(&mut request).await else {
return;
};
if bytes_read == 0 {
return;
}
let response = if bytes_read < 16 {
vec![0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0]
} else if request[..2] != [0x55, 0xaa] {
continue;
} else {
let payload = &request[2..14];
match (payload[0], payload[1]) {
(0x01, 0x80) => vec![0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0],
(0x01, 0xc7) => get_name_response(payload),
(0x01, 0xb4) => get_total_record_count_response(),
(0x01, 0xa4) => get_record_bytes_response(payload),
_ => vec![0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0],
}
};
if socket.write_all(&response).await.is_err() {
return;
}
}
});
addr
}
/// Builds the 34-byte get-name response: a 12-byte header (whether the
/// employee was found), followed by their name (if found) null-padded to
/// 10 bytes, followed by 12 don't-care bytes.
#[allow(clippy::unwrap_used)]
fn get_name_response(payload: &[u8]) -> Vec<u8> {
let employee_id = u32::from_le_bytes(payload[2..6].try_into().unwrap());
let mut response = [0u8; 34];
if let Some(name) = fake_users().get(&employee_id) {
response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
let name_bytes = name.as_bytes();
let len = name_bytes.len().min(10);
response[12..12 + len].clone_from_slice(&name_bytes[..len]);
} else {
response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
}
response.to_vec()
}
/// Builds the 10-byte get-total-record-count response.
#[allow(clippy::cast_possible_truncation)]
fn get_total_record_count_response() -> Vec<u8> {
let count = fake_records().len() as u16;
let mut response = [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0];
response[4..6].clone_from_slice(&count.to_le_bytes());
response.to_vec()
}
/// Builds the 1038-byte get-record-bytes response: a 12-byte header, the
/// requested 1024-byte block of records (`ff`-padded past the last real
/// one, since records don't align to 1024-byte block boundaries), and 2
/// trailing bytes.
#[allow(clippy::unwrap_used)]
fn get_record_bytes_response(payload: &[u8]) -> Vec<u8> {
let sequence_number = u16::from_le_bytes(payload[8..10].try_into().unwrap());
let all_bytes: Vec<u8> = fake_records().iter().flat_map(encode_record).collect();
let start = sequence_number as usize * 1024;
let mut block = [0xffu8; 1024];
let available = all_bytes.len().saturating_sub(start).min(1024);
block[..available].clone_from_slice(&all_bytes[start..start + available]);
let mut response = vec![0u8; 1038];
response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
response[12..1036].clone_from_slice(&block);
response
}
/// Encodes a [`Record`] back into its 12-byte wire representation (the
/// inverse of `Record`'s `TryFrom<&[u8]>`). The "unknown" bytes that
/// `Record` doesn't decode are left as 0, since they only need to
/// round-trip through our own parsing, not match a real device capture.
fn encode_record(record: &Record) -> [u8; 12] {
let mut bytes = [0u8; 12];
let clock_bits: u8 = match record.clock {
Clock::FirstIn => 0,
Clock::FirstOut => 1,
Clock::SecondIn => 2,
Clock::SecondOut => 3,
};
bytes[1] = clock_bits << 6;
bytes[4..8].copy_from_slice(&record.employee_id.to_le_bytes());
#[allow(clippy::cast_sign_loss)]
let year = record.datetime.year() as u32 & 0x0fff;
let month = record.datetime.month() & 0x0f;
let day = record.datetime.day() & 0x1f;
let hour = record.datetime.hour() & 0x1f;
let minute = record.datetime.minute() & 0x3f;
let packed = year | (month << 12) | (day << 16) | (hour << 21) | (minute << 26);
bytes[8..12].copy_from_slice(&packed.to_le_bytes());
bytes
}
-6
View File
@@ -1,6 +0,0 @@
mod r701;
mod record;
mod record_iterator;
pub use r701::R701;
pub use record::{Clock, Record};
pub use record_iterator::RecordIterator;
-152
View File
@@ -1,152 +0,0 @@
use crate::RecordIterator;
use std::io::{BufRead, BufReader, Error, ErrorKind::InvalidData, Result, Write};
use std::net::{TcpStream, ToSocketAddrs};
#[derive(Debug)]
pub struct R701 {
tcp_stream: TcpStream,
sequence_number: u16,
}
impl R701 {
pub fn connect(connection_info: impl ToSocketAddrs) -> Result<Self> {
// Create a new R701 struct
let mut new = Self {
tcp_stream: TcpStream::connect(connection_info)?,
sequence_number: 0,
};
// Try to ping the endpoint
new.ping()?;
Ok(new)
}
pub fn request(&mut self, payload: &[u8; 12]) -> Result<Vec<u8>> {
// Create a blank request
let mut request = [0x55, 0xaa, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
// Insert the payload
request[2..14].clone_from_slice(payload);
// Insert the sequence number
request[14..].clone_from_slice(&self.sequence_number.to_le_bytes());
self.sequence_number += 1;
// Send the request
self.tcp_stream.write_all(&request)?;
// Create a buffer and return the response
let mut buffer = BufReader::new(&self.tcp_stream);
Ok(buffer.fill_buf()?.to_vec())
}
pub fn ping(&mut self) -> Result<()> {
// Create a request with a payload of `01 80 00 00 00 00 00 00 00 00 00
// 00` and get the response
let response = self.request(&[0x01, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])?;
// If the response is not `aa 55 01 01 00 00 00 00 00 00` then return an
// error
if response != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0] {
return Err(Error::new(InvalidData, "Malformed response"));
}
Ok(())
}
pub fn get_name(&mut self, id: u32) -> Result<Option<String>> {
// Create a request with a payload of `01 c7 xx xx xx xx 00 00 00 00 14
// 00`, where `xx xx xx xx` is the employee ID in little endian
let mut request = [0x01, 0xc7, 0, 0, 0, 0, 0, 0, 0, 0, 0x14, 0];
request[2..6].clone_from_slice(&id.to_le_bytes());
// Get the response
let response = self.request(&request)?;
// If the response length is right but the header is `01 00 00 00 00 00
// 00 00` then the request is been succesful but the name was not found
if response.len() == 34
&& response[..12] == [0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
{
return Ok(None);
}
// If one between the response length or the response header is wrong
// return an error
if response.len() != 34
|| response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
{
return Err(Error::new(InvalidData, "Malformed response"));
}
// Get the name as a UTF-8 string and delete the `\0` at the end
let name = String::from_utf8_lossy(&response[12..22])
.trim_end_matches('\0')
.to_string();
// Return None if the name is empty, else return the name wrapped into a
// Some
Ok(Some(name).filter(|name| !name.is_empty()))
}
pub fn get_total_record_count(&mut self) -> Result<u16> {
// Create a request with a payload of `01 b4 08 00 00 00 00 00 ff ff 00
// 00` and get the response
let response = self.request(&[0x01, 0xb4, 0x08, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0])?;
// If one between the response length or the response header is wrong
// return an error
if response.len() != 10
|| response[..4] != [0xaa, 0x55, 0x01, 0x01]
|| response[6..] != [0u8; 4]
{
return Err(Error::new(InvalidData, "Malformed response"));
}
// Convert the payload into a u16 and return it
let mut record_count_le = [0u8; 2];
record_count_le.clone_from_slice(&response[4..6]);
Ok(u16::from_le_bytes(record_count_le))
}
pub fn get_record_bytes(
&mut self,
total_records: u16,
sequence_number: u16,
) -> Result<Vec<u8>> {
// Create a request with a payload of `01 a4 00 00 00 00 xx xx xx xx 00
// 04`, where `xx xx xx xx` changes meaning based on the sequence_number
// value
let mut request: [u8; 12] = [0x01, 0xa4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x04];
// If sequence_number is 0 then `xx xx xx xx` is `xx xx 00 00`, where
// `xx xx` is total_records in little endian
//
// Else `xx xx xx xx` is `00 00 xx xx`, where `xx xx` is sequence_number
// in little endian
if sequence_number == 0 {
request[6..8].clone_from_slice(&total_records.to_le_bytes());
} else {
request[8..10].clone_from_slice(&sequence_number.to_le_bytes());
}
// Get the response
let response = self.request(&request)?;
// If one between the response length, the response header or the last
// two bits is wrong return an error
if response.len() != 1038
|| response[..12] != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]
|| response[1036..] != [0, 0]
{
return Err(Error::new(InvalidData, "Malformed response"));
}
// Return only the payload bits as a vector
Ok(response[12..1036].to_vec())
}
pub fn into_record_iter(&mut self) -> Result<RecordIterator> {
RecordIterator::from(self)
}
}
-86
View File
@@ -1,86 +0,0 @@
use crate::{Record, R701};
use std::io::Result;
#[derive(Debug)]
pub struct RecordIterator<'a> {
r701: &'a mut R701,
input_buffer: Vec<u8>,
sequence_number: u16,
total_records: u16,
record_count: u16,
}
impl<'a> RecordIterator<'a> {
pub fn from(r701: &'a mut R701) -> Result<Self> {
// Get the total number of records
let total_records = r701.get_total_record_count()?;
// Calculate the sequence number on which the last record resides and
// the index of the first `ff` byte, avoiding overflows
//
// TODO: Find a better way to do the multiplication and the division
// avoiding overflows
#[allow(clippy::cast_possible_truncation)]
let sequence_number = (u32::from(total_records) * 12 / 1024) as u16;
let first_useless_byte_index = total_records as usize * 12 % 1024;
// The endpoint expects the first block of records to be sent first
r701.get_record_bytes(total_records, 0)?;
// Get the last records and cut out all the trailing `ff` bytes
let input_buffer = r701
.get_record_bytes(total_records, sequence_number)?
.drain(..first_useless_byte_index)
.collect::<Vec<u8>>();
// Return a new Iterator
Ok(Self {
r701,
input_buffer,
sequence_number,
total_records,
record_count: total_records,
})
}
}
impl<'a> Iterator for RecordIterator<'a> {
type Item = Record;
fn next(&mut self) -> Option<Self::Item> {
// Stop iterating when there are no more records
if self.record_count == 0 {
return None;
}
self.record_count -= 1;
// If the input buffer is almost empty, make another request to the
// endpoint asking for more data
if self.input_buffer.len() < 12 {
// If the buffer is almost empty but the sequence number is already
// zero, stop iterating
if self.sequence_number == 0 {
return None;
}
self.sequence_number -= 1;
// Request new bytes
let bytes = self
.r701
.get_record_bytes(self.total_records, self.sequence_number)
.ok()?;
// Put the bytes at the start of the vector
self.input_buffer.splice(0..0, bytes.iter().copied());
}
// Return a new Record
self.input_buffer
.drain(self.input_buffer.len() - 12..)
.as_slice()
.try_into()
.ok()
}
}