@reekon-tools/boldr-utils 1.17.0 → 1.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotation/canvas/elements/BackgroundSvg.js +26 -1
- package/dist/export/buildJobCsv.js +91 -17
- package/dist/export/config.d.ts +2 -2
- package/dist/export/config.js +11 -2
- package/dist/exports.d.ts +2 -0
- package/dist/exports.js +2 -0
- package/dist/utils/measurementKind.d.ts +22 -0
- package/dist/utils/measurementKind.js +22 -0
- package/dist/utils/measurementSort.d.ts +2 -0
- package/dist/utils/measurementSort.js +45 -0
- package/package.json +1 -1
|
@@ -78,7 +78,11 @@ const useBackgroundSkSvg = (url) => {
|
|
|
78
78
|
next = Skia.SVG.MakeFromData(data, fontMgr);
|
|
79
79
|
data.dispose();
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
// A load that lands after unmount (or after the url changed) owns a
|
|
82
|
+
// C++ handle nothing will ever draw. Free it here or it leaks outright.
|
|
83
|
+
if (cancelled)
|
|
84
|
+
next?.dispose?.();
|
|
85
|
+
else
|
|
82
86
|
setSvg(next);
|
|
83
87
|
}
|
|
84
88
|
catch (e) {
|
|
@@ -89,6 +93,27 @@ const useBackgroundSkSvg = (url) => {
|
|
|
89
93
|
cancelled = true;
|
|
90
94
|
};
|
|
91
95
|
}, [url]);
|
|
96
|
+
// An SkSVG is a handle on a C++ SVG DOM; dropping the JS reference does NOT
|
|
97
|
+
// free it. The browser path below already disposes its replaced bitmaps —
|
|
98
|
+
// this is the same discipline for the native path, which had none: every
|
|
99
|
+
// diagram open allocated an SkSVG and left it to the native heap forever.
|
|
100
|
+
//
|
|
101
|
+
// Timing matters. Disposing in the loader's own cleanup would free the SVG
|
|
102
|
+
// that ImageSVG is still drawing (state has not changed yet) — a native
|
|
103
|
+
// use-after-free. So release the REPLACED one from an effect keyed on the
|
|
104
|
+
// committed value, which runs after the render that stopped drawing it, and
|
|
105
|
+
// the live one on unmount.
|
|
106
|
+
const prevRef = useRef(null);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const prev = prevRef.current;
|
|
109
|
+
if (prev && prev !== svg)
|
|
110
|
+
prev.dispose?.();
|
|
111
|
+
prevRef.current = svg;
|
|
112
|
+
}, [svg]);
|
|
113
|
+
useEffect(() => () => {
|
|
114
|
+
prevRef.current?.dispose?.();
|
|
115
|
+
prevRef.current = null;
|
|
116
|
+
}, []);
|
|
92
117
|
return svg;
|
|
93
118
|
};
|
|
94
119
|
const SkSvgBackground = ({ image, resolveUrl }) => {
|
|
@@ -2,18 +2,42 @@ import { ColumnType, GroupType, } from '../types/firestore.js';
|
|
|
2
2
|
import { calculateFormula } from '../formulas/calculateFormula.js';
|
|
3
3
|
import { formatSelectedValues } from '../utils/selectValues.js';
|
|
4
4
|
import { isDefaultGroup, isFormGroupCompleted } from '../utils/groups.js';
|
|
5
|
+
import { sortMeasurementsByCompleted } from '../utils/measurementSort.js';
|
|
6
|
+
import { isScalarMeasurement, scalarValueSuffix, } from '../utils/measurementKind.js';
|
|
5
7
|
export const escapeCSVField = (field) => `"${field.replace(/"/g, '""')}"`;
|
|
6
8
|
// Spreadsheet-formula hyperlink cell. Two escaping layers: quotes inside the
|
|
7
9
|
// formula's string literals are doubled (Excel/Sheets escaping), then the
|
|
8
10
|
// whole formula is CSV-escaped.
|
|
9
11
|
export const hyperlinkCell = (url, label) => escapeCSVField(`=HYPERLINK("${url.replace(/"/g, '""')}","${label.replace(/"/g, '""')}")`);
|
|
10
12
|
const objectCell = (obj) => obj.url ? hyperlinkCell(obj.url, obj.name) : escapeCSVField(obj.name);
|
|
13
|
+
// The shared model types timestamps as Date, but both apps deliver Firestore
|
|
14
|
+
// Timestamps at runtime; accept either (and null/absent on legacy docs).
|
|
15
|
+
const toExportDate = (value) => {
|
|
16
|
+
if (value instanceof Date)
|
|
17
|
+
return value;
|
|
18
|
+
if (value &&
|
|
19
|
+
typeof value.toDate === 'function') {
|
|
20
|
+
return value.toDate();
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
25
|
+
// Fixed local-time format (no locale dependence) so the two platforms emit
|
|
26
|
+
// identical files and spreadsheets sort it lexically.
|
|
27
|
+
const dateCell = (value) => {
|
|
28
|
+
const d = toExportDate(value);
|
|
29
|
+
return d
|
|
30
|
+
? `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
|
31
|
+
: '';
|
|
32
|
+
};
|
|
33
|
+
const userCell = (user) => (user ? [user.firstName, user.lastName].filter(Boolean).join(' ') : '');
|
|
11
34
|
// Pure CSV assembly for the job export, shared by rock-pro-app and
|
|
12
35
|
// rock-desktop so the two platforms emit identical files. With no objects
|
|
13
|
-
// selected and
|
|
14
|
-
// identical to the legacy exportCSV output;
|
|
15
|
-
//
|
|
16
|
-
// column
|
|
36
|
+
// selected and the legacy measurement/label/notes columns on, the
|
|
37
|
+
// measurement sections are identical to the legacy exportCSV output; the
|
|
38
|
+
// completion/creation fields add columns after those, and objects add a
|
|
39
|
+
// trailing "Objects" column (list groups) or extend the form row (form
|
|
40
|
+
// groups), one skipped column after the existing cells.
|
|
17
41
|
export function buildJobCsv(input) {
|
|
18
42
|
const { scope, activeGroupId, config, groups, sections, formulas, measurementsByGroup, objectsByGroup, formatMeasurementValue, unitPrefs, } = input;
|
|
19
43
|
const csvRows = [];
|
|
@@ -48,12 +72,19 @@ export function buildJobCsv(input) {
|
|
|
48
72
|
...(includeField('measurement') ? ['Measurement'] : []),
|
|
49
73
|
...(includeField('label') ? ['Label'] : []),
|
|
50
74
|
...(includeField('notes') ? ['Notes'] : []),
|
|
75
|
+
...(includeField('isComplete') ? ['Complete'] : []),
|
|
76
|
+
...(includeField('completedBy') ? ['Completed By'] : []),
|
|
77
|
+
...(includeField('completedAt') ? ['Completed At'] : []),
|
|
78
|
+
...(includeField('createdAt') ? ['Created At'] : []),
|
|
79
|
+
...(includeField('createdBy') ? ['Created By'] : []),
|
|
51
80
|
].map(escapeCSVField);
|
|
52
81
|
if (objects.length > 0) {
|
|
53
82
|
headerCells.push(escapeCSVField(''), escapeCSVField('Objects'));
|
|
54
83
|
}
|
|
55
84
|
csvRows.push(headerCells.join(','));
|
|
56
|
-
|
|
85
|
+
// Export rows in the card/table display order (incomplete first, then
|
|
86
|
+
// capture time), regardless of the order the caller fetched them in.
|
|
87
|
+
const measurements = sortMeasurementsByCompleted(measurementsByGroup.get(group.id) ?? []).filter(measurementPassesFilter);
|
|
57
88
|
// Objects fill a parallel column, one per row from the top; extra rows
|
|
58
89
|
// are emitted when a group has more objects than measurements.
|
|
59
90
|
const rowCount = Math.max(measurements.length, objects.length);
|
|
@@ -61,8 +92,13 @@ export function buildJobCsv(input) {
|
|
|
61
92
|
const measurement = measurements[i];
|
|
62
93
|
const row = [escapeCSVField('')];
|
|
63
94
|
if (includeField('measurement')) {
|
|
95
|
+
// Angles and unitless numbers store their value as entered, not as
|
|
96
|
+
// micrometers — running them through the length formatter would
|
|
97
|
+
// render 45° as ~0. Emit them as-is with their own suffix.
|
|
64
98
|
row.push(escapeCSVField(measurement
|
|
65
|
-
?
|
|
99
|
+
? isScalarMeasurement(measurement.type)
|
|
100
|
+
? `${measurement.value}${scalarValueSuffix(measurement.type)}`
|
|
101
|
+
: formatMeasurementValue(measurement.value, measurement.device)
|
|
66
102
|
: ''));
|
|
67
103
|
}
|
|
68
104
|
if (includeField('label')) {
|
|
@@ -71,6 +107,21 @@ export function buildJobCsv(input) {
|
|
|
71
107
|
if (includeField('notes')) {
|
|
72
108
|
row.push(escapeCSVField(measurement?.note || ''));
|
|
73
109
|
}
|
|
110
|
+
if (includeField('isComplete')) {
|
|
111
|
+
row.push(escapeCSVField(measurement ? (measurement.isCompleted ? 'Yes' : 'No') : ''));
|
|
112
|
+
}
|
|
113
|
+
if (includeField('completedBy')) {
|
|
114
|
+
row.push(escapeCSVField(userCell(measurement?.completedBy)));
|
|
115
|
+
}
|
|
116
|
+
if (includeField('completedAt')) {
|
|
117
|
+
row.push(escapeCSVField(dateCell(measurement?.completedAt)));
|
|
118
|
+
}
|
|
119
|
+
if (includeField('createdAt')) {
|
|
120
|
+
row.push(escapeCSVField(dateCell(measurement?.createdAt)));
|
|
121
|
+
}
|
|
122
|
+
if (includeField('createdBy')) {
|
|
123
|
+
row.push(escapeCSVField(userCell(measurement?.createdBy)));
|
|
124
|
+
}
|
|
74
125
|
const obj = objects[i];
|
|
75
126
|
if (obj) {
|
|
76
127
|
row.push(escapeCSVField(''), objectCell(obj));
|
|
@@ -94,13 +145,24 @@ export function buildJobCsv(input) {
|
|
|
94
145
|
return;
|
|
95
146
|
csvRows.push(`Section: ${section.name}`);
|
|
96
147
|
csvRows.push('');
|
|
148
|
+
// Measurement-backed columns grow companion columns (Notes / Created
|
|
149
|
+
// At / Created By) as gated by the config; completion fields stay
|
|
150
|
+
// list-only — form completion is a whole-row concept.
|
|
151
|
+
const hasCompanions = (column) => column.type === ColumnType.Measurement ||
|
|
152
|
+
column.type === ColumnType.Angle;
|
|
97
153
|
const columnHeaders = [];
|
|
98
154
|
section.tableConfig.forEach((column) => {
|
|
99
155
|
columnHeaders.push(column.name);
|
|
100
|
-
if (
|
|
101
|
-
(
|
|
102
|
-
column.
|
|
103
|
-
|
|
156
|
+
if (hasCompanions(column)) {
|
|
157
|
+
if (includeField('notes')) {
|
|
158
|
+
columnHeaders.push(`${column.name} Notes`);
|
|
159
|
+
}
|
|
160
|
+
if (includeField('createdAt')) {
|
|
161
|
+
columnHeaders.push(`${column.name} Created At`);
|
|
162
|
+
}
|
|
163
|
+
if (includeField('createdBy')) {
|
|
164
|
+
columnHeaders.push(`${column.name} Created By`);
|
|
165
|
+
}
|
|
104
166
|
}
|
|
105
167
|
});
|
|
106
168
|
csvRows.push(['Form Name', ...columnHeaders].map(escapeCSVField).join(','));
|
|
@@ -165,14 +227,26 @@ export function buildJobCsv(input) {
|
|
|
165
227
|
else {
|
|
166
228
|
row.push('');
|
|
167
229
|
}
|
|
168
|
-
if (
|
|
169
|
-
(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
230
|
+
if (hasCompanions(column)) {
|
|
231
|
+
if (includeField('notes')) {
|
|
232
|
+
if (group.descriptions && group.descriptions[column.id]) {
|
|
233
|
+
row.push(escapeCSVField(group.descriptions[column.id]));
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
// Raw '' (unquoted) for byte-parity with legacy exportCSV.
|
|
237
|
+
row.push('');
|
|
238
|
+
}
|
|
173
239
|
}
|
|
174
|
-
|
|
175
|
-
|
|
240
|
+
if (includeField('createdAt') || includeField('createdBy')) {
|
|
241
|
+
const columnMeasurement = measurementsByGroup
|
|
242
|
+
.get(group.id)
|
|
243
|
+
?.find((m) => m.id === group.columns[column.id]);
|
|
244
|
+
if (includeField('createdAt')) {
|
|
245
|
+
row.push(escapeCSVField(dateCell(columnMeasurement?.createdAt)));
|
|
246
|
+
}
|
|
247
|
+
if (includeField('createdBy')) {
|
|
248
|
+
row.push(escapeCSVField(userCell(columnMeasurement?.createdBy)));
|
|
249
|
+
}
|
|
176
250
|
}
|
|
177
251
|
}
|
|
178
252
|
});
|
package/dist/export/config.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export type ExportCategory = 'defaultGroup' | 'groups' | 'forms';
|
|
2
2
|
export type ExportObjectType = 'calculators' | 'files' | 'photos' | 'cad' | 'labels' | 'notes';
|
|
3
3
|
export type ExportMeasurementFilter = 'all' | 'complete' | 'incomplete';
|
|
4
|
-
export type ExportImageFormat = 'hyperlink' | 'none';
|
|
5
|
-
export type ExportMeasurementDataField = 'measurement' | 'label' | 'notes';
|
|
4
|
+
export type ExportImageFormat = 'hyperlink' | 'embedded' | 'none';
|
|
5
|
+
export type ExportMeasurementDataField = 'measurement' | 'label' | 'notes' | 'isComplete' | 'completedBy' | 'completedAt' | 'createdAt' | 'createdBy';
|
|
6
6
|
export type ExportConfig = {
|
|
7
7
|
categories: ExportCategory[];
|
|
8
8
|
objects: ExportObjectType[];
|
package/dist/export/config.js
CHANGED
|
@@ -15,7 +15,16 @@ export const ALL_EXPORT_OBJECT_TYPES = [
|
|
|
15
15
|
'labels',
|
|
16
16
|
'notes',
|
|
17
17
|
];
|
|
18
|
-
export const ALL_EXPORT_MEASUREMENT_DATA_FIELDS = [
|
|
18
|
+
export const ALL_EXPORT_MEASUREMENT_DATA_FIELDS = [
|
|
19
|
+
'measurement',
|
|
20
|
+
'label',
|
|
21
|
+
'notes',
|
|
22
|
+
'isComplete',
|
|
23
|
+
'completedBy',
|
|
24
|
+
'completedAt',
|
|
25
|
+
'createdAt',
|
|
26
|
+
'createdBy',
|
|
27
|
+
];
|
|
19
28
|
// Objects are opt-in: nothing ticked and images excluded until the user
|
|
20
29
|
// configures otherwise, so a default export is measurements-only.
|
|
21
30
|
export const DEFAULT_EXPORT_CONFIG = {
|
|
@@ -37,7 +46,7 @@ export const EXPORT_EVERYTHING_CONFIG = {
|
|
|
37
46
|
const isCategory = (v) => typeof v === 'string' && ALL_EXPORT_CATEGORIES.includes(v);
|
|
38
47
|
const isObjectType = (v) => typeof v === 'string' && ALL_EXPORT_OBJECT_TYPES.includes(v);
|
|
39
48
|
const isMeasurementFilter = (v) => v === 'all' || v === 'complete' || v === 'incomplete';
|
|
40
|
-
const isImageFormat = (v) => v === 'hyperlink' || v === 'none';
|
|
49
|
+
const isImageFormat = (v) => v === 'hyperlink' || v === 'embedded' || v === 'none';
|
|
41
50
|
const isMeasurementDataField = (v) => typeof v === 'string' &&
|
|
42
51
|
ALL_EXPORT_MEASUREMENT_DATA_FIELDS.includes(v);
|
|
43
52
|
// Tolerant reader for the raw user-doc field. Missing/invalid fields fall
|
package/dist/exports.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { classifyFileForExport } from './export/objectKinds.js';
|
|
|
14
14
|
export { buildJobCsv, escapeCSVField, hyperlinkCell, type BuildJobCsvInput, type ExportObjectRow, type ExportScope, } from './export/buildJobCsv.js';
|
|
15
15
|
export { desktopBaseUrlForFirebaseProject, desktopFileEditorUrl, type DesktopLinkContext, } from './export/desktopLinks.js';
|
|
16
16
|
export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, } from './utils/indexLabels.js';
|
|
17
|
+
export { sortMeasurementsByCompleted } from './utils/measurementSort.js';
|
|
18
|
+
export { isLengthMeasurement, isScalarMeasurement, scalarValueSuffix, } from './utils/measurementKind.js';
|
|
17
19
|
export { toSelectedValues, formatSelectedValues, toStoredValue, } from './utils/selectValues.js';
|
|
18
20
|
export * from './calculator/index.js';
|
|
19
21
|
export { annotationScopeKey, isFieldOp, isTemplateScope, isCalculatorScope, type AnnotationDataProvider, type AnnotationFile, type AnnotationFilePatch, type AnnotationFileSummary, type AnnotationScope, type CalculatorScope, type FieldOp, type ImageBlob, type JobGroupScope, type JobScope, type Patch, type TemplateScope, type Unsubscribe, type UploadedImageRef, } from './annotation/data/AnnotationDataProvider.js';
|
package/dist/exports.js
CHANGED
|
@@ -21,6 +21,8 @@ export { classifyFileForExport } from './export/objectKinds.js';
|
|
|
21
21
|
export { buildJobCsv, escapeCSVField, hyperlinkCell, } from './export/buildJobCsv.js';
|
|
22
22
|
export { desktopBaseUrlForFirebaseProject, desktopFileEditorUrl, } from './export/desktopLinks.js';
|
|
23
23
|
export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, } from './utils/indexLabels.js';
|
|
24
|
+
export { sortMeasurementsByCompleted } from './utils/measurementSort.js';
|
|
25
|
+
export { isLengthMeasurement, isScalarMeasurement, scalarValueSuffix, } from './utils/measurementKind.js';
|
|
24
26
|
export { toSelectedValues, formatSelectedValues, toStoredValue, } from './utils/selectValues.js';
|
|
25
27
|
// Construction Calculator shared module (schema, units, evaluator, solver,
|
|
26
28
|
// validation).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { MeasurementType } from '../types/firestore.js';
|
|
2
|
+
/**
|
|
3
|
+
* Angle and Number are BARE SCALARS: `value` is stored exactly as entered
|
|
4
|
+
* (signed degrees / a unitless decimal) and must never go through the
|
|
5
|
+
* micrometer parser or formatter. Only Length is micrometers — including a
|
|
6
|
+
* legacy doc with no `type` at all, which predates the field.
|
|
7
|
+
*
|
|
8
|
+
* These exist because the shorthand for "is this a length" was
|
|
9
|
+
* `type !== MeasurementType.Angle`. That read correctly while the enum had two
|
|
10
|
+
* members and became wrong the moment it gained a third: a unitless `25` fell
|
|
11
|
+
* into the length branch and rendered as `0 in`. Prefer these over an inline
|
|
12
|
+
* comparison so the next member added is a one-file change.
|
|
13
|
+
*/
|
|
14
|
+
export declare const isLengthMeasurement: (type?: MeasurementType) => boolean;
|
|
15
|
+
/** Angle or Number — a value shown as-is, with no unit conversion. */
|
|
16
|
+
export declare const isScalarMeasurement: (type?: MeasurementType) => boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The unit mark shown alongside a scalar value. Numbers are unitless by
|
|
19
|
+
* definition, so this is empty for them — callers must not substitute the
|
|
20
|
+
* user's selected unit.
|
|
21
|
+
*/
|
|
22
|
+
export declare const scalarValueSuffix: (type?: MeasurementType) => string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { MeasurementType } from '../types/firestore.js';
|
|
2
|
+
/**
|
|
3
|
+
* Angle and Number are BARE SCALARS: `value` is stored exactly as entered
|
|
4
|
+
* (signed degrees / a unitless decimal) and must never go through the
|
|
5
|
+
* micrometer parser or formatter. Only Length is micrometers — including a
|
|
6
|
+
* legacy doc with no `type` at all, which predates the field.
|
|
7
|
+
*
|
|
8
|
+
* These exist because the shorthand for "is this a length" was
|
|
9
|
+
* `type !== MeasurementType.Angle`. That read correctly while the enum had two
|
|
10
|
+
* members and became wrong the moment it gained a third: a unitless `25` fell
|
|
11
|
+
* into the length branch and rendered as `0 in`. Prefer these over an inline
|
|
12
|
+
* comparison so the next member added is a one-file change.
|
|
13
|
+
*/
|
|
14
|
+
export const isLengthMeasurement = (type) => (type ?? MeasurementType.Length) === MeasurementType.Length;
|
|
15
|
+
/** Angle or Number — a value shown as-is, with no unit conversion. */
|
|
16
|
+
export const isScalarMeasurement = (type) => !isLengthMeasurement(type);
|
|
17
|
+
/**
|
|
18
|
+
* The unit mark shown alongside a scalar value. Numbers are unitless by
|
|
19
|
+
* definition, so this is empty for them — callers must not substitute the
|
|
20
|
+
* user's selected unit.
|
|
21
|
+
*/
|
|
22
|
+
export const scalarValueSuffix = (type) => type === MeasurementType.Angle ? '°' : '';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Firestore Timestamp doesn't support relational comparison operators, which
|
|
2
|
+
// silently degrades sort comparators to insertion order. The shared model
|
|
3
|
+
// types createdAt as Date, but both apps deliver Timestamps at runtime — read
|
|
4
|
+
// via the methods Timestamps actually expose.
|
|
5
|
+
const createdAtMillis = (createdAt) => {
|
|
6
|
+
if (createdAt == null)
|
|
7
|
+
return 0;
|
|
8
|
+
if (createdAt instanceof Date)
|
|
9
|
+
return createdAt.getTime();
|
|
10
|
+
const ts = createdAt;
|
|
11
|
+
if (typeof ts.toMillis === 'function')
|
|
12
|
+
return ts.toMillis();
|
|
13
|
+
if (typeof ts.seconds === 'number') {
|
|
14
|
+
return ts.seconds * 1000 + Math.floor((ts.nanoseconds ?? 0) / 1e6);
|
|
15
|
+
}
|
|
16
|
+
return 0;
|
|
17
|
+
};
|
|
18
|
+
// Canonical display order for a LIST group's measurements, shared by
|
|
19
|
+
// rock-desktop and rock-pro-app so cards, tables and exports all agree:
|
|
20
|
+
// incomplete first, then by capture time. Callers that hide completed
|
|
21
|
+
// measurements filter at render time — completed entries sort to the tail, so
|
|
22
|
+
// the visible positional indexes are unaffected either way.
|
|
23
|
+
export const sortMeasurementsByCompleted = (groupMeasurements) => {
|
|
24
|
+
return [...groupMeasurements].sort((a, b) => {
|
|
25
|
+
const isCompletedA = a.isCompleted === true;
|
|
26
|
+
const isCompletedB = b.isCompleted === true;
|
|
27
|
+
if (isCompletedA !== isCompletedB)
|
|
28
|
+
return isCompletedA ? 1 : -1;
|
|
29
|
+
const ms = createdAtMillis(a.createdAt) - createdAtMillis(b.createdAt);
|
|
30
|
+
if (ms !== 0)
|
|
31
|
+
return ms;
|
|
32
|
+
// Synced floods collapse createdAt into a millisecond-wide window and
|
|
33
|
+
// legacy data has identical timestamps for whole batches. Device capture
|
|
34
|
+
// sequence is the right tiebreaker — it's monotonic per device session.
|
|
35
|
+
const aNum = a.deviceMeasurementNumber;
|
|
36
|
+
const bNum = b.deviceMeasurementNumber;
|
|
37
|
+
if (aNum != null && bNum != null)
|
|
38
|
+
return aNum - bNum;
|
|
39
|
+
if (aNum != null)
|
|
40
|
+
return -1;
|
|
41
|
+
if (bNum != null)
|
|
42
|
+
return 1;
|
|
43
|
+
return 0;
|
|
44
|
+
});
|
|
45
|
+
};
|