1
18
19import type { PropertyValue, PropertyInfo } from "../values/types.js";
20
21export type RollupAggregate = "COUNT" | "SUM" | "AVERAGE" | "MIN" | "MAX" | "PERCENT" | "UNIQUE";
22
23export const ROLLUP_AGGREGATES: readonly RollupAggregate[] = [
24 "COUNT",
25 "SUM",
26 "AVERAGE",
27 "MIN",
28 "MAX",
29 "PERCENT",
30 "UNIQUE",
31];
32
33export interface RollupConfig {
34 aggregate: RollupAggregate;
35 targetPropertyType: string;
36 display?: "list" | "count";
37}
38
39export type RollupValue = number | string | string[] | null;
40
41
42export function aggregateAllowed(aggregate: RollupAggregate, targetType: string): boolean {
43 switch (aggregate) {
44 case "SUM":
45 case "AVERAGE":
46 return targetType === "number";
47 case "MIN":
48 case "MAX":
49 return targetType === "number" || targetType === "date";
50 case "COUNT":
51 case "PERCENT":
52 case "UNIQUE":
53 return true;
54 }
55}
56
57export function inferredType(
58 aggregate: RollupAggregate,
59 targetType: string,
60): "number" | "date" | "array" {
61 if (aggregate === "MIN" || aggregate === "MAX") return targetType === "date" ? "date" : "number";
62 if (aggregate === "UNIQUE") return "array";
63 return "number";
64}
65
66function isNumeric(v: PropertyValue): v is number {
67 return typeof v === "number" && Number.isFinite(v);
68}
69
70function dateKey(v: PropertyValue): number | null {
71 if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) {
72 const d = new Date(v.length === 10 ? v + "T00:00:00Z" : v).getTime();
73 return Number.isNaN(d) ? null : d;
74 }
75 return null;
76}
77
78
83export function aggregateRollup(
84 values: readonly (PropertyValue | undefined)[],
85 config: RollupConfig,
86 allLinkedCount?: number,
87): RollupValue {
88 const present = values.filter((v): v is PropertyValue => v !== undefined && v !== null);
89 switch (config.aggregate) {
90 case "COUNT":
91
92 return values.length;
93 case "SUM": {
94 const nums = present.filter(isNumeric);
95 if (nums.length === 0) return null;
96 return nums.reduce((a, b) => a + b, 0);
97 }
98 case "AVERAGE": {
99 const nums = present.filter(isNumeric);
100 if (nums.length === 0) return null;
101 return nums.reduce((a, b) => a + b, 0) / nums.length;
102 }
103 case "MIN":
104 case "MAX": {
105 if (config.targetPropertyType === "date") {
106 const dated = present.map((v) => dateKey(v)).filter((k): k is number => k !== null);
107 if (dated.length === 0) return null;
108 const best = config.aggregate === "MIN" ? Math.min(...dated) : Math.max(...dated);
109 return new Date(best).toISOString().slice(0, 10);
110 }
111 const nums = present.filter(isNumeric);
112 if (nums.length === 0) return null;
113 return config.aggregate === "MIN" ? Math.min(...nums) : Math.max(...nums);
114 }
115 case "PERCENT": {
116 const total = allLinkedCount ?? values.length;
117 if (total === 0) return 0;
118 return values.length / total;
119 }
120 case "UNIQUE": {
121 const seen = new Set<PropertyValue>();
122 for (const v of present) seen.add(v);
123 const list = [...seen].map((v) =>
124 typeof v === "number" ? String(v) : typeof v === "string" ? v : JSON.stringify(v),
125 );
126 if (config.display === "count") return list.length;
127 return list;
128 }
129 }
130}
131
132
139export function collectLinkedValues(
140 links: readonly string[],
141 targetValues: ReadonlyMap<string, PropertyValue | undefined>,
142 matchIds?: ReadonlySet<string>,
143): (PropertyValue | undefined)[] {
144 return links
145 .filter((id) => matchIds === undefined || matchIds.has(id))
146 .map((id) => targetValues.get(id));
147}
148
149export type { PropertyInfo };
150