Something went wrong. Try again.
Vibed Google Timeline alternative (VIBED)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317// Derived views of a day's raw records: capture modes, gaps, activity spans, summary statistics.
import type { Activity, DeviceEvent, Point } from './api'import { MINUTE } from './time'import { originOrder } from './colors'
/** Closed time range [start, end], UTC ms. */export interface TimeRange { start: number end: number}
/** Capture mode of the app's state machine (android CaptureMachine.kt), or unknown. */export type CaptureMode = 'moving' | 'stopping' | 'stationary' | 'unknown'
export interface ModeSpan extends TimeRange { mode: CaptureMode}
/** "outage": tracking was off or impaired (see isOutageEvent); "missing": unexplained. */export type GapKind = 'outage' | 'missing'
export interface Gap extends TimeRange { kind: GapKind /** Mode whose expected spacing was exceeded, else the mode at the gap's start. */ mode: CaptureMode /** Device events within the gap, which may explain it. */ events: DeviceEvent[]}
export interface ActivitySpan extends TimeRange { activity: string}
export interface OriginStats { origin: string count: number medianAcc: number | null}
export interface GapStats { count: number total: number}
export interface Summary { count: number byOrigin: OriginStats[] medianAcc: number | null mock: number gaps: Record<GapKind, GapStats>}
/** * Longest expected time without a fix per mode, from the app's Tuning.kt. Moving and stopping * request a fix every 30–60 s but skip it within 20 m, so slow movement and short halts leave * holes of a few minutes. Stationary relies on a 30 min low-power heartbeat that may run late. * Without mode information, assume the strict moving rate. */export const MAX_SPACING: Record<CaptureMode, number> = { moving: 10 * MINUTE, stopping: 10 * MINUTE, stationary: 45 * MINUTE, unknown: 10 * MINUTE,}
/** How long the app stays in stopping after STILL begins (Tuning.STOPPING_DURATION_MS). */const STOPPING_MS = 4 * MINUTE
const MOVING_ACTIVITIES = new Set(['WALKING', 'RUNNING', 'ON_BICYCLE', 'IN_VEHICLE', 'ON_FOOT'])
/** Permissions whose loss doesn't stop location capture. */const HARMLESS_PERMISSIONS = new Set([ 'POST_NOTIFICATIONS', 'IGNORE_BATTERY_OPTIMIZATIONS', 'ACTIVITY_RECOGNITION', 'BLUETOOTH_CONNECT', 'activity_transitions',])
export function inRange(t: number, r: TimeRange): boolean { return t >= r.start && t <= r.end}
function field(e: DeviceEvent, key: string): unknown { return e.data != null && typeof e.data === 'object' ? (e.data as Record<string, unknown>)[key] : undefined}
function asMode(v: unknown): CaptureMode | null { return v === 'moving' || v === 'stopping' || v === 'stationary' ? v : null}
/** Whether the event means tracking was off or impaired around it. */export function isOutageEvent(e: DeviceEvent): boolean { switch (e.event) { // Every start follows a time without the service, even when no stop was recorded (killed). case 'service_start': case 'service_stop': case 'boot': case 'app_update': return true case 'location_setting': return field(e, 'enabled') === false case 'permission': return field(e, 'granted') === false && !HARMLESS_PERMISSIONS.has(String(field(e, 'name'))) default: return false }}
interface ModeChange { t: number mode: CaptureMode}
/** Mode changes from the app's `mode` events; the mode before the first is its `from`. */function modeEventChanges(events: DeviceEvent[]): { initial: CaptureMode; changes: ModeChange[] } { const changes: ModeChange[] = [] let initial: CaptureMode | null = null for (const e of events) { const to = e.event === 'mode' ? asMode(field(e, 'to')) : null if (!to) continue if (!changes.length) initial = asMode(field(e, 'from')) changes.push({ t: e.t, mode: to }) } return { initial: initial ?? 'unknown', changes }}
/** Mode changes approximated from activity transitions, following CaptureMachine. */function activityChanges(activities: Activity[]): { initial: CaptureMode; changes: ModeChange[] } { const changes: ModeChange[] = [] let initial: CaptureMode = 'unknown' // A change cancels any later one still pending (the scheduled end of stopping). const push = (t: number, mode: CaptureMode) => { while (changes.length && changes[changes.length - 1].t > t) changes.pop() changes.push({ t, mode }) } for (const a of activities) { const still = a.activity === 'STILL' const enter = a.transition !== 'exit' if (!changes.length && still) initial = enter ? 'moving' : 'stationary' const cur = [...changes].reverse().find((c) => c.t <= a.t)?.mode ?? initial if (still && enter) { if (cur === 'moving' || cur === 'unknown') { push(a.t, 'stopping') push(a.t + STOPPING_MS, 'stationary') } } else if (still || (enter && MOVING_ACTIVITIES.has(a.activity))) { if (cur !== 'moving') push(a.t, 'moving') } } return { initial, changes }}
/** * Capture mode over [start, end]: from `mode` events, or approximated from activity transitions * when there are none (imports, older app versions). */export function captureModes(events: DeviceEvent[], activities: Activity[], start: number, end: number): ModeSpan[] { let { initial, changes } = modeEventChanges(events) if (!changes.length) ({ initial, changes } = activityChanges(activities)) const spans: ModeSpan[] = [] let cur: ModeSpan = { mode: initial, start, end } for (const c of changes) { if (c.t >= end) break if (c.mode === cur.mode) continue if (c.t > cur.start) spans.push({ ...cur, end: c.t }) cur = { mode: c.mode, start: Math.max(c.t, start), end } } spans.push(cur) return spans}
export function modeAt(modes: ModeSpan[], t: number): CaptureMode { return modes.find((m) => t >= m.start && t < m.end)?.mode ?? modes[modes.length - 1]?.mode ?? 'unknown'}
/** First mode whose stretch within [a, b] lasts longer than its expected spacing. */function exceededMode(modes: ModeSpan[], a: number, b: number): CaptureMode | null { for (const m of modes) { if (Math.min(b, m.end) - Math.max(a, m.start) > MAX_SPACING[m.mode]) return m.mode } return null}
/** * Intervals without fixes that the capture mode doesn't explain, or that contain an outage event. * `range` adds the edges before the first and after the last fix; an end before the last fix skips * the trailing edge. */export function findGaps(points: Point[], events: DeviceEvent[], modes: ModeSpan[], range: TimeRange): Gap[] { if (!points.length) return [] const times = [Math.min(range.start, points[0].t), ...points.map((p) => p.t)] times.push(Math.max(range.end, times[times.length - 1])) const minSpacing = Math.min(...Object.values(MAX_SPACING)) const gaps: Gap[] = [] const evs = [...events].sort((a, b) => a.t - b.t) let j = 0 for (let i = 1; i < times.length; i++) { const start = times[i - 1] const end = times[i] while (j < evs.length && evs[j].t < start) j++ if (end <= start) continue let k = j while (k < evs.length && evs[k].t <= end) k++ const inside = evs.slice(j, k) const outage = inside.some(isOutageEvent) const exceeded = end - start > minSpacing ? exceededMode(modes, start, end) : null if (outage || exceeded) { gaps.push({ start, end, kind: outage ? 'outage' : 'missing', mode: exceeded ?? modeAt(modes, start), events: inside }) } } return gaps}
/** * Activity spans from enter/exit transitions. An enter ends the current span unless it repeats its * activity; an exit ends the matching span. An exit before any enter means the activity began * before the day. Samples (records without a transition) are used as enters only on days without * transitions, since they flicker between likely activities. The last span runs to the day's end * (or now). */export function activitySpans(activities: Activity[], dayStart: number, dayEnd: number): ActivitySpan[] { const spans: ActivitySpan[] = [] let cur: { activity: string; start: number } | null = null let seen = false const transitions = activities.filter((a) => a.transition) for (const a of transitions.length ? transitions : activities) { if (a.transition === 'exit') { if (cur && cur.activity === a.activity) { spans.push({ activity: cur.activity, start: cur.start, end: a.t }) cur = null } else if (!cur && !seen) { spans.push({ activity: a.activity, start: dayStart, end: a.t }) } } else if (!cur || cur.activity !== a.activity) { if (cur) spans.push({ activity: cur.activity, start: cur.start, end: a.t }) cur = { activity: a.activity, start: a.t } } seen = true } if (cur) spans.push({ activity: cur.activity, start: cur.start, end: Math.min(dayEnd, Date.now()) }) return spans.filter((s) => s.end > s.start)}
export interface SampleRun extends TimeRange { activity: string count: number}
/** Consecutive activity samples (records without a transition) of the same activity, merged. */export function sampleRuns(activities: Activity[]): SampleRun[] { const runs: SampleRun[] = [] for (const a of activities) { if (a.transition) continue const last = runs[runs.length - 1] if (last?.activity === a.activity) { last.end = a.t last.count++ } else { runs.push({ activity: a.activity, start: a.t, end: a.t, count: 1 }) } } return runs}
function median(values: number[]): number | null { if (!values.length) return null const s = [...values].sort((a, b) => a - b) const mid = s.length >> 1 return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2}
function gapStats(gaps: Gap[], kind: GapKind): GapStats { const of = gaps.filter((g) => g.kind === kind) return { count: of.length, total: of.reduce((s, g) => s + g.end - g.start, 0) }}
export function summarize(points: Point[], gaps: Gap[]): Summary { const groups = new Map<string, Point[]>() for (const p of points) { const g = groups.get(p.origin) if (g) g.push(p) else groups.set(p.origin, [p]) } const accs = (ps: Point[]) => ps.flatMap((p) => (p.acc != null ? [p.acc] : [])) return { count: points.length, byOrigin: [...groups.entries()] .map(([origin, ps]) => ({ origin, count: ps.length, medianAcc: median(accs(ps)) })) .sort((a, b) => originOrder(a.origin, b.origin)), medianAcc: median(accs(points)), mock: points.filter((p) => p.mock).length, gaps: { outage: gapStats(gaps, 'outage'), missing: gapStats(gaps, 'missing') }, }}
/** Index of the fix nearest to t (points sorted by t), or -1 if there are none. */export function nearestIndex(points: Point[], t: number): number { if (!points.length) return -1 let lo = 0 let hi = points.length - 1 while (lo < hi) { const mid = (lo + hi) >> 1 if (points[mid].t < t) lo = mid + 1 else hi = mid } if (lo > 0 && t - points[lo - 1].t <= points[lo].t - t) return lo - 1 return lo}
/** Zone of the fix nearest to t, for labeling times that have no fix of their own. */export function zoneAt(points: Point[], t: number, fallback: string): string { const i = nearestIndex(points, t) return i < 0 ? fallback : points[i].zone}