diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42223e1..af9852b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,7 @@ jobs: cache: pnpm - run: corepack pnpm install --frozen-lockfile - run: corepack pnpm run license:check + - run: corepack pnpm test - run: corepack pnpm run build rust-format: diff --git a/docs/provenance.md b/docs/provenance.md index c29dd35..8fe1a58 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -37,6 +37,16 @@ Do not replace or add an icon, font, screenshot, fixture, or other asset unless its source, contributor authority, license, and required attribution are added to this record or `NOTICE`. +## Fixture byte-integrity boundary + +`scripts/check-fixture-byte-integrity.mjs` compares local worktree bytes with +the committed blob and verifies that every registered fixture location opts out +of Git text normalization. In CI, a normal checkout materialises the worktree +from that same blob, so the byte comparison cannot independently prove capture +fidelity there; the Git attribute check is the meaningful CI protection. This +does not establish SHA-256 or byte-length assertions for provenance-listed +fixtures. + ## Third-party material - JavaScript production dependencies are locked by `pnpm-lock.yaml` and listed diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index dc6b52e..09099f4 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "ded2577b0f66b63d6ea65fe3d315d69dce68b0fa930f2b6d9106b7e0b52b526d", + "compatibility_surface_sha256": "225d2dfef642da0ee454a240247d616eba2ed60e19fa139ebbc5d9cff06fe0c1", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 7cbd46f..0bd5f7b 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -3,7 +3,7 @@ "files": [ { "path": ".github/workflows/ci.yml", - "sha256": "309fe37d7038a4a1eb6197edf32f652af50ded2017a55d55466e577b878b05ed" + "sha256": "04c2796dc7b382a0582c82d0bc86f3b9b819b7bf46df6cf23a36b385f0f2e832" }, { "path": ".github/workflows/dependency-security.yml", @@ -71,7 +71,7 @@ }, { "path": "package.json", - "sha256": "bc58b4532fc7279bdbaef868ff4ecda87fcb353647a2cbac7dd7cd06ee423cf3" + "sha256": "d6a5107c4c556fbf7e494f98b743d737cc47fa8bd01cecdc942066c53b4d5e86" }, { "path": "scripts/check-tally-live-read-boundary.mjs", @@ -99,7 +99,7 @@ }, { "path": "src-tauri/Cargo.toml", - "sha256": "3bb11f1d204fa55443e80686a1def19210b8756ace49585cb308b27e649b52a1" + "sha256": "c9bd8222ce43a2c6eb09efd4c61c2ac8093c581806a8b8776f3f5c42e5130ea3" }, { "path": "src-tauri/crates/bridge-tally-core/Cargo.toml", @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "8d13e54b487bf27984ed988abee126d90e63a21a72d12aa21a819796bff4c569" + "sha256": "1788dbf6f84c47399ce645989468e93b5b9d41e59cfac11639d3caa96bf3adb7" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -303,7 +303,7 @@ }, { "path": "src-tauri/src/lib.rs", - "sha256": "5582994d94163235a885bce0d44eadc11b6d8eb9a36898610e94c2b1141efbae" + "sha256": "5a2e2c35da52a7c3b01c5810882a1eb3a9ea45fac82556b1eee1a715989585b9" }, { "path": "src-tauri/src/sync/coordinator.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "ded2577b0f66b63d6ea65fe3d315d69dce68b0fa930f2b6d9106b7e0b52b526d" + "manifest_sha256": "225d2dfef642da0ee454a240247d616eba2ed60e19fa139ebbc5d9cff06fe0c1" } diff --git a/package.json b/package.json index b5066cd..533cb63 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ }, "scripts": { "dev": "vite --host 127.0.0.1", - "build": "node --experimental-strip-types --test scripts/*.test.mjs && tsc && vite build", + "test": "node --experimental-strip-types --test scripts/*.test.mjs", + "build": "tsc && vite build", "check": "pnpm run build && pnpm run cargo:check", "tauri": "tauri", "tauri:dev": "tauri dev", diff --git a/scripts/check-dependency-inventory.mjs b/scripts/check-dependency-inventory.mjs index 554b84d..d2ce5ca 100644 --- a/scripts/check-dependency-inventory.mjs +++ b/scripts/check-dependency-inventory.mjs @@ -10,13 +10,9 @@ const checkFrontend = modes.size === 0 || modes.has("--frontend"); const checkRust = modes.size === 0 || modes.has("--rust"); const firstPartyRustPackages = new Set([ "bridge", - "bridge-tally-canonical", "bridge-tally-core", - "bridge-tally-incremental", - "bridge-tally-observability", "bridge-tally-primitives", "bridge-tally-protocol", - "bridge-tally-runtime", "bridge-tally-transport", "tally-protocol-simulator", ]); diff --git a/scripts/check-fixture-byte-integrity.mjs b/scripts/check-fixture-byte-integrity.mjs index 8ec68b4..d3e7a4b 100644 --- a/scripts/check-fixture-byte-integrity.mjs +++ b/scripts/check-fixture-byte-integrity.mjs @@ -11,6 +11,23 @@ const fixtureDirectories = [ "src-tauri/crates/tally-protocol-simulator/fixtures", "docs/tally/compatibility/fixtures", ]; +const repositoryDirectories = walkDirectories(repositoryRoot) + .map((directory) => relative(repositoryRoot, directory).replaceAll("\\", "/")); +const ignoredDirectories = gitIgnoredPaths(repositoryDirectories); +const discoveredFixtureDirectories = repositoryDirectories + .filter((directory) => !ignoredDirectories.has(directory)) + .filter((directory) => directory.endsWith("/fixture") || directory.endsWith("/fixtures")) + .sort(); +const unexpectedFixtureDirectories = discoveredFixtureDirectories.filter( + (directory) => !fixtureDirectories.includes(directory), +); +if (unexpectedFixtureDirectories.length) { + throw new Error( + "unexpected fixture directories are not covered by byte-integrity policy:\n" + + unexpectedFixtureDirectories.map((directory) => `- ${directory}`).join("\n") + + "\nRegister each directory in fixtureDirectories and .gitattributes before adding fixtures.", + ); +} const fixtures = fixtureDirectories .flatMap((directory) => { const paths = walkFiles(join(repositoryRoot, directory)); @@ -40,6 +57,10 @@ if (attributeFailures.length) { const byteFailures = []; for (const fixture of fixtures) { + // In CI the checkout materialises this file from the same HEAD blob, so this + // comparison cannot independently establish captured-byte provenance there. + // The checked .gitattributes rule is the meaningful CI protection; this + // comparison still catches local worktree conversion or mutation. const committed = runGit(["show", "--no-textconv", `HEAD:${fixture}`], { allowFailure: true }); if (committed.status !== 0) { // A new fixture has no blob until its first commit. Attribute coverage still @@ -72,9 +93,38 @@ function walkFiles(directory) { return paths; } -function runGit(args, { allowFailure = false } = {}) { +function walkDirectories(directory) { + const directories = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!entry.isDirectory() || [".git", "node_modules", "target"].includes(entry.name)) continue; + const path = join(directory, entry.name); + directories.push(path, ...walkDirectories(path)); + } + return directories; +} + +function gitIgnoredPaths(paths) { + if (!paths.length) return new Set(); + const result = runGit(["check-ignore", "-z", "--stdin"], { + allowFailure: true, + input: `${paths.join("\0")}\0`, + }); + if (result.status !== 0 && result.status !== 1) { + throw new Error(`git check-ignore failed with status ${result.status}`); + } + return new Set( + result.stdout + .toString("utf8") + .split("\0") + .filter(Boolean) + .map((path) => path.replaceAll("\\", "/")), + ); +} + +function runGit(args, { allowFailure = false, input } = {}) { const result = spawnSync("git", args, { cwd: repositoryRoot, + input, maxBuffer: 64 * 1024 * 1024, windowsHide: true, }); diff --git a/scripts/check-fixture-byte-integrity.test.mjs b/scripts/check-fixture-byte-integrity.test.mjs new file mode 100644 index 0000000..ba8023e --- /dev/null +++ b/scripts/check-fixture-byte-integrity.test.mjs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); + +test("an unregistered fixture directory fails the byte-integrity gate", async () => { + const directory = await mkdtemp(join(root, ".fixture-integrity-")); + const fixtureDirectory = join(directory, "fixtures"); + + try { + await mkdir(fixtureDirectory, { recursive: true }); + await writeFile(join(fixtureDirectory, "synthetic.xml"), "\n"); + + let failure = null; + try { + execFileSync(process.execPath, ["scripts/check-fixture-byte-integrity.mjs"], { + cwd: root, + encoding: "utf8", + stdio: "pipe", + }); + } catch (cause) { + failure = cause; + } + + assert.ok(failure, "an unregistered fixture directory must fail the gate"); + assert.match(`${failure.stderr}${failure.stdout}`, /unexpected fixture directories/); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test("a git-ignored fixture directory is outside the byte-integrity inventory", async () => { + const ignoredRoot = join(root, "dist"); + await mkdir(ignoredRoot, { recursive: true }); + const directory = await mkdtemp(join(ignoredRoot, ".fixture-integrity-")); + const fixtureDirectory = join(directory, "fixtures"); + + try { + await mkdir(fixtureDirectory, { recursive: true }); + await writeFile(join(fixtureDirectory, "synthetic.xml"), "\n"); + assert.doesNotThrow(() => execFileSync( + process.execPath, + ["scripts/check-fixture-byte-integrity.mjs"], + { cwd: root, encoding: "utf8", stdio: "pipe" }, + )); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/scripts/client-grouping.test.mjs b/scripts/client-grouping.test.mjs index 6805f40..88b8784 100644 --- a/scripts/client-grouping.test.mjs +++ b/scripts/client-grouping.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { applyClientGroupLabel, groupClientRows, rollbackFailedClientGroupLabel, sumExactDecimals } from "../src/client-grouping.ts"; +import { applyClientGroupLabel, groupClientRows, reconcileLoadedSortPreference, rollbackFailedClientGroupLabel, sumExactDecimals } from "../src/client-grouping.ts"; test("a failed optimistic label save restores only the value that actually failed", () => { const persisted = { "synthetic-company-guid": "Original" }; @@ -27,6 +27,13 @@ test("a failed optimistic label save restores only the value that actually faile ); }); +test("a late preference load cannot overwrite a sort chosen during startup", () => { + const current = { key: "client", desc: false }; + const persisted = { key: "overdue", desc: true }; + assert.deepEqual(reconcileLoadedSortPreference(current, persisted, true), current); + assert.deepEqual(reconcileLoadedSortPreference(current, persisted, false), persisted); +}); + test("applying a group label preserves every company figure byte-for-byte", () => { const row = { companyGuid: "synthetic-company-guid", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d5f044e..1259dd9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -94,4 +94,4 @@ zip = { version = "8.6.0", default-features = false, features = ["deflate"] } libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects", "Win32_System_Threading"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading"] } diff --git a/src-tauri/src/client_groups.rs b/src-tauri/src/client_groups.rs index f84588c..2f37c35 100644 --- a/src-tauri/src/client_groups.rs +++ b/src-tauri/src/client_groups.rs @@ -1,11 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -//! Operator-owned company filing labels. +//! Operator-owned company filing labels and all-client sort preference. //! //! These labels deliberately live in the ordinary application configuration //! directory, not in the encrypted Tally mirror. They are an operator's filing -//! choice rather than accounting data, and reading them must never resolve the -//! mirror key or prompt the operating-system keychain. +//! choice rather than accounting data. They contain neither accounting figures +//! nor mirror state, and reading them must never resolve the mirror key or +//! prompt the operating-system keychain. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -14,9 +15,28 @@ use std::path::{Path, PathBuf}; const FILE_NAME: &str = "client-group-labels-v1.json"; const SCHEMA_VERSION: u8 = 1; +const SORT_FILE_NAME: &str = "client-sort-preference-v1.json"; +const SORT_SCHEMA_VERSION: u8 = 1; pub type ClientGroupLabels = BTreeMap; +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ClientSortKey { + Client, + Receivable, + Overdue, + Unallocated, + Oldest, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientSortPreference { + pub key: ClientSortKey, + pub desc: bool, +} + #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct ClientGroupLabelsFile { @@ -24,6 +44,13 @@ struct ClientGroupLabelsFile { labels: ClientGroupLabels, } +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ClientSortPreferenceFile { + version: u8, + sort: ClientSortPreference, +} + #[derive(Debug, thiserror::Error)] pub enum ClientGroupLabelsError { #[error("client group label file could not be read")] @@ -42,6 +69,15 @@ pub fn load(directory: &Path) -> ClientGroupLabels { try_load(directory).unwrap_or_default() } +pub fn load_sort_preference(directory: &Path) -> Option { + let contents = std::fs::read_to_string(directory.join(SORT_FILE_NAME)).ok()?; + if contents.trim().is_empty() { + return None; + } + let file = serde_json::from_str::(&contents).ok()?; + (file.version == SORT_SCHEMA_VERSION).then_some(file.sort) +} + fn try_load(directory: &Path) -> Result { let contents = match std::fs::read_to_string(directory.join(FILE_NAME)) { Ok(contents) => contents, @@ -80,19 +116,34 @@ pub fn save_label( labels.insert(company_guid.to_string(), label.to_string()); } - std::fs::create_dir_all(directory).map_err(ClientGroupLabelsError::Write)?; - let path = directory.join(FILE_NAME); let contents = serde_json::to_vec_pretty(&ClientGroupLabelsFile { version: SCHEMA_VERSION, labels, }) .expect("client group label schema always serializes"); - write_file_atomically(&path, &contents).map_err(ClientGroupLabelsError::Write)?; + save_bytes(directory, FILE_NAME, &contents).map_err(ClientGroupLabelsError::Write) +} + +pub fn save_sort_preference( + directory: &Path, + sort: ClientSortPreference, +) -> Result<(), std::io::Error> { + let contents = serde_json::to_vec_pretty(&ClientSortPreferenceFile { + version: SORT_SCHEMA_VERSION, + sort, + }) + .expect("client sort preference schema always serializes"); + save_bytes(directory, SORT_FILE_NAME, &contents) +} + +fn save_bytes(directory: &Path, file_name: &str, contents: &[u8]) -> Result<(), std::io::Error> { + std::fs::create_dir_all(directory)?; + let path = directory.join(file_name); + write_file_atomically(&path, contents)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .map_err(ClientGroupLabelsError::Write)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; } Ok(()) } @@ -125,7 +176,7 @@ fn write_file_atomically(path: &Path, contents: &[u8]) -> Result<(), std::io::Er return Err(error); } drop(file); - if let Err(error) = std::fs::rename(&temporary, path) { + if let Err(error) = replace_file(&temporary, path) { let _ = std::fs::remove_file(&temporary); return Err(error); } @@ -137,6 +188,45 @@ fn write_file_atomically(path: &Path, contents: &[u8]) -> Result<(), std::io::Er )) } +#[cfg(not(windows))] +fn replace_file(temporary: &Path, destination: &Path) -> Result<(), std::io::Error> { + std::fs::rename(temporary, destination) +} + +#[cfg(windows)] +fn replace_file(temporary: &Path, destination: &Path) -> Result<(), std::io::Error> { + use std::os::windows::ffi::OsStrExt as _; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let temporary = temporary + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: both buffers are NUL-terminated and remain alive for the call. + // The source is a newly created sibling owned by this process. The flags + // provide Windows' replace-existing behavior and request durable metadata. + let replaced = unsafe { + MoveFileExW( + temporary.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if replaced == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + fn temporary_label_path(parent: &Path, file_name: &str, sequence: u32) -> PathBuf { parent.join(format!( ".{file_name}.{}.{}.tmp", @@ -281,4 +371,84 @@ mod tests { )); assert!(unreadable_path.is_dir()); } + + #[test] + fn sort_preference_survives_a_reload_without_changing_group_labels() { + let directory = tempfile::tempdir().expect("temporary config directory"); + save_label(directory.path(), "synthetic-company-guid", "North practice") + .expect("save label"); + let sort = ClientSortPreference { + key: ClientSortKey::Unallocated, + desc: false, + }; + + save_sort_preference(directory.path(), sort.clone()).expect("save sort preference"); + + assert_eq!(load_sort_preference(directory.path()), Some(sort)); + assert_eq!( + load(directory.path()).get("synthetic-company-guid"), + Some(&"North practice".to_string()) + ); + } + + #[test] + fn replacement_sort_write_keeps_only_the_latest_complete_preference() { + let directory = tempfile::tempdir().expect("temporary config directory"); + save_sort_preference( + directory.path(), + ClientSortPreference { + key: ClientSortKey::Overdue, + desc: true, + }, + ) + .expect("first sort preference"); + let latest = ClientSortPreference { + key: ClientSortKey::Client, + desc: false, + }; + save_sort_preference(directory.path(), latest.clone()).expect("replacement preference"); + + assert_eq!(load_sort_preference(directory.path()), Some(latest)); + assert!(directory + .path() + .read_dir() + .expect("directory entries") + .all(|entry| !entry + .expect("directory entry") + .file_name() + .to_string_lossy() + .ends_with(".tmp"))); + } + + #[test] + fn sort_storage_remains_readable_by_the_old_v1_label_contract() { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct OldClientGroupLabelsFile { + version: u8, + labels: ClientGroupLabels, + } + + let directory = tempfile::tempdir().expect("temporary config directory"); + save_label(directory.path(), "existing-guid", "Existing practice") + .expect("save existing label"); + save_sort_preference( + directory.path(), + ClientSortPreference { + key: ClientSortKey::Oldest, + desc: true, + }, + ) + .expect("save new sort preference"); + + let raw_labels = std::fs::read(directory.path().join(FILE_NAME)) + .expect("read label bytes after new writer"); + let old_reader: OldClientGroupLabelsFile = serde_json::from_slice(&raw_labels) + .expect("the previous release must still parse the label file after rollback"); + assert_eq!(old_reader.version, SCHEMA_VERSION); + assert_eq!( + old_reader.labels.get("existing-guid"), + Some(&"Existing practice".to_string()) + ); + } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7f98840..33e13e7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2121,6 +2121,16 @@ pub fn load_client_group_labels(app: AppHandle) -> client_groups::ClientGroupLab client_groups::load(&directory) } +/// Reads the optional all-client sort preference from ordinary application +/// configuration. Like group labels, it never initialises the Tally mirror. +#[tauri::command] +pub fn load_client_sort_preference(app: AppHandle) -> Option { + let Ok(directory) = app.path().app_config_dir() else { + return None; + }; + client_groups::load_sort_preference(&directory) +} + #[derive(Debug, Deserialize)] pub struct SaveClientGroupLabelRequest { pub company_guid: String, @@ -2144,6 +2154,19 @@ pub fn save_client_group_label( .map_err(|_| "Bridge could not save this group label.".to_string()) } +/// Saves the optional all-client sort preference without accessing the Tally mirror. +#[tauri::command] +pub fn save_client_sort_preference( + app: AppHandle, + preference: client_groups::ClientSortPreference, +) -> Result<(), String> { + let directory = app.path().app_config_dir().map_err(|_| { + "Bridge could not locate its local client-preference configuration.".to_string() + })?; + client_groups::save_sort_preference(&directory, preference) + .map_err(|_| "Bridge could not save the all-client sort preference.".to_string()) +} + #[derive(Debug, Deserialize)] pub struct AllCompaniesOutstandingsRequest { pub config: TallyConfig, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1a4282f..1e52fc8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -97,6 +97,8 @@ pub fn run() { commands::fetch_tally_outstandings_all_companies, commands::load_client_group_labels, commands::save_client_group_label, + commands::load_client_sort_preference, + commands::save_client_sort_preference, commands::detect_tally_base_currency, commands::tally_persisted_company_profiles, commands::tally_mirror_explorer_page, @@ -235,9 +237,9 @@ mod security_config_tests { } #[cfg(test)] -mod client_group_label_mount_tests { +mod client_preference_mount_tests { #[test] - fn group_label_load_path_is_mirror_and_keychain_free() { + fn client_preference_commands_are_mirror_and_keychain_free() { let commands = include_str!("commands.rs"); let start = commands .find("pub fn load_client_group_labels") @@ -246,10 +248,12 @@ mod client_group_label_mount_tests { .find("pub struct AllCompaniesOutstandingsRequest") .map(|offset| start + offset) .expect("end of group-label commands"); - let group_label_commands = &commands[start..end]; + let client_preference_commands = &commands[start..end]; - assert!(group_label_commands.contains("app_config_dir")); - assert!(!group_label_commands.contains("LazyTallyMirror")); - assert!(!group_label_commands.contains("keyring")); + assert!(client_preference_commands.contains("load_client_sort_preference")); + assert!(client_preference_commands.contains("save_client_sort_preference")); + assert!(client_preference_commands.contains("app_config_dir")); + assert!(!client_preference_commands.contains("LazyTallyMirror")); + assert!(!client_preference_commands.contains("keyring")); } } diff --git a/src/AllClientsScreen.tsx b/src/AllClientsScreen.tsx index 638790b..4a7ec8d 100644 --- a/src/AllClientsScreen.tsx +++ b/src/AllClientsScreen.tsx @@ -3,7 +3,7 @@ import React from "react"; import { ChevronRight, RefreshCw } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; -import { applyClientGroupLabel, ClientGroupLabels, groupClientRows, rollbackFailedClientGroupLabel } from "./client-grouping"; +import { applyClientGroupLabel, ClientGroupLabels, groupClientRows, reconcileLoadedSortPreference, rollbackFailedClientGroupLabel } from "./client-grouping"; import { outstandingsPartialState } from "./outstandings-copy"; type CompanyRef = { name: string; guid: string }; @@ -61,6 +61,20 @@ function formatCompact(value: string | undefined) { } type SortKey = "client" | "receivable" | "overdue" | "unallocated" | "oldest"; +type SortPreference = { key: SortKey; desc: boolean }; + +const defaultSort: SortPreference = { key: "overdue", desc: true }; + +function isSortPreference(value: unknown): value is SortPreference { + return Boolean( + value + && typeof value === "object" + && "key" in value + && "desc" in value + && ["client", "receivable", "overdue", "unallocated", "oldest"].includes(String(value.key)) + && typeof value.desc === "boolean", + ); +} /// Severity tiers reuse the ageing ramp used on the single-client screen, so a /// chip means the same thing in both places. @@ -73,7 +87,7 @@ function ageTier(days: number | null) { } export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: Props) { - const [sort, setSort] = React.useState<{ key: SortKey; desc: boolean }>({ key: "overdue", desc: true }); + const [sort, setSort] = React.useState(defaultSort); const [entries, setEntries] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); @@ -81,6 +95,7 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P const [groupLabelError, setGroupLabelError] = React.useState(null); const persistedGroupLabels = React.useRef({}); const requestVersion = React.useRef(0); + const sortChangedDuringLoad = React.useRef(false); React.useEffect(() => { let active = true; @@ -104,6 +119,26 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P }; }, []); + React.useEffect(() => { + let active = true; + void invoke("load_client_sort_preference") + .then((preference) => { + if (active && isSortPreference(preference)) { + setSort((current) => reconcileLoadedSortPreference( + current, + preference, + sortChangedDuringLoad.current, + )); + } + }) + // Sorting is an optional local display preference. Keep the safe default + // if its config file cannot be read. + .catch(() => {}); + return () => { + active = false; + }; + }, []); + const load = React.useCallback(async () => { if (companies.length === 0) return; const version = requestVersion.current + 1; @@ -236,6 +271,13 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P }); }, []); + const changeSort = React.useCallback((key: SortKey) => { + const next = sort.key === key ? { key, desc: !sort.desc } : { key, desc: key !== "client" }; + sortChangedDuringLoad.current = true; + setSort(next); + void invoke("save_client_sort_preference", { preference: next }).catch(() => {}); + }, [sort]); + const renderRow = (row: (typeof rows)[number]) => { const partial = row.reasonCode ? outstandingsPartialState(row.reasonCode) : null; return ( @@ -358,10 +400,7 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P type="button" className={sort.key === key ? "is-sorted" : undefined} aria-sort={sort.key === key ? (sort.desc ? "descending" : "ascending") : "none"} - onClick={() => setSort((current) => - current.key === key - ? { key, desc: !current.desc } - : { key, desc: key !== "client" })} + onClick={() => changeSort(key)} > {label} diff --git a/src/client-grouping.ts b/src/client-grouping.ts index 110e4e9..294004b 100644 --- a/src/client-grouping.ts +++ b/src/client-grouping.ts @@ -26,6 +26,14 @@ export function rollbackFailedClientGroupLabel( return applyClientGroupLabel(current, companyGuid, persisted[companyGuid] ?? ""); } +export function reconcileLoadedSortPreference( + current: Sort, + persisted: Sort, + userChangedSort: boolean, +): Sort { + return userChangedSort ? current : persisted; +} + export type GroupableClientRow = { companyGuid: string; exactAmounts: {