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: {