Просмотр исходного кода

RHL-038 refactor(search): add branch sorting functionality to search results and update related tests

Code_Uwe 3 недель назад
Родитель
Сommit
4501ad1593

+ 4 - 2
components/search/SearchResultsToolbar.jsx

@@ -51,11 +51,13 @@ export default function SearchResultsToolbar({
 						<DropdownMenuRadioItem value={SEARCH_RESULTS_SORT.RELEVANCE}>
 							Relevanz
 						</DropdownMenuRadioItem>
+
 						<DropdownMenuRadioItem value={SEARCH_RESULTS_SORT.DATE_DESC}>
 							Datum (neueste zuerst)
 						</DropdownMenuRadioItem>
-						<DropdownMenuRadioItem value={SEARCH_RESULTS_SORT.FILENAME_ASC}>
-							Dateiname (A–Z)
+
+						<DropdownMenuRadioItem value={SEARCH_RESULTS_SORT.BRANCH_ASC}>
+							Niederlassung
 						</DropdownMenuRadioItem>
 					</DropdownMenuRadioGroup>
 				</DropdownMenuContent>

+ 87 - 10
lib/frontend/search/resultsSorting.js

@@ -1,13 +1,58 @@
 export const SEARCH_RESULTS_SORT = Object.freeze({
 	RELEVANCE: "relevance",
 	DATE_DESC: "date_desc",
-	FILENAME_ASC: "filename_asc",
+	BRANCH_ASC: "branch_asc",
 });
 
 function pad2(value) {
 	return String(value || "").padStart(2, "0");
 }
 
+/**
+ * Extract a comparable branch number from ids like "NL01", "NL200".
+ *
+ * Why this exists:
+ * - A lexicographic compare would sort "NL10" before "NL2" (wrong).
+ * - We want deterministic ordering that matches how humans read branch ids.
+ *
+ * @param {any} branchId
+ * @returns {number|null}
+ */
+function toBranchNumber(branchId) {
+	const raw = String(branchId || "").trim();
+	const match = /^NL(\d+)$/i.exec(raw);
+	if (!match) return null;
+
+	const n = Number(match[1]);
+	return Number.isInteger(n) ? n : null;
+}
+
+/**
+ * Compare two branch ids.
+ *
+ * Policy:
+ * - If both look like NL<number>, compare numerically (NL2 < NL10).
+ * - If only one looks like NL<number>, prefer the valid one first.
+ * - Otherwise fall back to a stable lexicographic compare.
+ *
+ * @param {any} a
+ * @param {any} b
+ * @returns {number}
+ */
+function compareBranchIds(a, b) {
+	const aa = String(a || "");
+	const bb = String(b || "");
+
+	const na = toBranchNumber(aa);
+	const nb = toBranchNumber(bb);
+
+	if (na !== null && nb !== null) return na - nb;
+	if (na !== null && nb === null) return -1;
+	if (na === null && nb !== null) return 1;
+
+	return aa.localeCompare(bb, "en");
+}
+
 /**
  * Build an ISO-like date key (YYYY-MM-DD) from a search item.
  *
@@ -28,6 +73,10 @@ export function toSearchItemIsoDateKey(item) {
 /**
  * Format the search item date as German UI string: DD.MM.YYYY
  *
+ * Important:
+ * - This is user-facing output (German).
+ * - We still keep it pure/testable and independent from UI frameworks.
+ *
  * @param {any} item
  * @returns {string}
  */
@@ -41,13 +90,21 @@ export function formatSearchItemDateDe(item) {
 /**
  * Sort search items according to the selected sort mode.
  *
- * Policy:
- * - RELEVANCE: keep backend order (we return a shallow copy to avoid accidental mutation)
- * - DATE_DESC: newest date first, then filename asc (stable & predictable)
- * - FILENAME_ASC: filename asc
+ * UX/API policy:
+ * - RELEVANCE:
+ *   Keep backend order (assumed relevance-ranked). We still return a shallow copy
+ *   to avoid accidental mutations by callers.
+ *
+ * - DATE_DESC:
+ *   Newest date first.
+ *   Tie-breakers: branch (stable, numeric for NLxx), then filename asc.
+ *
+ * - BRANCH_ASC:
+ *   Branch ascending (NL01..NLxx), then newest date first, then filename asc.
+ *   This keeps multi/all searches easy to scan without introducing UI grouping headers.
  *
  * @param {any[]} items
- * @param {"relevance"|"date_desc"|"filename_asc"|string} sortMode
+ * @param {"relevance"|"date_desc"|"branch_asc"|string} sortMode
  * @returns {any[]}
  */
 export function sortSearchItems(items, sortMode) {
@@ -60,18 +117,38 @@ export function sortSearchItems(items, sortMode) {
 			const da = toSearchItemIsoDateKey(a);
 			const db = toSearchItemIsoDateKey(b);
 
+			// Newest first
 			if (da !== db) return da < db ? 1 : -1;
 
+			// Stable tie-breakers
+			const ba = a?.branch;
+			const bb = b?.branch;
+			const branchCmp = compareBranchIds(ba, bb);
+			if (branchCmp !== 0) return branchCmp;
+
 			const fa = String(a?.filename || "");
 			const fb = String(b?.filename || "");
 			return fa.localeCompare(fb, "de");
 		});
 	}
 
-	if (sortMode === SEARCH_RESULTS_SORT.FILENAME_ASC) {
-		return arr.sort((a, b) =>
-			String(a?.filename || "").localeCompare(String(b?.filename || ""), "de")
-		);
+	if (sortMode === SEARCH_RESULTS_SORT.BRANCH_ASC) {
+		return arr.sort((a, b) => {
+			const ba = a?.branch;
+			const bb = b?.branch;
+
+			const branchCmp = compareBranchIds(ba, bb);
+			if (branchCmp !== 0) return branchCmp;
+
+			// Within the same branch: newest date first
+			const da = toSearchItemIsoDateKey(a);
+			const db = toSearchItemIsoDateKey(b);
+			if (da !== db) return da < db ? 1 : -1;
+
+			const fa = String(a?.filename || "");
+			const fb = String(b?.filename || "");
+			return fa.localeCompare(fb, "de");
+		});
 	}
 
 	// Unknown sort mode => fail-safe to backend order.

+ 84 - 13
lib/frontend/search/resultsSorting.test.js

@@ -21,34 +21,105 @@ describe("lib/frontend/search/resultsSorting", () => {
 		);
 	});
 
-	it("RELEVANCE keeps backend order", () => {
+	it("RELEVANCE keeps backend order (shallow copy)", () => {
 		const items = [{ filename: "b.pdf" }, { filename: "a.pdf" }];
 		const out = sortSearchItems(items, SEARCH_RESULTS_SORT.RELEVANCE);
+
 		expect(out.map((x) => x.filename)).toEqual(["b.pdf", "a.pdf"]);
+		// Ensure we did not return the same array instance (mutation safety).
+		expect(out).not.toBe(items);
 	});
 
-	it("DATE_DESC sorts newest date first, then filename asc", () => {
+	it("DATE_DESC sorts newest date first, then branch asc, then filename asc", () => {
 		const items = [
-			{ year: "2025", month: "12", day: "18", filename: "b.pdf" },
-			{ year: "2025", month: "12", day: "18", filename: "a.pdf" },
-			{ year: "2025", month: "01", day: "02", filename: "z.pdf" },
+			// Same date, different branches
+			{
+				branch: "NL10",
+				year: "2025",
+				month: "12",
+				day: "18",
+				filename: "b.pdf",
+			},
+			{
+				branch: "NL2",
+				year: "2025",
+				month: "12",
+				day: "18",
+				filename: "a.pdf",
+			},
+			// Older date
+			{
+				branch: "NL01",
+				year: "2025",
+				month: "01",
+				day: "02",
+				filename: "z.pdf",
+			},
 		];
 
 		const out = sortSearchItems(items, SEARCH_RESULTS_SORT.DATE_DESC);
 
 		expect(
-			out.map((x) => `${toSearchItemIsoDateKey(x)}|${x.filename}`)
-		).toEqual(["2025-12-18|a.pdf", "2025-12-18|b.pdf", "2025-01-02|z.pdf"]);
+			out.map((x) => `${toSearchItemIsoDateKey(x)}|${x.branch}|${x.filename}`)
+		).toEqual([
+			"2025-12-18|NL2|a.pdf", // NL2 before NL10 (numeric)
+			"2025-12-18|NL10|b.pdf",
+			"2025-01-02|NL01|z.pdf",
+		]);
 	});
 
-	it("FILENAME_ASC sorts by filename", () => {
+	it("BRANCH_ASC sorts branch asc (numeric), then newest date first, then filename asc", () => {
 		const items = [
-			{ filename: "b.pdf" },
-			{ filename: "a.pdf" },
-			{ filename: "c.pdf" },
+			// NL10 should come after NL2
+			{
+				branch: "NL10",
+				year: "2025",
+				month: "12",
+				day: "18",
+				filename: "b.pdf",
+			},
+			{
+				branch: "NL2",
+				year: "2025",
+				month: "12",
+				day: "18",
+				filename: "a.pdf",
+			},
+
+			// Same branch (NL2), older date + different filename
+			{
+				branch: "NL2",
+				year: "2025",
+				month: "01",
+				day: "02",
+				filename: "z.pdf",
+			},
+			{
+				branch: "NL2",
+				year: "2025",
+				month: "12",
+				day: "18",
+				filename: "c.pdf",
+			},
 		];
 
-		const out = sortSearchItems(items, SEARCH_RESULTS_SORT.FILENAME_ASC);
-		expect(out.map((x) => x.filename)).toEqual(["a.pdf", "b.pdf", "c.pdf"]);
+		const out = sortSearchItems(items, SEARCH_RESULTS_SORT.BRANCH_ASC);
+
+		expect(
+			out.map((x) => `${x.branch}|${toSearchItemIsoDateKey(x)}|${x.filename}`)
+		).toEqual([
+			// NL2 group first
+			"NL2|2025-12-18|a.pdf",
+			"NL2|2025-12-18|c.pdf", // same date -> filename tie-breaker
+			"NL2|2025-01-02|z.pdf",
+			// then NL10
+			"NL10|2025-12-18|b.pdf",
+		]);
+	});
+
+	it("unknown sort mode fails safe to backend order", () => {
+		const items = [{ filename: "b.pdf" }, { filename: "a.pdf" }];
+		const out = sortSearchItems(items, "unknown");
+		expect(out.map((x) => x.filename)).toEqual(["b.pdf", "a.pdf"]);
 	});
 });