@chronicle.app/moves-app 0.3.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # @chronicle.app/moves-app
2
+
3
+ Chronicle plugin for the location storyline from [Moves](<https://en.wikipedia.org/wiki/Moves_(application)>) — the automatic activity tracker ProtoGeo built, Facebook bought in 2014, and shut down in July 2018.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @chronicle.app/moves-app
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Point the extractor at your unzipped Moves export — the archive the
14
+ moves-export.com service handed you before the shutdown, which is the only way
15
+ this data ever left the app.
16
+
17
+ ### Basic extraction
18
+
19
+ ```bash
20
+ chronicle extract moves-app --input /path/to/moves_export
21
+ ```
22
+
23
+ ### With date filtering
24
+
25
+ ```bash
26
+ chronicle extract moves-app \
27
+ --input /path/to/moves_export \
28
+ --since 2015-01-01 \
29
+ --until 2015-12-31 \
30
+ --limit 1000
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ - `--input` (required): Path to the unzipped export
36
+ - `--since`: Extract records that start on or after this date
37
+ - `--until`: Extract records that start on or before this date
38
+ - `--limit`: Maximum number of records to extract (the most recent ones)
39
+
40
+ ## File Format
41
+
42
+ The export holds the same history at five roll-ups (daily, weekly, monthly,
43
+ yearly, full) and in four cuts (storyline, places, activities, summary), in
44
+ several formats. This plugin reads the JSON **storyline**, which is the
45
+ superset: it inlines each day's places and its activities with their GPS track,
46
+ so nothing has to be joined across files.
47
+
48
+ ```
49
+ json/daily/storyline/storyline_YYYYMMDD.json one day per file
50
+ json/full/storyline.json every day in one array
51
+ ```
52
+
53
+ `--input` accepts the export folder, its `json` folder, the daily storyline
54
+ folder, or a storyline JSON file directly — the extractor resolves it. The daily
55
+ folder wins when both are present, because a windowed or limited run then only
56
+ opens the files it needs.
57
+
58
+ A day is an ordered list of segments, and a segment is one of:
59
+
60
+ - `place` — a stay: the place it was at, plus any activity recorded there
61
+ - `move` — movement between two stays, split into legs
62
+ - `off` — tracking was off, but the phone still recorded activity
63
+
64
+ A `move`/`off` segment splits into legs: walk to the station, take the train,
65
+ walk to the office is one segment of three activities, each with its own type,
66
+ distance, and track. The leg is the unit that carries a single mode of travel,
67
+ so the leg — not the segment — is what becomes a journey. The extractor emits
68
+ one record per stay and one per leg:
69
+
70
+ - `recordType: "places"` for stays
71
+ - `recordType: "moves"` for legs of movement
72
+
73
+ A segment that spans midnight is written into both days' files. It is deduped on
74
+ its start time, which is also its identity downstream, so both copies land on one
75
+ record.
76
+
77
+ ## Schema
78
+
79
+ | Source | Chronicle |
80
+ | ------------------ | ------------------------------------------------------------------------------------ |
81
+ | `place` segment | `VisitAction` → `object: Venue` or `Place`, `startTime`/`endTime` |
82
+ | leg of movement | `TravelAction` → `Journey` (`travelMode`, `distance`, `path`), `startTime`/`endTime` |
83
+ | `place` dictionary | `Venue`/`Place` (name, coordinates, `sameAs` to Foursquare/Facebook) |
84
+
85
+ The source is `moves-app`, after the service's own domain (`moves-app.com`, and
86
+ `dev.moves-app.com` for the format docs the export's PDF points at). Not bare
87
+ `moves`: a source name is a namespace, and it sits beside `moves` the record
88
+ type. Not `facebook-moves` either, though Facebook bought the company in 2014 —
89
+ these ids were minted by the Moves service throughout, and the same place
90
+ records carry `facebookPlaceId` values that belong to the genuinely separate
91
+ `facebook` id space.
92
+
93
+ Moves numbered its places but never its segments, so a stay and a leg are keyed
94
+ on the instant they began — a person is in one place, and on one leg of one
95
+ journey, at a time. The start is spelled as a UTC ISO string so it hashes the
96
+ same on every replica, and the type is part of the keyset, so a stay, the leg
97
+ that ended it, and that leg's `Journey` never collide.
98
+
99
+ A place keys on the Moves place id, which Moves issued and reused on every
100
+ visit. A named one is a `Venue`; an unnamed one (a spot Moves detected but never
101
+ named) is a `Place`. Where Moves matched a place against Foursquare or Facebook
102
+ it kept that service's id, which becomes a `sameAs` edge — so the Moves venue
103
+ collapses onto the same entity as that service's own `Venue`, the way an Arc
104
+ Timeline place does.
105
+
106
+ A leg's `activity` becomes the journey's `travelMode`, in the words Chronicle
107
+ already uses for those modes (Moves' `underground` is written `metro`, as Arc
108
+ Timeline writes it). Where Moves has no counterpart its own word stands:
109
+ `transport` is Moves saying "motorized, but I could not tell which", and
110
+ narrowing it to a car would be an invention. A leg's GPS `trackPoints` become
111
+ the journey's `path`, a GeoJSON `LineString` with per-point times in
112
+ `properties.coordTimes`.
113
+
114
+ The export carries no account identity — no name, no handle, no user id — so the
115
+ self is the per-source singleton, merged onto the real person through `@me`.
116
+
117
+ ### Not ingested
118
+
119
+ - **Steps and calories.** Moves counted both, per leg and per day, and neither
120
+ has a home in the schema today.
121
+ - **Activity recorded while at a place.** Moves logged the walking done inside a
122
+ venue as an activity on the `place` segment. It has no track and goes
123
+ nowhere — it is a step count, which is the point above.
124
+ - **Foursquare category ids.** The export carries the ids, not the names, and a
125
+ raw id is not a category anyone can read.
126
+ - **A place's `type`** (`home`, `work`, `user`) — Moves' note on where the place
127
+ came from, which has no schema property.
128
+ - **The other cuts and roll-ups.** `places`, `activities`, and `summary` are
129
+ projections of the storyline; the weekly, monthly, and yearly folders are the
130
+ same days re-bucketed.
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ npm install
136
+ npm run build
137
+ npm test
138
+ ```
139
+
140
+ Tests use a small synthetic export written to a temporary folder, so they never
141
+ read the host's Moves data or any account.
@@ -0,0 +1,3 @@
1
+ export declare const MOVES_TRAVEL_MODES: Record<string, string>;
2
+ export declare function travelModeName(activity: string | undefined): string | undefined;
3
+ //# sourceMappingURL=activityTypes.d.ts.map
@@ -0,0 +1,31 @@
1
+ // Moves' activity vocabulary, mapped to the travel-mode names Chronicle already
2
+ // carries for the same modes (the Arc Timeline plugin writes `metro`, so Moves'
3
+ // `underground` is written the same way — one word per mode across sources).
4
+ //
5
+ // Where Moves has no counterpart the source's own word stands: `transport` is
6
+ // Moves saying "motorized, but I could not tell which", and narrowing it to a
7
+ // car would be an invention. `escalator` is Moves' own too.
8
+ //
9
+ // `group` is the coarse bucket Moves rolled an activity up under (walking,
10
+ // running, cycling, transport); it is derivable from the activity, so only the
11
+ // specific one is carried.
12
+ export const MOVES_TRAVEL_MODES = {
13
+ walking: 'walking',
14
+ running: 'running',
15
+ cycling: 'cycling',
16
+ transport: 'transport',
17
+ train: 'train',
18
+ bus: 'bus',
19
+ tram: 'tram',
20
+ underground: 'metro',
21
+ airplane: 'airplane',
22
+ boat: 'boat',
23
+ funicular: 'funicular',
24
+ escalator: 'escalator',
25
+ snowboarding: 'snowboarding',
26
+ };
27
+ export function travelModeName(activity) {
28
+ if (!activity)
29
+ return undefined;
30
+ return MOVES_TRAVEL_MODES[activity] ?? activity;
31
+ }
@@ -0,0 +1,36 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import MovesTransformer from './MovesTransformer.js';
4
+ export declare class MovesExtractor extends Extractor<typeof MovesExtractor> {
5
+ static source: string;
6
+ static description: string;
7
+ static delivery: "export";
8
+ static strategy: string;
9
+ static recordTypes: string[];
10
+ static default: boolean;
11
+ static defaultTransformer: typeof MovesTransformer;
12
+ static schema: z.ZodObject<{
13
+ since: z.ZodOptional<z.ZodDate>;
14
+ until: z.ZodOptional<z.ZodDate>;
15
+ limit: z.ZodOptional<z.ZodNumber>;
16
+ } & {
17
+ input: z.ZodString;
18
+ }, "strip", z.ZodTypeAny, {
19
+ input: string;
20
+ since?: Date | undefined;
21
+ until?: Date | undefined;
22
+ limit?: number | undefined;
23
+ }, {
24
+ input: string;
25
+ since?: Date | undefined;
26
+ until?: Date | undefined;
27
+ limit?: number | undefined;
28
+ }>;
29
+ extract(): AsyncGenerator<Record>;
30
+ determineCount(): Promise<number | null>;
31
+ private selectFrom;
32
+ private readDays;
33
+ private resolveInput;
34
+ private listDayFiles;
35
+ }
36
+ //# sourceMappingURL=MovesExtractor.d.ts.map
@@ -0,0 +1,194 @@
1
+ import { Extractor, selfAgent } from '@chronicle.app/etl';
2
+ import { readFile, readdir, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { dayKey, parseMovesTime, shiftDayKey } from '../time.js';
6
+ import MovesTransformer from './MovesTransformer.js';
7
+ // A daily storyline file is named for the local day it covers.
8
+ const DAILY_FILE = /^storyline_(\d{8})\.json$/;
9
+ export class MovesExtractor extends Extractor {
10
+ static source = 'moves-app';
11
+ static description = 'Location storyline: places and moves';
12
+ static delivery = 'export';
13
+ // The strategy, in Moves' own vocabulary: the archive the moves-export.com
14
+ // service handed you, which is the only way this data ever left the app.
15
+ static strategy = 'moves-export';
16
+ static recordTypes = ['places', 'moves'];
17
+ static default = true;
18
+ static defaultTransformer = MovesTransformer;
19
+ static schema = Extractor.schema.extend({
20
+ input: z
21
+ .string()
22
+ .describe('Path to the unzipped Moves export — the export folder, its json/ folder, the daily storyline folder, or a storyline JSON file'),
23
+ });
24
+ async *extract() {
25
+ const config = this.config;
26
+ // The export carries no account identity — no name, no handle, no user id.
27
+ // So the self is the per-source singleton, merged onto the real person
28
+ // through "@me" like every other source's self.
29
+ const person = selfAgent({
30
+ source: 'moves-app',
31
+ });
32
+ const selected = [];
33
+ // A segment that spans midnight is written into both days, so the same
34
+ // segment is read twice. Its start instant tells the copies apart — the
35
+ // instant, not the string Moves wrote it as, which is the same identity the
36
+ // transformer keys on and holds even if the two copies carry different
37
+ // offsets.
38
+ const seen = new Set();
39
+ for await (const day of this.readDays(config.input)) {
40
+ for (const segment of [...(day.segments ?? [])].reverse()) {
41
+ for (const item of this.selectFrom(segment)) {
42
+ const start = parseMovesTime(item.data.startTime);
43
+ if (!start)
44
+ continue;
45
+ const marker = `${item.recordType}:${start.toISOString()}`;
46
+ if (seen.has(marker))
47
+ continue;
48
+ if (config.since && start < config.since)
49
+ continue;
50
+ if (config.until && start > config.until)
51
+ continue;
52
+ seen.add(marker);
53
+ selected.push(item);
54
+ if (this.shouldStopExtracting(selected.length))
55
+ break;
56
+ }
57
+ if (this.shouldStopExtracting(selected.length))
58
+ break;
59
+ }
60
+ if (this.shouldStopExtracting(selected.length))
61
+ break;
62
+ }
63
+ this.logInitStep(`Emitting ${selected.length} records ` +
64
+ `(${selected.filter(s => s.recordType === 'places').length} places, ` +
65
+ `${selected.filter(s => s.recordType === 'moves').length} moves)`);
66
+ for (const { data, recordType } of selected) {
67
+ yield this.createRecord(data, { recordType, person });
68
+ }
69
+ }
70
+ async determineCount() {
71
+ const config = this.config;
72
+ try {
73
+ const seen = new Set();
74
+ for await (const day of this.readDays(config.input)) {
75
+ for (const segment of day.segments ?? []) {
76
+ for (const item of this.selectFrom(segment)) {
77
+ const start = parseMovesTime(item.data.startTime);
78
+ if (!start)
79
+ continue;
80
+ if (config.since && start < config.since)
81
+ continue;
82
+ if (config.until && start > config.until)
83
+ continue;
84
+ seen.add(`${item.recordType}:${start.toISOString()}`);
85
+ }
86
+ }
87
+ }
88
+ const limit = this.getEffectiveLimit();
89
+ return limit === null ? seen.size : Math.min(seen.size, limit);
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ // The records a segment yields. A stay is one record. Movement splits into
96
+ // its legs — each carries a single mode of travel, its own distance, and its
97
+ // own track, which is what a Journey holds. A leg recorded while at a place
98
+ // (walking around inside a venue) went nowhere, so it yields nothing.
99
+ selectFrom(segment) {
100
+ if (segment.type === 'place') {
101
+ return segment.place ? [{ data: segment, recordType: 'places' }] : [];
102
+ }
103
+ return (segment.activities ?? []).map(activity => ({
104
+ data: activity,
105
+ recordType: 'moves',
106
+ }));
107
+ }
108
+ // The storyline days, newest first, so `--limit N` yields the N most recent
109
+ // records. A day file is read and released one at a time; the single full
110
+ // file is read whole, because it is one JSON array.
111
+ async *readDays(input) {
112
+ const config = this.config;
113
+ const source = await this.resolveInput(input);
114
+ // The since/until window, widened by a day at each edge: a file is named
115
+ // for the local day it covers and the window is UTC, so a file just outside
116
+ // the window can still hold a segment inside it. The per-segment check is
117
+ // what decides; this only skips files that cannot contribute.
118
+ const sinceKey = config.since ? shiftDayKey(dayKey(config.since), -1) : undefined;
119
+ const untilKey = config.until ? shiftDayKey(dayKey(config.until), 1) : undefined;
120
+ if (source.kind === 'file') {
121
+ const days = JSON.parse(await readFile(source.path, 'utf-8'));
122
+ for (const day of [...days].reverse()) {
123
+ if (sinceKey && day.date < sinceKey)
124
+ break;
125
+ if (untilKey && day.date > untilKey)
126
+ continue;
127
+ yield day;
128
+ }
129
+ return;
130
+ }
131
+ for (const { path, key } of source.files) {
132
+ // Day keys sort chronologically, so every remaining file is older still.
133
+ if (sinceKey && key < sinceKey)
134
+ break;
135
+ if (untilKey && key > untilKey)
136
+ continue;
137
+ const days = JSON.parse(await readFile(path, 'utf-8'));
138
+ for (const day of [...days].reverse())
139
+ yield day;
140
+ }
141
+ }
142
+ // Resolve the input to the storyline itself. The input may be the folder of
143
+ // daily files, a single storyline JSON file, or a parent that is easier to
144
+ // point at — the export root or its json/ folder. The daily folder wins when
145
+ // both are present: it holds the same data, and a limited or windowed run
146
+ // only opens the files it needs.
147
+ async resolveInput(input) {
148
+ const dailyDirs = [
149
+ input,
150
+ join(input, 'daily', 'storyline'),
151
+ join(input, 'json', 'daily', 'storyline'),
152
+ ];
153
+ for (const dir of dailyDirs) {
154
+ const files = await this.listDayFiles(dir);
155
+ if (files.length > 0)
156
+ return { kind: 'daily', files };
157
+ }
158
+ const fullFiles = [
159
+ input,
160
+ join(input, 'full', 'storyline.json'),
161
+ join(input, 'json', 'full', 'storyline.json'),
162
+ ];
163
+ for (const path of fullFiles) {
164
+ if (!path.endsWith('.json'))
165
+ continue;
166
+ try {
167
+ if ((await stat(path)).isFile())
168
+ return { kind: 'file', path };
169
+ }
170
+ catch {
171
+ continue; // Not there — try the next candidate.
172
+ }
173
+ }
174
+ throw new Error(`No Moves storyline found under ${input} — expected a folder of ` +
175
+ `storyline_YYYYMMDD.json files or a storyline.json, in there, in ` +
176
+ `daily/storyline/, in full/, or under json/`);
177
+ }
178
+ // The daily storyline files in a folder, newest first. An empty list means
179
+ // this folder is not the one.
180
+ async listDayFiles(dir) {
181
+ let entries;
182
+ try {
183
+ entries = await readdir(dir);
184
+ }
185
+ catch {
186
+ return []; // Not a directory — the caller tries the next candidate.
187
+ }
188
+ return entries
189
+ .map(name => DAILY_FILE.exec(name))
190
+ .filter((match) => match !== null)
191
+ .map(match => ({ path: join(dir, match[0]), key: match[1] }))
192
+ .sort((a, b) => b.key.localeCompare(a.key));
193
+ }
194
+ }
@@ -0,0 +1,12 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class MovesTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildVisit;
6
+ private buildTravel;
7
+ private spanKey;
8
+ private buildPlace;
9
+ private buildLocation;
10
+ private buildPlaceSameAs;
11
+ }
12
+ //# sourceMappingURL=MovesTransformer.d.ts.map
@@ -0,0 +1,117 @@
1
+ import { ChronicleTransformer } from '@chronicle.app/etl';
2
+ import { travelModeName } from '../activityTypes.js';
3
+ import { movesTimeToIso } from '../time.js';
4
+ import { buildTrackFeature } from '../track.js';
5
+ export default class MovesTransformer extends ChronicleTransformer {
6
+ async transform(record) {
7
+ const context = record.context;
8
+ if (context.recordType === 'places') {
9
+ return this.buildVisit(record.data, context.person);
10
+ }
11
+ if (context.recordType === 'moves') {
12
+ return this.buildTravel(record.data, context.person);
13
+ }
14
+ return [];
15
+ }
16
+ buildVisit(segment, person) {
17
+ const { place } = segment;
18
+ const startTime = movesTimeToIso(segment.startTime);
19
+ const endTime = movesTimeToIso(segment.endTime);
20
+ if (!place || !startTime)
21
+ return [];
22
+ const action = {
23
+ '@type': 'VisitAction',
24
+ '@key': this.spanKey(startTime),
25
+ source: 'moves-app',
26
+ agent: person,
27
+ object: this.buildPlace(place),
28
+ startTime,
29
+ ...(endTime && { endTime }),
30
+ };
31
+ return [action];
32
+ }
33
+ buildTravel(activity, person) {
34
+ const startTime = movesTimeToIso(activity.startTime);
35
+ const endTime = movesTimeToIso(activity.endTime);
36
+ if (!startTime)
37
+ return [];
38
+ // The action stays sparse; the detail lives on the Journey it produces.
39
+ const mode = travelModeName(activity.activity);
40
+ const path = buildTrackFeature(activity.trackPoints);
41
+ const journey = {
42
+ '@type': 'Journey',
43
+ '@key': this.spanKey(startTime),
44
+ source: 'moves-app',
45
+ ...(activity.distance !== undefined && { distance: activity.distance }),
46
+ ...(mode && { travelMode: mode }),
47
+ ...(path && { path }),
48
+ };
49
+ const action = {
50
+ '@type': 'TravelAction',
51
+ '@key': this.spanKey(startTime),
52
+ source: 'moves-app',
53
+ agent: person,
54
+ result: journey,
55
+ startTime,
56
+ ...(endTime && { endTime }),
57
+ };
58
+ return [action];
59
+ }
60
+ // A segment carries no id in the export — Moves numbered its places, never
61
+ // its segments. What identifies one is when it began: a person is in one
62
+ // place, and on one leg of one journey, at a time. So the start instant is
63
+ // the natural key, spelled as a UTC ISO string so it hashes the same on every
64
+ // replica. The type is part of the keyset, so a stay, the leg that ended it,
65
+ // and that leg's Journey never collide.
66
+ spanKey(startTime) {
67
+ return ['@type', 'source', { key: 'startTime', value: startTime }];
68
+ }
69
+ // A named place is a point of interest → a Venue; an unnamed one is a spot
70
+ // Moves detected but never named → a Place. Either way it keys on the Moves
71
+ // place id, which is the id Moves issued and reused on every visit, and either
72
+ // way it carries whatever identity edges the place has: the name decides the
73
+ // type, never whether a foreign id is reported.
74
+ buildPlace(place) {
75
+ const sameAs = this.buildPlaceSameAs(place);
76
+ const common = {
77
+ '@key': ['@type', 'source', 'sourceId'],
78
+ source: 'moves-app',
79
+ sourceId: String(place.id),
80
+ // The place's coordinates are its own Location value — a keyless
81
+ // (content-interned) geo node, not entity properties on the place.
82
+ location: this.buildLocation(place),
83
+ ...(sameAs.length > 0 && { sameAs }),
84
+ };
85
+ return place.name
86
+ ? { '@type': 'Venue', ...common, name: place.name }
87
+ : { '@type': 'Place', ...common };
88
+ }
89
+ buildLocation(place) {
90
+ return {
91
+ '@type': 'Location',
92
+ latitude: place.location.lat,
93
+ longitude: place.location.lon,
94
+ };
95
+ }
96
+ // Moves matched some places against Foursquare and Facebook and kept those
97
+ // services' ids. Emit a sameAs edge per id so the Moves place collapses into
98
+ // the same entity as that service's own Venue (which keys identically on
99
+ // [@type, source, sourceId]). The id is a real handle from the export — never
100
+ // synthesized — and each edge carries the title when there is one, so a
101
+ // yet-unseen foreign venue still has a name when this edge is what mints it.
102
+ buildPlaceSameAs(place) {
103
+ const edges = [
104
+ { source: 'foursquare', sourceId: place.foursquareId },
105
+ { source: 'facebook', sourceId: place.facebookPlaceId },
106
+ ];
107
+ return edges
108
+ .filter((edge) => Boolean(edge.sourceId))
109
+ .map(edge => ({
110
+ '@type': 'Venue',
111
+ '@key': ['@type', 'source', 'sourceId'],
112
+ source: edge.source,
113
+ sourceId: edge.sourceId,
114
+ ...(place.name && { name: place.name }),
115
+ }));
116
+ }
117
+ }
@@ -0,0 +1,3 @@
1
+ export { MovesExtractor } from './connectors/MovesExtractor.js';
2
+ export { default as MovesTransformer } from './connectors/MovesTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { MovesExtractor } from './connectors/MovesExtractor.js';
2
+ export { default as MovesTransformer } from './connectors/MovesTransformer.js';
package/dist/time.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { MovesTime } from './types.js';
2
+ export declare function parseMovesTime(value: MovesTime): Date | undefined;
3
+ export declare function movesTimeToIso(value: MovesTime): string | undefined;
4
+ export declare function dayKey(date: Date): string;
5
+ export declare function shiftDayKey(key: string, days: number): string;
6
+ //# sourceMappingURL=time.d.ts.map
package/dist/time.js ADDED
@@ -0,0 +1,35 @@
1
+ // Moves writes times in the ISO 8601 *basic* format — "20140510T134349-0300"
2
+ // (local wall clock plus the offset it was read at) or "20141021T042519Z".
3
+ // JavaScript's Date only parses the extended format, so the separators go back
4
+ // in before parsing. The offset is part of the string, so the instant is exact
5
+ // and does not depend on where the import runs.
6
+ const BASIC_TIME = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z|[+-]\d{4})$/;
7
+ export function parseMovesTime(value) {
8
+ const match = BASIC_TIME.exec(value);
9
+ if (!match)
10
+ return undefined;
11
+ const [, year, month, day, hour, minute, second, zone] = match;
12
+ const offset = zone === 'Z' ? 'Z' : `${zone.slice(0, 3)}:${zone.slice(3)}`;
13
+ const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}${offset}`);
14
+ return Number.isNaN(date.getTime()) ? undefined : date;
15
+ }
16
+ // The same instant as an ISO string, which is what the payload carries and what
17
+ // the dedupe keysets hash. A segment has no id in the export, so its start is
18
+ // its identity — the value has to be a plain string, spelled the same way on
19
+ // every run and every replica.
20
+ export function movesTimeToIso(value) {
21
+ return parseMovesTime(value)?.toISOString();
22
+ }
23
+ // The "YYYYMMDD" day key a daily storyline file is named for.
24
+ export function dayKey(date) {
25
+ return date.toISOString().slice(0, 10).replaceAll('-', '');
26
+ }
27
+ // A day key shifted by whole days, used to widen a since/until window before it
28
+ // is applied to file names. A file is named for the *local* day it covers, and
29
+ // the window is given as UTC, so the two disagree by up to a day at the edges.
30
+ // The exact filter is the per-segment start-time check; this one only decides
31
+ // which files are worth opening, so it errs one day wide on each side.
32
+ export function shiftDayKey(key, days) {
33
+ const ms = Date.UTC(Number(key.slice(0, 4)), Number(key.slice(4, 6)) - 1, Number(key.slice(6, 8)));
34
+ return dayKey(new Date(ms + days * 86_400_000));
35
+ }
@@ -0,0 +1,3 @@
1
+ import { MovesTrackPoint } from './types.js';
2
+ export declare function buildTrackFeature(points: MovesTrackPoint[] | undefined): string | undefined;
3
+ //# sourceMappingURL=track.d.ts.map
package/dist/track.js ADDED
@@ -0,0 +1,33 @@
1
+ import { movesTimeToIso } from './time.js';
2
+ // A leg's GPS trail as a GeoJSON `Feature` (LineString) with per-point time
3
+ // carried in `properties.coordTimes`, the Mapbox convention — so it round-trips
4
+ // to GPX and renders directly. Stored verbatim as the Journey's `path` (a JSON
5
+ // string in a Text property), the same encoding the Arc Timeline plugin writes.
6
+ //
7
+ // Moves records only position and time per fix; there is no speed or course to
8
+ // carry, so those arrays are absent rather than filled with nulls.
9
+ //
10
+ // Returns undefined when there are fewer than two fixes — a line needs two
11
+ // points, and Moves leaves the track empty on legs it inferred without GPS.
12
+ export function buildTrackFeature(points) {
13
+ if (!points || points.length < 2)
14
+ return undefined;
15
+ const coordinates = [];
16
+ const coordTimes = [];
17
+ for (const point of points) {
18
+ if (typeof point.lat !== 'number' || typeof point.lon !== 'number')
19
+ continue;
20
+ const time = movesTimeToIso(point.time);
21
+ if (!time)
22
+ continue;
23
+ coordinates.push([point.lon, point.lat]);
24
+ coordTimes.push(time);
25
+ }
26
+ if (coordinates.length < 2)
27
+ return undefined;
28
+ return JSON.stringify({
29
+ type: 'Feature',
30
+ geometry: { type: 'LineString', coordinates },
31
+ properties: { coordTimes },
32
+ });
33
+ }
@@ -0,0 +1,50 @@
1
+ import { AgentAndChildren } from '@chronicle.app/schema';
2
+ export type MovesTime = string;
3
+ export interface MovesLocation {
4
+ lat: number;
5
+ lon: number;
6
+ }
7
+ export interface MovesPlace {
8
+ id: number;
9
+ name?: string;
10
+ type: 'home' | 'work' | 'user' | 'foursquare' | 'facebook' | 'unknown';
11
+ location: MovesLocation;
12
+ foursquareId?: string;
13
+ foursquareCategoryIds?: string[];
14
+ facebookPlaceId?: string;
15
+ }
16
+ export interface MovesTrackPoint {
17
+ lat: number;
18
+ lon: number;
19
+ time: MovesTime;
20
+ }
21
+ export interface MovesActivity {
22
+ activity: string;
23
+ group?: string;
24
+ manual?: boolean;
25
+ startTime: MovesTime;
26
+ endTime: MovesTime;
27
+ duration?: number;
28
+ distance?: number;
29
+ steps?: number;
30
+ calories?: number;
31
+ trackPoints?: MovesTrackPoint[];
32
+ }
33
+ export interface MovesSegment {
34
+ type: 'place' | 'move' | 'off';
35
+ startTime: MovesTime;
36
+ endTime: MovesTime;
37
+ place?: MovesPlace;
38
+ activities?: MovesActivity[];
39
+ lastUpdate?: MovesTime;
40
+ }
41
+ export interface MovesDay {
42
+ date: string;
43
+ segments?: MovesSegment[];
44
+ lastUpdate?: MovesTime;
45
+ }
46
+ export interface MovesRecordContext {
47
+ recordType: 'places' | 'moves';
48
+ person: AgentAndChildren;
49
+ }
50
+ //# sourceMappingURL=types.d.ts.map
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@chronicle.app/moves-app",
3
+ "version": "0.3.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "chronicle",
18
+ "moves-app"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Chronicle plugin for the location storyline from a Moves data export",
24
+ "devDependencies": {
25
+ "@chronicle.app/etl": "0.3.0",
26
+ "@chronicle.app/schema": "0.3.0",
27
+ "@chronicle.app/tsconfig": "0.3.0",
28
+ "@types/node": "^22.10.1",
29
+ "typescript": "^5.7.2"
30
+ },
31
+ "homepage": "https://chronicle.app",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/chronicle-app/chronicle.git",
35
+ "directory": "plugins/moves-app"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "dependencies": {
42
+ "zod": "^3.24.1"
43
+ },
44
+ "peerDependencies": {
45
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
46
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.13.0"
50
+ },
51
+ "exports": {
52
+ ".": {
53
+ "types": "./dist/index.d.ts",
54
+ "import": "./dist/index.js"
55
+ }
56
+ },
57
+ "chronicle": {
58
+ "plugin": true,
59
+ "sources": {
60
+ "moves-app": {
61
+ "strategies": {
62
+ "moves-export": {
63
+ "delivery": "export",
64
+ "recordTypes": [
65
+ "places",
66
+ "moves"
67
+ ],
68
+ "default": true
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }