diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 51f4be0..2b7b4cd 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -11,6 +11,8 @@ * mappit-core load --export output.json * mappit-core load --export output.kml * mappit-core load --stats + * mappit-core load --quality # validation + quality metrics + * mappit-core load --sanitize --export clean.json # drop invalid items */ import * as fs from 'node:fs'; @@ -29,6 +31,7 @@ import { parseTakeoutMonthly } from './loaders/takeout-monthly'; import { filterByDateRange, filterByArea, filterByActivityType } from './filters'; import type { BoundingBox } from './filters'; import { computeSummary, computeYearlySummary } from './stats'; +import { validateDataset, sanitizeDataset, computeQualityMetrics } from './quality'; import { exportToJson } from './exporters/json-exporter'; import { exportToKml } from './exporters/kml-exporter'; import type { MappitDataset, DataSource } from './types'; @@ -188,6 +191,48 @@ function printSummary(ds: MappitDataset): void { console.log(); } +/** + * Pretty-print validation report and quality metrics to stdout. + */ +function printQuality(ds: MappitDataset): void { + const v = validateDataset(ds); + const q = computeQualityMetrics(ds); + + console.log('\n Data Quality'); + console.log('─'.repeat(45)); + console.log(`Validation: ${v.valid ? 'ok' : `${v.issueCount.toLocaleString()} issues`}`); + for (const [reason, count] of Object.entries(v.byReason)) { + if (count > 0) console.log(` ${reason.padEnd(20)} ${count.toLocaleString().padStart(10)}`); + } + + const cov = q.activityPathCoverage; + if (cov.ratio !== null) { + console.log( + `Activities w/ path: ${cov.withPath.toLocaleString()} / ${cov.activities.toLocaleString()} (${(cov.ratio * 100).toFixed(1)}%)`, + ); + } + + if (q.accuracy) { + const a = q.accuracy; + console.log(`Accuracy (m): median ${a.median}, p90 ${a.p90}, max ${a.max}`); + for (const b of a.buckets) { + console.log(` ${b.label.padEnd(20)} ${b.count.toLocaleString().padStart(10)}`); + } + } + + const g = q.gaps; + if (g.basis !== 'none') { + console.log( + `Gaps > ${g.thresholdMinutes} min: ${g.count.toLocaleString()} (${g.totalHours.toFixed(1)} h, on ${g.basis})`, + ); + for (const gap of g.largest) { + console.log(` ${gap.start} → ${gap.end} ${gap.hours.toFixed(1)} h`); + } + } + + console.log(); +} + /** * Apply all filters specified via CLI flags. */ @@ -289,6 +334,17 @@ export function run(argv?: string[]): void { describe: 'Print summary statistics', type: 'boolean', default: false, + }) + .option('quality', { + alias: 'q', + describe: 'Print validation report and data quality metrics', + type: 'boolean', + default: false, + }) + .option('sanitize', { + describe: 'Drop invalid points and timeline entries before filtering', + type: 'boolean', + default: false, }), (cmdArgv) => { const spinner = ora({ stream: process.stdout }); @@ -302,6 +358,16 @@ export function run(argv?: string[]): void { `Loaded ${ds.source} — ${ds.points.length} points, ${ds.timeline.length} timeline entries`, ); + // 1b — Sanitize + if (cmdArgv.sanitize) { + const before = ds.points.length + ds.timeline.length; + ds = sanitizeDataset(ds); + const removed = before - ds.points.length - ds.timeline.length; + spinner.succeed( + `Sanitized — ${removed} invalid ${removed === 1 ? 'item' : 'items'} removed`, + ); + } + // 2 — Filter const hasFilters = cmdArgv.filterDate || cmdArgv.filterArea || cmdArgv.filterActivity; @@ -319,9 +385,12 @@ export function run(argv?: string[]): void { } // 3 — Stats - if (cmdArgv.stats || (!cmdArgv.export && !hasFilters)) { + if (cmdArgv.stats || (!cmdArgv.export && !hasFilters && !cmdArgv.quality)) { printSummary(ds); } + if (cmdArgv.quality) { + printQuality(ds); + } // 4 — Export if (cmdArgv.export) { diff --git a/packages/core/src/filters.ts b/packages/core/src/filters.ts index fdbcbf4..6f6ea8c 100644 --- a/packages/core/src/filters.ts +++ b/packages/core/src/filters.ts @@ -37,7 +37,8 @@ function isInBounds(lat: number, lng: number, bounds: BoundingBox): boolean { ); } -function recomputeDateRange( +/** @internal Recompute `dateRange` from the remaining points and entries. */ +export function recomputeDateRange( points: LocationPoint[], timeline: TimelineEntry[], ): { min: string; max: string } { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c449038..d19f11b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,6 +74,20 @@ export { type PeriodSummary, } from './stats'; +// Data validation & quality metrics +export { + validateDataset, + sanitizeDataset, + computeQualityMetrics, + type ValidationReason, + type ValidationIssue, + type ValidationReport, + type ValidateOptions, + type TimelineGap, + type QualityMetrics, + type QualityOptions, +} from './quality'; + // Phase 2.4–2.5 — Exporters export { exportToJson, diff --git a/packages/core/src/quality.ts b/packages/core/src/quality.ts new file mode 100644 index 0000000..d066838 --- /dev/null +++ b/packages/core/src/quality.ts @@ -0,0 +1,314 @@ +/** + * Data validation and quality metrics for MappitDataset. + * + * - {@link validateDataset} reports invalid coordinates, timestamps and + * intervals without changing the dataset. + * - {@link sanitizeDataset} returns a new dataset without the invalid items. + * - {@link computeQualityMetrics} measures accuracy distribution, gaps in the + * timeline and how many activities have a GPS path. A low path coverage is + * the signal of loader bugs such as #22 (paths silently dropped). + */ + +import type { ActivitySegment, LocationPoint, MappitDataset, TimelineEntry } from './types'; +import { recomputeDateRange } from './filters'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Why an item failed validation. */ +export type ValidationReason = + | 'coords-not-finite' + | 'coords-out-of-range' + | 'invalid-timestamp' + | 'end-before-start' + | 'path-point-invalid'; + +/** A single validation problem. */ +export interface ValidationIssue { + /** Where the item lives: `points` or `timeline`. */ + collection: 'points' | 'timeline'; + /** Index of the item in its collection. */ + index: number; + reason: ValidationReason; +} + +/** Result of {@link validateDataset}. */ +export interface ValidationReport { + /** True when no issue was found. */ + valid: boolean; + /** Total number of issues (also beyond `maxIssues`). */ + issueCount: number; + /** Issue count for each reason. */ + byReason: Record; + /** The first `maxIssues` issues, in dataset order. */ + issues: ValidationIssue[]; +} + +export interface ValidateOptions { + /** Maximum number of issues listed in `issues` (default 100). */ + maxIssues?: number; +} + +/** A gap between two consecutive timeline entries (or points). */ +export interface TimelineGap { + /** End of the data before the gap (ISO 8601). */ + start: string; + /** Start of the data after the gap (ISO 8601). */ + end: string; + hours: number; +} + +/** Result of {@link computeQualityMetrics}. */ +export interface QualityMetrics { + /** Distribution of `accuracy` (metres) over raw points; `null` without data. */ + accuracy: { + count: number; + min: number; + median: number; + p90: number; + max: number; + buckets: Array<{ label: string; count: number }>; + } | null; + /** Gaps longer than the threshold, computed on the timeline (or on points). */ + gaps: { + basis: 'timeline' | 'points' | 'none'; + thresholdMinutes: number; + count: number; + totalHours: number; + /** Largest gaps, longest first. */ + largest: TimelineGap[]; + }; + /** How many activities have at least one GPS path point. */ + activityPathCoverage: { + activities: number; + withPath: number; + /** `withPath / activities`, or `null` without activities. */ + ratio: number | null; + }; +} + +export interface QualityOptions { + /** Minimum gap length to report, in minutes (default 60). */ + gapThresholdMinutes?: number; + /** How many of the largest gaps to list (default 5). */ + topGaps?: number; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const REASONS: ValidationReason[] = [ + 'coords-not-finite', + 'coords-out-of-range', + 'invalid-timestamp', + 'end-before-start', + 'path-point-invalid', +]; + +const ACCURACY_BUCKETS: Array<{ label: string; max: number }> = [ + { label: '≤10 m', max: 10 }, + { label: '≤50 m', max: 50 }, + { label: '≤100 m', max: 100 }, + { label: '≤500 m', max: 500 }, + { label: '>500 m', max: Infinity }, +]; + +function coordsProblem(lat: unknown, lng: unknown): ValidationReason | null { + if (typeof lat !== 'number' || typeof lng !== 'number') return 'coords-not-finite'; + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return 'coords-not-finite'; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return 'coords-out-of-range'; + return null; +} + +function time(value: unknown): number { + return typeof value === 'string' && value ? Date.parse(value) : NaN; +} + +function intervalProblem(startTime: string, endTime: string): ValidationReason | null { + const s = time(startTime); + const e = time(endTime); + if (Number.isNaN(s) || Number.isNaN(e)) return 'invalid-timestamp'; + if (e < s) return 'end-before-start'; + return null; +} + +function pointProblem(p: LocationPoint): ValidationReason | null { + return coordsProblem(p.lat, p.lng) ?? (Number.isNaN(time(p.timestamp)) ? 'invalid-timestamp' : null); +} + +/** Problems that make a timeline entry unusable (the entry gets removed). */ +function entryProblem(e: TimelineEntry): ValidationReason | null { + const coords = + e.type === 'visit' + ? coordsProblem(e.lat, e.lng) + : (coordsProblem(e.startLocation?.lat, e.startLocation?.lng) ?? + coordsProblem(e.endLocation?.lat, e.endLocation?.lng)); + return coords ?? intervalProblem(e.startTime, e.endTime); +} + +function isValidPathPoint(p: ActivitySegment['path'][number]): boolean { + if (coordsProblem(p.lat, p.lng)) return false; + return p.timestamp === undefined || !Number.isNaN(time(p.timestamp)); +} + +function percentile(sorted: number[], q: number): number { + const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1)); + return sorted[idx]; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Check coordinates (finite, lat ±90, lng ±180), timestamps and intervals of + * every point, timeline entry and activity path point. + */ +export function validateDataset( + dataset: MappitDataset, + options: ValidateOptions = {}, +): ValidationReport { + const maxIssues = options.maxIssues ?? 100; + const byReason = Object.fromEntries(REASONS.map((r) => [r, 0])) as Record< + ValidationReason, + number + >; + const issues: ValidationIssue[] = []; + let issueCount = 0; + + const report = (collection: ValidationIssue['collection'], index: number, reason: ValidationReason) => { + issueCount++; + byReason[reason]++; + if (issues.length < maxIssues) issues.push({ collection, index, reason }); + }; + + dataset.points.forEach((p, i) => { + const reason = pointProblem(p); + if (reason) report('points', i, reason); + }); + + dataset.timeline.forEach((e, i) => { + const reason = entryProblem(e); + if (reason) report('timeline', i, reason); + if (e.type === 'activity') { + for (const p of e.path ?? []) { + if (!isValidPathPoint(p)) report('timeline', i, 'path-point-invalid'); + } + } + }); + + return { valid: issueCount === 0, issueCount, byReason, issues }; +} + +/** + * Return a new dataset without invalid points and timeline entries; invalid + * path points are removed from their activity, which is kept. `dateRange` + * is recomputed, as in the filters. The input dataset is not modified. + */ +export function sanitizeDataset(dataset: MappitDataset): MappitDataset { + const points = dataset.points.filter((p) => !pointProblem(p)); + const timeline = dataset.timeline + .filter((e) => !entryProblem(e)) + .map((e) => { + if (e.type !== 'activity') return e; + const path = (e.path ?? []).filter(isValidPathPoint); + return path.length === e.path?.length ? e : { ...e, path }; + }); + return { ...dataset, dateRange: recomputeDateRange(points, timeline), points, timeline }; +} + +/** + * Compute quality metrics: accuracy distribution of raw points, gaps in the + * timeline (or in the points, for datasets without a timeline) and the share + * of activities with a GPS path. Invalid timestamps are ignored. + */ +export function computeQualityMetrics( + dataset: MappitDataset, + options: QualityOptions = {}, +): QualityMetrics { + const thresholdMinutes = options.gapThresholdMinutes ?? 60; + const topGaps = options.topGaps ?? 5; + + // Accuracy + const acc = dataset.points + .map((p) => p.accuracy) + .filter((a): a is number => typeof a === 'number' && Number.isFinite(a) && a >= 0) + .sort((a, b) => a - b); + const accuracy = + acc.length === 0 + ? null + : { + count: acc.length, + min: acc[0], + median: percentile(acc, 0.5), + p90: percentile(acc, 0.9), + max: acc[acc.length - 1], + buckets: ACCURACY_BUCKETS.map((b, i) => ({ + label: b.label, + count: acc.filter( + (a) => a <= b.max && (i === 0 || a > ACCURACY_BUCKETS[i - 1].max), + ).length, + })), + }; + + // Gaps: intervals [start, end] sorted by start; a gap is time not covered + // by any interval seen so far. + let basis: QualityMetrics['gaps']['basis'] = 'none'; + let intervals: Array<{ s: number; e: number; sIso: string; eIso: string }> = []; + if (dataset.timeline.length > 0) { + basis = 'timeline'; + intervals = dataset.timeline.map((x) => ({ + s: time(x.startTime), + e: time(x.endTime), + sIso: x.startTime, + eIso: x.endTime, + })); + } else if (dataset.points.length > 0) { + basis = 'points'; + intervals = dataset.points.map((p) => ({ + s: time(p.timestamp), + e: time(p.timestamp), + sIso: p.timestamp, + eIso: p.timestamp, + })); + } + intervals = intervals + .filter((x) => !Number.isNaN(x.s) && !Number.isNaN(x.e) && x.e >= x.s) + .sort((a, b) => a.s - b.s); + + const gaps: TimelineGap[] = []; + const thresholdMs = thresholdMinutes * 60_000; + let coveredUntil = -Infinity; + let coveredUntilIso = ''; + for (const x of intervals) { + if (coveredUntil !== -Infinity && x.s - coveredUntil > thresholdMs) { + gaps.push({ + start: coveredUntilIso, + end: x.sIso, + hours: (x.s - coveredUntil) / 3_600_000, + }); + } + if (x.e > coveredUntil) { + coveredUntil = x.e; + coveredUntilIso = x.eIso; + } + } + const totalHours = gaps.reduce((sum, g) => sum + g.hours, 0); + const largest = [...gaps].sort((a, b) => b.hours - a.hours).slice(0, topGaps); + + // Activity path coverage + const activities = dataset.timeline.filter((e): e is ActivitySegment => e.type === 'activity'); + const withPath = activities.filter((a) => (a.path?.length ?? 0) > 0).length; + + return { + accuracy, + gaps: { basis, thresholdMinutes, count: gaps.length, totalHours, largest }, + activityPathCoverage: { + activities: activities.length, + withPath, + ratio: activities.length === 0 ? null : withPath / activities.length, + }, + }; +} diff --git a/packages/core/tests/cli.test.ts b/packages/core/tests/cli.test.ts index 2882a5d..dbdbb7d 100644 --- a/packages/core/tests/cli.test.ts +++ b/packages/core/tests/cli.test.ts @@ -205,3 +205,42 @@ describe('CLI — stats', () => { expect(out).toContain('Dataset Summary'); }); }); + +// --------------------------------------------------------------------------- +// Quality & sanitize +// --------------------------------------------------------------------------- +describe('CLI — quality', () => { + it('prints quality metrics with --quality', () => { + const out = run(['load', path.join(FIXTURES, 'timeline-semantic-paths.json'), '--quality']); + expect(out).toContain('Data Quality'); + expect(out).toContain('Validation: ok'); + expect(out).toContain('Activities w/ path: 1 / 1 (100.0%)'); + expect(out).not.toContain('Dataset Summary'); + }); + + it('drops invalid items with --sanitize', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mappit-')); + const input = path.join(tmp, 'dirty.json'); + fs.writeFileSync( + input, + JSON.stringify({ + semanticSegments: [ + { + startTime: '2024-01-15T10:00:00Z', + endTime: '2024-01-15T11:00:00Z', + visit: { topCandidate: { placeLocation: { latLng: '45.46°, 9.19°' } } }, + }, + { + startTime: '2024-01-15T12:00:00Z', + endTime: '2024-01-15T11:30:00Z', + visit: { topCandidate: { placeLocation: { latLng: '45.47°, 9.17°' } } }, + }, + ], + }), + ); + const out = run(['load', input, '--sanitize', '--quality']); + expect(out).toContain('1 invalid item removed'); + expect(out).toContain('Validation: ok'); + fs.rmSync(tmp, { recursive: true, force: true }); + }); +}); diff --git a/packages/core/tests/quality.test.ts b/packages/core/tests/quality.test.ts new file mode 100644 index 0000000..ee7eb42 --- /dev/null +++ b/packages/core/tests/quality.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { validateDataset, sanitizeDataset, computeQualityMetrics } from '../src/quality'; +import { parseTimelineSemantic } from '../src/loaders/timeline-semantic'; +import { parseRecords } from '../src/loaders/records'; +import type { MappitDataset, LocationPoint, TimelineEntry } from '../src/types'; + +const FIXTURES = path.resolve(__dirname, '../../../fixtures'); +const load = (name: string) => JSON.parse(fs.readFileSync(path.join(FIXTURES, name), 'utf-8')); + +const ds = (points: LocationPoint[] = [], timeline: TimelineEntry[] = []): MappitDataset => ({ + source: 'records', + dateRange: { min: '', max: '' }, + points, + timeline, +}); + +const pt = (timestamp: string, lat = 45, lng = 9, accuracy?: number): LocationPoint => ({ + timestamp, + lat, + lng, + ...(accuracy !== undefined ? { accuracy } : {}), +}); + +const visit = (startTime: string, endTime: string, lat = 45, lng = 9): TimelineEntry => ({ + type: 'visit', + startTime, + endTime, + lat, + lng, +}); + +const activity = ( + startTime: string, + endTime: string, + path: Array<{ lat: number; lng: number; timestamp?: string }> = [], +): TimelineEntry => ({ + type: 'activity', + startTime, + endTime, + activityType: 'WALKING', + startLocation: { lat: 45, lng: 9 }, + endLocation: { lat: 45.1, lng: 9.1 }, + path, +}); + +// --------------------------------------------------------------------------- +// validateDataset +// --------------------------------------------------------------------------- +describe('validateDataset', () => { + it('accepts a valid dataset', () => { + const r = validateDataset( + ds([pt('2024-01-01T10:00:00Z')], [visit('2024-01-01T10:00:00Z', '2024-01-01T11:00:00Z')]), + ); + expect(r.valid).toBe(true); + expect(r.issueCount).toBe(0); + }); + + it('accepts the bundled fixtures', () => { + for (const name of ['timeline-semantic.json', 'timeline-semantic-paths.json']) { + expect(validateDataset(parseTimelineSemantic(load(name))).valid).toBe(true); + } + expect(validateDataset(parseRecords(load('records.json'))).valid).toBe(true); + }); + + it('flags coordinates out of range and not finite', () => { + const r = validateDataset( + ds([ + pt('2024-01-01T10:00:00Z', 91, 9), + pt('2024-01-01T10:00:00Z', 45, -181), + pt('2024-01-01T10:00:00Z', NaN, 9), + ]), + ); + expect(r.byReason['coords-out-of-range']).toBe(2); + expect(r.byReason['coords-not-finite']).toBe(1); + expect(r.issues.map((i) => i.index)).toEqual([0, 1, 2]); + }); + + it('flags missing or invalid timestamps and reversed intervals', () => { + const r = validateDataset( + ds( + [pt(''), pt('not a date')], + [ + visit('2024-01-01T12:00:00Z', '2024-01-01T11:00:00Z'), + activity('', '2024-01-01T11:00:00Z'), + ], + ), + ); + expect(r.byReason['invalid-timestamp']).toBe(3); + expect(r.byReason['end-before-start']).toBe(1); + expect(r.issues.filter((i) => i.collection === 'timeline')).toHaveLength(2); + }); + + it('flags invalid path points', () => { + const r = validateDataset( + ds( + [], + [ + activity('2024-01-01T10:00:00Z', '2024-01-01T11:00:00Z', [ + { lat: 45, lng: 9, timestamp: '2024-01-01T10:10:00Z' }, + { lat: 200, lng: 9 }, + { lat: 45, lng: 9, timestamp: 'bad' }, + ]), + ], + ), + ); + expect(r.byReason['path-point-invalid']).toBe(2); + }); + + it('caps the listed issues but keeps the full count', () => { + const points = Array.from({ length: 10 }, () => pt('2024-01-01T10:00:00Z', 99, 9)); + const r = validateDataset(ds(points), { maxIssues: 3 }); + expect(r.issues).toHaveLength(3); + expect(r.issueCount).toBe(10); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeDataset +// --------------------------------------------------------------------------- +describe('sanitizeDataset', () => { + const dirty = ds( + [pt('2024-01-01T10:00:00Z'), pt('2024-01-01T10:00:00Z', 99, 9), pt('')], + [ + visit('2024-01-01T10:00:00Z', '2024-01-01T11:00:00Z'), + visit('2024-01-01T12:00:00Z', '2024-01-01T11:00:00Z'), + activity('2024-01-01T11:00:00Z', '2024-01-01T12:00:00Z', [ + { lat: 45, lng: 9 }, + { lat: 45, lng: 999 }, + ]), + ], + ); + + it('removes invalid points and entries, and invalid path points', () => { + const clean = sanitizeDataset(dirty); + expect(clean.points).toHaveLength(1); + expect(clean.timeline).toHaveLength(2); + const act = clean.timeline.find((e) => e.type === 'activity'); + expect(act?.type === 'activity' && act.path).toHaveLength(1); + expect(validateDataset(clean).valid).toBe(true); + }); + + it('recomputes dateRange from the remaining items', () => { + const withRange = { ...dirty, dateRange: { min: '1999-01-01T00:00:00Z', max: '2099-01-01T00:00:00Z' } }; + const clean = sanitizeDataset(withRange); + expect(clean.dateRange.min).toBe('2024-01-01T10:00:00Z'); + expect(clean.dateRange.max).toBe('2024-01-01T12:00:00Z'); + }); + + it('does not modify the input', () => { + sanitizeDataset(dirty); + expect(dirty.points).toHaveLength(3); + expect(dirty.timeline).toHaveLength(3); + const act = dirty.timeline[2]; + expect(act.type === 'activity' && act.path).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// computeQualityMetrics +// --------------------------------------------------------------------------- +describe('computeQualityMetrics', () => { + it('computes the accuracy distribution', () => { + const q = computeQualityMetrics( + ds([5, 20, 20, 80, 300, 1000].map((a, i) => pt(`2024-01-01T10:0${i}:00Z`, 45, 9, a))), + ); + expect(q.accuracy).toMatchObject({ count: 6, min: 5, median: 20, p90: 1000, max: 1000 }); + expect(q.accuracy?.buckets.map((b) => b.count)).toEqual([1, 2, 1, 1, 1]); + }); + + it('returns null accuracy without data', () => { + expect(computeQualityMetrics(ds([pt('2024-01-01T10:00:00Z')])).accuracy).toBeNull(); + }); + + it('finds timeline gaps above the threshold, handling overlaps', () => { + const q = computeQualityMetrics( + ds( + [], + [ + visit('2024-01-01T08:00:00Z', '2024-01-01T12:00:00Z'), + activity('2024-01-01T09:00:00Z', '2024-01-01T10:00:00Z'), // inside the visit + visit('2024-01-01T12:30:00Z', '2024-01-01T13:00:00Z'), // 30 min gap: below + visit('2024-01-01T16:00:00Z', '2024-01-01T17:00:00Z'), // 3 h gap + visit('2024-01-02T17:00:00Z', '2024-01-02T18:00:00Z'), // 24 h gap + ], + ), + ); + expect(q.gaps.basis).toBe('timeline'); + expect(q.gaps.count).toBe(2); + expect(q.gaps.totalHours).toBeCloseTo(27); + expect(q.gaps.largest[0]).toEqual({ + start: '2024-01-01T17:00:00Z', + end: '2024-01-02T17:00:00Z', + hours: 24, + }); + }); + + it('uses points when there is no timeline', () => { + const q = computeQualityMetrics( + ds([pt('2024-01-01T10:00:00Z'), pt('2024-01-01T10:30:00Z'), pt('2024-01-01T14:30:00Z')]), + { gapThresholdMinutes: 60 }, + ); + expect(q.gaps.basis).toBe('points'); + expect(q.gaps.count).toBe(1); + expect(q.gaps.largest[0].hours).toBe(4); + }); + + it('measures activity path coverage', () => { + const q = computeQualityMetrics( + ds( + [], + [ + activity('2024-01-01T10:00:00Z', '2024-01-01T11:00:00Z', [{ lat: 45, lng: 9 }]), + activity('2024-01-01T12:00:00Z', '2024-01-01T13:00:00Z'), + ], + ), + ); + expect(q.activityPathCoverage).toEqual({ activities: 2, withPath: 1, ratio: 0.5 }); + }); + + // Regression for #22: standalone timelinePath segments must end up in activities + it('reports full path coverage for real-format semantic exports (#22)', () => { + const q = computeQualityMetrics(parseTimelineSemantic(load('timeline-semantic-paths.json'))); + expect(q.activityPathCoverage.activities).toBeGreaterThan(0); + expect(q.activityPathCoverage.ratio).toBe(1); + }); +});