1
13
14import type { GroupSpec } from "@factory/shared/dto";
15
16import type { RecordRow, PropertyInfo, PropertyValue } from "../values/types.js";
17import { dateBucket } from "../values/dates.js";
18
19export const MAX_GROUP_LEVELS = 3;
20export const EMPTY_KEY = "__empty";
21export const EMPTY_LABEL = "(Empty)";
22
23export interface GroupMeta {
24 key: string;
25 label: string;
26 count: number;
27 records: RecordRow[];
28 children?: GroupMeta[];
29}
30
31function optionName(config: Record<string, unknown>, id: string): string {
32 const opts = Array.isArray(config["options"])
33 ? (config["options"] as Array<Record<string, unknown>>)
34 : [];
35 const hit = opts.find((o) => o["id"] === id);
36 return typeof hit?.["name"] === "string" ? hit["name"] : id;
37}
38
39function granularity(config: Record<string, unknown>): "day" | "week" | "month" {
40 const g = config["groupGranularity"];
41 return g === "week" || g === "month" ? g : "day";
42}
43
44interface Bucket {
45 key: string;
46 label: string;
47 order: number;
48 records: RecordRow[];
49}
50
51function groupLevel(rows: RecordRow[], property: PropertyInfo): GroupMeta[] {
52 const cfg = property.config;
53 const buckets = new Map<string, Bucket>();
54 const empty: RecordRow[] = [];
55
56 const put = (key: string, label: string, order: number, row: RecordRow) => {
57 let b = buckets.get(key);
58 if (!b) {
59 b = { key, label, order, records: [] };
60 buckets.set(key, b);
61 }
62 b.records.push(row);
63 };
64
65 for (const row of rows) {
66 const v = row.values[property.id];
67 switch (property.type) {
68 case "select":
69 case "status": {
70 if (typeof v === "string" && v.length > 0) {
71 const idx = Array.isArray(cfg["options"])
72 ? (cfg["options"] as Array<Record<string, unknown>>).findIndex((o) => o["id"] === v)
73 : -1;
74 put(v, optionName(cfg, v), idx < 0 ? Number.MAX_SAFE_INTEGER : idx, row);
75 } else empty.push(row);
76 break;
77 }
78 case "multi_select": {
79 if (Array.isArray(v) && v.length > 0) {
80 v.forEach((x, i) => {
81 if (typeof x === "string") put(x, optionName(cfg, x), i, row);
82 });
83 } else empty.push(row);
84 break;
85 }
86 case "date": {
87 if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) {
88 const bucket = dateBucket(v, granularity(cfg));
89 put(bucket, bucket, v.charCodeAt(0) , row);
90 } else empty.push(row);
91 break;
92 }
93 case "date_range": {
94 if (v && typeof v === "object" && typeof (v as { start?: unknown }).start === "string") {
95 const start = (v as { start: string }).start;
96 const bucket = dateBucket(start, granularity(cfg));
97 put(bucket, bucket, start.charCodeAt(0), row);
98 } else empty.push(row);
99 break;
100 }
101 case "person": {
102 if (Array.isArray(v) && v.length > 0) {
103 v.forEach((x, i) => {
104 if (typeof x === "string") put(x, x, i, row);
105 });
106 } else empty.push(row);
107 break;
108 }
109 case "checkbox": {
110 put(
111 v === true ? "checked" : "unchecked",
112 v === true ? "Checked" : "Unchecked",
113 v === true ? 0 : 1,
114 row,
115 );
116 break;
117 }
118 default: {
119
120 if (
121 v === undefined ||
122 v === null ||
123 (typeof v === "string" && v.length === 0) ||
124 (Array.isArray(v) && v.length === 0)
125 ) {
126 empty.push(row);
127 } else if (typeof v === "number") {
128 put(`n:${v}`, String(v), v, row);
129 } else if (typeof v === "string") {
130 put(`s:${v}`, v, Number.MAX_SAFE_INTEGER, row);
131 } else {
132 empty.push(row);
133 }
134 break;
135 }
136 }
137 }
138
139 const metas: GroupMeta[] = [...buckets.values()].map((b) => ({
140 key: b.key,
141 label: b.label,
142 count: b.records.length,
143 records: b.records,
144 }));
145
146
147 metas.sort((a, b) => {
148 let r = 0;
149 if (property.type === "date" || property.type === "date_range")
150 r = a.key.localeCompare(b.key);
151 else if (property.type === "number") r = Number(a.key.slice(2)) - Number(b.key.slice(2));
152 else if (property.type === "select" || property.type === "status") {
153
154 const idx = (k: string) => {
155 const opts = Array.isArray(cfg["options"])
156 ? (cfg["options"] as Array<Record<string, unknown>>)
157 : [];
158 return opts.findIndex((o) => o["id"] === k) < 0
159 ? Number.MAX_SAFE_INTEGER
160 : opts.findIndex((o) => o["id"] === k);
161 };
162 r = idx(a.key) - idx(b.key);
163 } else if (a.key.startsWith("s:") || b.key.startsWith("s:")) r = a.key.localeCompare(b.key);
164 else r = a.key.localeCompare(b.key);
165 return r;
166 });
167
168
169 if (empty.length > 0)
170 metas.push({ key: EMPTY_KEY, label: EMPTY_LABEL, count: empty.length, records: empty });
171 return metas;
172}
173
174
178export function groupRecords(
179 rows: RecordRow[],
180 groups: readonly GroupSpec[],
181 properties: Map<string, PropertyInfo>,
182): GroupMeta[] {
183 if (groups.length === 0) return [];
184 const level0 = groups[0]!;
185 const property = properties.get(level0.propertyId);
186 if (!property) return [];
187 let metas = groupLevel(rows, property);
188
189 if (level0.direction === "desc") {
190 const empty = metas.filter((m) => m.key === EMPTY_KEY);
191 const rest = metas.filter((m) => m.key !== EMPTY_KEY).reverse();
192 metas = [...rest, ...empty];
193 }
194
195 if (groups.length > 1) {
196 for (const m of metas) {
197 m.children = groupRecords(
198 m.records,
199 groups.slice(1, Math.min(groups.length, MAX_GROUP_LEVELS + 1)),
200 properties,
201 );
202 }
203 }
204 return metas;
205}
206
207