@glance-apps/sync 0.2.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 krelltunez
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,2 @@
1
+ # glance-sync
2
+ Shared sync engine for the GLANCE app family (dayGLANCE, lifeGLANCE, lastGLANCE). WebDAV transport, AES-256-GCM encryption, conflict-free merge.
@@ -0,0 +1,147 @@
1
+ // Disable Vercel's default body parser so we can forward raw request bodies
2
+ // (e.g. text/calendar) without them being mangled or rejected as unsupported.
3
+ export const config = {
4
+ api: {
5
+ bodyParser: false,
6
+ },
7
+ };
8
+
9
+ function validateProxyUrl(urlString) {
10
+ let parsed;
11
+ try {
12
+ parsed = new URL(urlString);
13
+ } catch {
14
+ throw new Error('Invalid URL');
15
+ }
16
+
17
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
18
+ throw new Error('Only http and https URLs are allowed');
19
+ }
20
+
21
+ const hostname = parsed.hostname.toLowerCase();
22
+
23
+ if (hostname === 'localhost' || hostname === '0.0.0.0') {
24
+ throw new Error('Private/reserved addresses are not allowed');
25
+ }
26
+
27
+ // Block IPv4 private/reserved ranges
28
+ const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
29
+ if (ipv4) {
30
+ const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
31
+ if (
32
+ a === 10 ||
33
+ (a === 172 && b >= 16 && b <= 31) ||
34
+ (a === 192 && b === 168) ||
35
+ a === 127 ||
36
+ (a === 169 && b === 254) ||
37
+ a === 0 ||
38
+ (a === 100 && b >= 64 && b <= 127)
39
+ ) {
40
+ throw new Error('Private/reserved addresses are not allowed');
41
+ }
42
+ }
43
+
44
+ // Block IPv6 loopback, unspecified, link-local, and private/ULA ranges.
45
+ // ::1 — loopback
46
+ // :: — unspecified
47
+ // ::ffff:… — IPv4-mapped (e.g. ::ffff:127.0.0.1 bypasses the IPv4 check above)
48
+ // fe80:… — link-local
49
+ // fc… / fd… — Unique Local (ULA, fc00::/7); original code only blocked fd
50
+ // NOTE: DNS rebinding (public hostname → private IP at connection time) is a
51
+ // known limitation that cannot be fixed without a post-connection IP check,
52
+ // which fetch() does not expose. Risk is low on Vercel (IPv4-only runtime).
53
+ if (
54
+ hostname === '::1' ||
55
+ hostname === '::' ||
56
+ /^::ffff:/i.test(hostname) ||
57
+ /^fe80:/i.test(hostname) ||
58
+ /^fc/i.test(hostname) ||
59
+ /^fd/i.test(hostname)
60
+ ) {
61
+ throw new Error('Private/reserved addresses are not allowed');
62
+ }
63
+
64
+ return parsed;
65
+ }
66
+
67
+ function readRawBody(req) {
68
+ return new Promise((resolve, reject) => {
69
+ let data = '';
70
+ req.on('data', chunk => data += chunk);
71
+ req.on('end', () => resolve(data));
72
+ req.on('error', reject);
73
+ });
74
+ }
75
+
76
+ export default async function handler(req, res) {
77
+ // Handle CORS preflight
78
+ if (req.method === 'OPTIONS') {
79
+ res.setHeader('Access-Control-Allow-Origin', '*');
80
+ res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, MKCOL, PROPFIND, OPTIONS');
81
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, X-WebDAV-Auth, Content-Type, Depth, If-Match, If-None-Match');
82
+ res.setHeader('Access-Control-Max-Age', '86400');
83
+ return res.status(204).end();
84
+ }
85
+
86
+ const { url } = req.query;
87
+
88
+ if (!url) {
89
+ return res.status(400).json({ error: 'Missing url parameter' });
90
+ }
91
+
92
+ try {
93
+ validateProxyUrl(url);
94
+ } catch (err) {
95
+ return res.status(400).json({ error: err.message });
96
+ }
97
+
98
+ try {
99
+ const headers = {};
100
+
101
+ // Only set Content-Type for requests that have a body — sending it on GET/HEAD
102
+ // causes Apache mod_dav to return 404 (unexpected Content-Type on bodyless request).
103
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
104
+ headers['Content-Type'] = req.headers['content-type'] || 'application/octet-stream';
105
+ }
106
+
107
+ // Forward X-WebDAV-Auth as Authorization
108
+ if (req.headers['x-webdav-auth']) {
109
+ headers['Authorization'] = req.headers['x-webdav-auth'];
110
+ }
111
+
112
+ if (req.headers['depth'] !== undefined) {
113
+ headers['Depth'] = req.headers['depth'];
114
+ }
115
+
116
+ // Forward optimistic-concurrency headers.
117
+ if (req.headers['if-match']) {
118
+ headers['If-Match'] = req.headers['if-match'];
119
+ }
120
+ if (req.headers['if-none-match']) {
121
+ headers['If-None-Match'] = req.headers['if-none-match'];
122
+ }
123
+
124
+ const fetchOptions = {
125
+ method: req.method,
126
+ headers,
127
+ };
128
+
129
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
130
+ const rawBody = await readRawBody(req);
131
+ if (rawBody) {
132
+ fetchOptions.body = rawBody;
133
+ }
134
+ }
135
+
136
+ const response = await fetch(url, fetchOptions);
137
+ const body = await response.text();
138
+
139
+ res.setHeader('Content-Type', response.headers.get('content-type') || 'text/plain');
140
+ res.setHeader('Cache-Control', 'no-store');
141
+ const etag = response.headers.get('etag');
142
+ if (etag) res.setHeader('ETag', etag);
143
+ res.status(response.status).send(body);
144
+ } catch (err) {
145
+ res.status(502).json({ error: 'Failed to proxy WebDAV request' });
146
+ }
147
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@glance-apps/sync",
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "types/index.d.ts",
8
+ "files": [
9
+ "src",
10
+ "api",
11
+ "types",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "test": "vitest run"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/glance-apps/glance-sync.git"
21
+ },
22
+ "devDependencies": {
23
+ "vitest": "^3.1.4"
24
+ }
25
+ }
@@ -0,0 +1 @@
1
+ // Placeholder — auto-backup module is extracted in Step 5.
package/src/crypto.js ADDED
@@ -0,0 +1 @@
1
+ // Placeholder — encryption module is extracted in Step 3.
package/src/engine.js ADDED
@@ -0,0 +1 @@
1
+ // Placeholder — sync engine is implemented in Step 6.
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export {
2
+ mergeArrayById,
3
+ mergeDailyNotes,
4
+ mergeHabits,
5
+ mergeHabitLogs,
6
+ mergeRoutineDefinitions,
7
+ mergeSyncData,
8
+ pruneTombstones,
9
+ } from './merge.js';
package/src/merge.js ADDED
@@ -0,0 +1,884 @@
1
+ /**
2
+ * Merge engine for @glance-apps/sync.
3
+ *
4
+ * Instead of "last write wins" at the whole-dataset level, these functions
5
+ * merge arrays by ID — keeping newer versions when both sides have the same
6
+ * item, preserving items that only exist on one side, and respecting
7
+ * tombstones for permanently deleted items.
8
+ */
9
+
10
+ // Last-writer-wins for scalar config fields using per-field updatedAt timestamps.
11
+ // Falls back to remote-wins when neither side has a timestamp (legacy payloads).
12
+ const pickConfigByTs = (localVal, localTs, remoteVal, remoteTs, defaultVal) => {
13
+ const lTime = localTs ? new Date(localTs).getTime() : 0;
14
+ const rTime = remoteTs ? new Date(remoteTs).getTime() : 0;
15
+ const winner = rTime >= lTime ? remoteVal : localVal;
16
+ return winner !== undefined ? winner : defaultVal;
17
+ };
18
+ const newerTs = (a, b) => {
19
+ if (!a && !b) return null;
20
+ if (!a) return b;
21
+ if (!b) return a;
22
+ return new Date(a).getTime() >= new Date(b).getTime() ? a : b;
23
+ };
24
+
25
+ // Structural deep equality — key-order independent.
26
+ // Used instead of JSON.stringify comparisons to avoid spurious dirty flags
27
+ // when two objects have identical content but different key insertion order.
28
+ const deepEqual = (a, b) => {
29
+ if (a === b) return true;
30
+ if (a === null || b === null || typeof a !== typeof b) return false;
31
+ if (typeof a !== 'object') return a === b;
32
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
33
+ if (Array.isArray(a)) {
34
+ if (a.length !== b.length) return false;
35
+ return a.every((v, i) => deepEqual(v, b[i]));
36
+ }
37
+ const keysA = Object.keys(a);
38
+ const keysB = Object.keys(b);
39
+ if (keysA.length !== keysB.length) return false;
40
+ return keysA.every(k => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
41
+ };
42
+
43
+ /**
44
+ * Generic array merge by item ID, preserving local ordering and appending
45
+ * remote-only items at the end.
46
+ *
47
+ * @param {Array} localItems - Items from this device
48
+ * @param {Array} remoteItems - Items from the server
49
+ * @param {Object} deletedIds - Map of item ID → deletion timestamp (tombstones)
50
+ * @param {Date|null} [syncHorizon] - Items older than this date that are local-only
51
+ * are presumed zombie (tombstone pruned on remote) and silently dropped.
52
+ * @param {Object} [options]
53
+ * @param {string} [options.idField='id'] - Field name used as the item identifier
54
+ * @param {string} [options.timestampField='updatedAt'] - Field name used for last-modified comparison
55
+ * @returns {{ merged: Array, localChanged: boolean, remoteChanged: boolean }}
56
+ */
57
+ export const mergeArrayById = (localItems, remoteItems, deletedIds, syncHorizon = null, { idField = 'id', timestampField = 'updatedAt' } = {}) => {
58
+ const remoteMap = new Map(remoteItems.map(t => [String(t[idField]), t]));
59
+ const localIds = new Set(localItems.map(t => String(t[idField])));
60
+ let localChanged = false;
61
+ let remoteChanged = false;
62
+ const merged = [];
63
+
64
+ // First pass: iterate local items in order
65
+ for (const localItem of localItems) {
66
+ const id = String(localItem[idField]);
67
+ if (deletedIds[id] && new Date(deletedIds[id]) > new Date(localItem[timestampField] || 0)) {
68
+ localChanged = true; // Removed a local item
69
+ continue;
70
+ }
71
+ const remoteItem = remoteMap.get(id);
72
+ if (remoteItem) {
73
+ const localTime = new Date(localItem[timestampField] || 0);
74
+ const remoteTime = new Date(remoteItem[timestampField] || 0);
75
+ if (remoteTime > localTime) {
76
+ merged.push(remoteItem);
77
+ localChanged = true;
78
+ } else if (localTime > remoteTime) {
79
+ merged.push(localItem);
80
+ remoteChanged = true;
81
+ } else {
82
+ merged.push(localItem); // Equal timestamps — keep local
83
+ }
84
+ } else {
85
+ // Only in local. If the item is older than the sync horizon, the remote's
86
+ // tombstone for it was likely pruned — treat it as a zombie rather than
87
+ // uploading it and resurrecting a deleted item.
88
+ if (syncHorizon && localItem[timestampField] && new Date(localItem[timestampField]) < syncHorizon) {
89
+ localChanged = true; // drop silently — presumed zombie
90
+ } else {
91
+ merged.push(localItem);
92
+ remoteChanged = true;
93
+ }
94
+ }
95
+ }
96
+
97
+ // Second pass: append remote-only items
98
+ for (const remoteItem of remoteItems) {
99
+ const id = String(remoteItem[idField]);
100
+ if (localIds.has(id)) continue;
101
+ if (deletedIds[id] && new Date(deletedIds[id]) > new Date(remoteItem[timestampField] || 0)) {
102
+ remoteChanged = true; // Tell remote this was deleted
103
+ continue;
104
+ }
105
+ merged.push(remoteItem);
106
+ localChanged = true;
107
+ }
108
+
109
+ return { merged, localChanged, remoteChanged };
110
+ };
111
+
112
+ /**
113
+ * Merges routine definitions by bucket (day of week), unioning chips by ID
114
+ * and respecting tombstones for deleted chips.
115
+ *
116
+ * Each side's bucket is an array of { id, name } chips. The merge preserves
117
+ * local ordering and appends any remote-only chips at the end — mirroring the
118
+ * mergeArrayById strategy. Chips present in the deletedChipIds tombstone map
119
+ * are excluded from the merged result.
120
+ *
121
+ * @param {Object} localDefs - Local routine definitions (bucket → chip[])
122
+ * @param {Object} remoteDefs - Remote routine definitions (bucket → chip[])
123
+ * @param {Object} [deletedChipIds] - Map of chip ID → deletion timestamp (tombstones)
124
+ * @returns {{ merged: Object, localChanged: boolean, remoteChanged: boolean }}
125
+ */
126
+ export const mergeRoutineDefinitions = (localDefs, remoteDefs, deletedChipIds = {}) => {
127
+ const allBuckets = new Set([...Object.keys(localDefs), ...Object.keys(remoteDefs)]);
128
+ const merged = {};
129
+ let localChanged = false;
130
+ let remoteChanged = false;
131
+
132
+ // Check if a chip is suppressed by its tombstone (deletion must be newer than chip)
133
+ const isTombstoned = (chip) => {
134
+ const deleteTime = deletedChipIds[String(chip.id)];
135
+ if (!deleteTime) return false;
136
+ // If chip has lastModified and it's newer than the deletion, chip wins (resurrects)
137
+ if (chip.lastModified && new Date(chip.lastModified) > new Date(deleteTime)) return false;
138
+ return true;
139
+ };
140
+
141
+ for (const bucket of allBuckets) {
142
+ const localChips = localDefs[bucket] || [];
143
+ const remoteChips = remoteDefs[bucket] || [];
144
+ const localIds = new Set(localChips.map(c => String(c.id)));
145
+ const remoteMap = new Map(remoteChips.map(c => [String(c.id), c]));
146
+
147
+ // Start with local chips in order, filtering out tombstoned chips
148
+ const bucketMerged = [];
149
+ for (const chip of localChips) {
150
+ if (isTombstoned(chip)) {
151
+ localChanged = true; // chip removed locally
152
+ continue;
153
+ }
154
+ bucketMerged.push(chip);
155
+ }
156
+
157
+ // Append remote-only chips (skip tombstoned)
158
+ for (const remoteChip of remoteChips) {
159
+ const id = String(remoteChip.id);
160
+ if (localIds.has(id)) continue;
161
+ if (isTombstoned(remoteChip)) {
162
+ remoteChanged = true; // tell remote this was deleted
163
+ continue;
164
+ }
165
+ bucketMerged.push(remoteChip);
166
+ localChanged = true;
167
+ }
168
+
169
+ // Check for local-only chips (remote needs them)
170
+ for (const localChip of localChips) {
171
+ const id = String(localChip.id);
172
+ if (isTombstoned(localChip)) continue; // don't flag deleted chips as needing push
173
+ if (!remoteMap.has(id)) {
174
+ remoteChanged = true;
175
+ }
176
+ }
177
+
178
+ merged[bucket] = bucketMerged;
179
+ }
180
+
181
+ // Bucket only on remote → local needs it
182
+ for (const bucket of Object.keys(remoteDefs)) {
183
+ if (!localDefs[bucket] && remoteDefs[bucket]?.length > 0) {
184
+ // Only flag if there are non-tombstoned chips
185
+ if (remoteDefs[bucket].some(c => !isTombstoned(c))) {
186
+ localChanged = true;
187
+ }
188
+ }
189
+ }
190
+ // Bucket only on local → remote needs it
191
+ for (const bucket of Object.keys(localDefs)) {
192
+ if (!remoteDefs[bucket] && localDefs[bucket]?.length > 0) {
193
+ if (localDefs[bucket].some(c => !isTombstoned(c))) {
194
+ remoteChanged = true;
195
+ }
196
+ }
197
+ }
198
+
199
+ return { merged, localChanged, remoteChanged };
200
+ };
201
+
202
+ /**
203
+ * Merges daily notes by date key, keeping the newer version per day.
204
+ * Entries with { deleted: true } are tombstones representing intentional
205
+ * deletions — they participate in the "newer wins" comparison so that a
206
+ * deletion on one device propagates to the other instead of being
207
+ * resurrected by the non-deleted copy.
208
+ *
209
+ * @param {Object} localNotes - Local daily notes { "YYYY-MM-DD": { text, lastModified, deleted? } }
210
+ * @param {Object} remoteNotes - Remote daily notes
211
+ * @returns {{ merged: Object, localChanged: boolean, remoteChanged: boolean }}
212
+ */
213
+ export const mergeDailyNotes = (localNotes, remoteNotes) => {
214
+ const allDates = new Set([...Object.keys(localNotes), ...Object.keys(remoteNotes)]);
215
+ const merged = {};
216
+ let localChanged = false;
217
+ let remoteChanged = false;
218
+
219
+ for (const dateKey of allDates) {
220
+ const local = localNotes[dateKey];
221
+ const remote = remoteNotes[dateKey];
222
+
223
+ if (local && !remote) {
224
+ // Only local has it — keep it (remote needs it, even if tombstone)
225
+ merged[dateKey] = local;
226
+ remoteChanged = true;
227
+ } else if (!local && remote) {
228
+ // Only remote has it — local needs it
229
+ merged[dateKey] = remote;
230
+ localChanged = true;
231
+ } else {
232
+ // Both have it — newer wins (tombstones included)
233
+ const localTime = new Date(local.lastModified || 0);
234
+ const remoteTime = new Date(remote.lastModified || 0);
235
+ if (remoteTime > localTime) {
236
+ merged[dateKey] = remote;
237
+ localChanged = true;
238
+ } else if (localTime > remoteTime) {
239
+ merged[dateKey] = local;
240
+ remoteChanged = true;
241
+ } else {
242
+ merged[dateKey] = local; // Equal — keep local
243
+ }
244
+ }
245
+ }
246
+
247
+ return { merged, localChanged, remoteChanged };
248
+ };
249
+
250
+ /**
251
+ * Merges habit arrays by ID, preserving local ordering and appending
252
+ * remote-only habits at the end.
253
+ *
254
+ * Habits don't have lastModified timestamps, so when both sides have the
255
+ * same habit we keep the version with the most recent `createdAt` (in
256
+ * practice they'll be identical). For mutable fields like `archived` and
257
+ * `name`, the version with a newer `createdAt` wins; if equal, local wins.
258
+ *
259
+ * Habits whose ID appears in either side's deletedHabitIds tombstone map
260
+ * are suppressed and the tombstone is propagated to both sides so the
261
+ * deletion persists across devices.
262
+ *
263
+ * @param {Array} localHabits - Habits from this device
264
+ * @param {Array} remoteHabits - Habits from the server
265
+ * @param {Object} localDeletedIds - Map of habit ID → deletion timestamp (tombstones)
266
+ * @param {Object} remoteDeletedIds - Map of habit ID → deletion timestamp (tombstones)
267
+ * @returns {{ merged: Array, mergedDeletedIds: Object, localChanged: boolean, remoteChanged: boolean }}
268
+ */
269
+ export const mergeHabits = (localHabits, remoteHabits, localDeletedIds = {}, remoteDeletedIds = {}) => {
270
+ // Build the union of tombstones, keeping the most recent deletion timestamp
271
+ const mergedDeletedIds = { ...localDeletedIds };
272
+ for (const [id, ts] of Object.entries(remoteDeletedIds)) {
273
+ if (!mergedDeletedIds[id] || new Date(ts) > new Date(mergedDeletedIds[id])) {
274
+ mergedDeletedIds[id] = ts;
275
+ }
276
+ }
277
+
278
+ const remoteMap = new Map(remoteHabits.map(h => [String(h.id), h]));
279
+ const localIds = new Set(localHabits.map(h => String(h.id)));
280
+ let localChanged = false;
281
+ let remoteChanged = false;
282
+ const merged = [];
283
+
284
+ for (const localHabit of localHabits) {
285
+ const id = String(localHabit.id);
286
+ // Skip habits suppressed by a tombstone that is newer than the habit itself.
287
+ // If the habit was modified after the deletion (e.g. recreated on another device),
288
+ // the habit's lastModified wins and it is kept — matching task tombstone behaviour.
289
+ if (mergedDeletedIds[id] && new Date(mergedDeletedIds[id]) > new Date(localHabit.lastModified || localHabit.createdAt || 0)) {
290
+ remoteChanged = true; // remote may still have this habit
291
+ continue;
292
+ }
293
+ const remoteHabit = remoteMap.get(id);
294
+ if (remoteHabit) {
295
+ const localTime = new Date(localHabit.lastModified || localHabit.createdAt || 0);
296
+ const remoteTime = new Date(remoteHabit.lastModified || remoteHabit.createdAt || 0);
297
+ if (remoteTime > localTime) {
298
+ merged.push(remoteHabit);
299
+ localChanged = true;
300
+ } else if (localTime > remoteTime) {
301
+ merged.push(localHabit);
302
+ remoteChanged = true;
303
+ } else {
304
+ // Equal — check for field-level differences (e.g. archived on one side)
305
+ if (!deepEqual(localHabit, remoteHabit)) {
306
+ merged.push(localHabit); // keep local
307
+ remoteChanged = true;
308
+ } else {
309
+ merged.push(localHabit);
310
+ }
311
+ }
312
+ } else {
313
+ merged.push(localHabit);
314
+ remoteChanged = true;
315
+ }
316
+ }
317
+
318
+ for (const remoteHabit of remoteHabits) {
319
+ const id = String(remoteHabit.id);
320
+ if (mergedDeletedIds[id] && new Date(mergedDeletedIds[id]) > new Date(remoteHabit.lastModified || remoteHabit.createdAt || 0)) {
321
+ localChanged = true; // local may still have this habit
322
+ continue;
323
+ }
324
+ if (!localIds.has(id)) {
325
+ merged.push(remoteHabit);
326
+ localChanged = true;
327
+ }
328
+ }
329
+
330
+ // Propagate merged tombstones if either side is missing entries
331
+ if (Object.keys(mergedDeletedIds).length !== Object.keys(localDeletedIds).length) localChanged = true;
332
+ if (Object.keys(mergedDeletedIds).length !== Object.keys(remoteDeletedIds).length) remoteChanged = true;
333
+
334
+ return { merged, mergedDeletedIds, localChanged, remoteChanged };
335
+ };
336
+
337
+ /**
338
+ * Merges habit logs by date key. Within each date, per-habit counts are
339
+ * merged by taking the maximum value (counts only increase within a day).
340
+ *
341
+ * @param {Object} localLogs - { "YYYY-MM-DD": { habitId: count } }
342
+ * @param {Object} remoteLogs - { "YYYY-MM-DD": { habitId: count } }
343
+ * @param {Object} [localTs] - { "YYYY-MM-DD:habitId": ISO } timestamps for local entries
344
+ * @param {Object} [remoteTs] - { "YYYY-MM-DD:habitId": ISO } timestamps for remote entries
345
+ * @returns {{ merged: Object, mergedTimestamps: Object, localChanged: boolean, remoteChanged: boolean }}
346
+ */
347
+ export const mergeHabitLogs = (localLogs, remoteLogs, localTs = {}, remoteTs = {}) => {
348
+ const allDates = new Set([...Object.keys(localLogs), ...Object.keys(remoteLogs)]);
349
+ const merged = {};
350
+ const mergedTimestamps = { ...localTs };
351
+ let localChanged = false;
352
+ let remoteChanged = false;
353
+
354
+ // Merge remote timestamps in (keep the newer of each key).
355
+ for (const [k, v] of Object.entries(remoteTs)) {
356
+ if (!mergedTimestamps[k] || new Date(v) > new Date(mergedTimestamps[k])) {
357
+ mergedTimestamps[k] = v;
358
+ }
359
+ }
360
+
361
+ for (const dateKey of allDates) {
362
+ const local = localLogs[dateKey];
363
+ const remote = remoteLogs[dateKey];
364
+
365
+ if (local && !remote) {
366
+ merged[dateKey] = local;
367
+ remoteChanged = true;
368
+ } else if (!local && remote) {
369
+ merged[dateKey] = remote;
370
+ localChanged = true;
371
+ } else {
372
+ // Both have it — per-habit merge using timestamps when available, Math.max for legacy.
373
+ const allHabitIds = new Set([...Object.keys(local), ...Object.keys(remote)]);
374
+ const dayMerged = {};
375
+ for (const habitId of allHabitIds) {
376
+ const localCount = local[habitId] !== undefined ? local[habitId] : 0;
377
+ const remoteCount = remote[habitId] !== undefined ? remote[habitId] : 0;
378
+ const tsKey = `${dateKey}:${habitId}`;
379
+ const lTime = localTs[tsKey] ? new Date(localTs[tsKey]).getTime() : 0;
380
+ const rTime = remoteTs[tsKey] ? new Date(remoteTs[tsKey]).getTime() : 0;
381
+
382
+ let winner;
383
+ if (lTime > 0 || rTime > 0) {
384
+ // Timestamp-based: last-writer-wins (handles decrements).
385
+ winner = lTime >= rTime ? localCount : remoteCount;
386
+ if (lTime > rTime && localCount !== remoteCount) remoteChanged = true;
387
+ if (rTime > lTime && localCount !== remoteCount) localChanged = true;
388
+ } else {
389
+ // Legacy (no timestamps): take the max to be safe.
390
+ winner = Math.max(localCount, remoteCount);
391
+ if (remoteCount > localCount) localChanged = true;
392
+ if (localCount > remoteCount) remoteChanged = true;
393
+ }
394
+ dayMerged[habitId] = winner;
395
+ if (local[habitId] === undefined && remote[habitId] !== undefined) localChanged = true;
396
+ if (remote[habitId] === undefined && local[habitId] !== undefined) remoteChanged = true;
397
+ }
398
+ merged[dateKey] = dayMerged;
399
+ }
400
+ }
401
+
402
+ return { merged, mergedTimestamps, localChanged, remoteChanged };
403
+ };
404
+
405
+ // Prune tombstone entries older than the cutoff date.
406
+ export const pruneTombstones = (tombstones, cutoff) => {
407
+ if (!cutoff) return tombstones;
408
+ const pruned = {};
409
+ for (const [id, ts] of Object.entries(tombstones)) {
410
+ if (new Date(ts) >= cutoff) pruned[id] = ts;
411
+ }
412
+ return pruned;
413
+ };
414
+
415
+ /**
416
+ * Full data-level merge: combines local and remote sync snapshots with
417
+ * per-item granularity.
418
+ *
419
+ * Handles: scheduled tasks, inbox tasks, recycle bin, recurring tasks,
420
+ * completed UIDs, tombstones, and cross-list reconciliation.
421
+ *
422
+ * @param {Object} localData - Local sync payload .data
423
+ * @param {Object} remoteData - Remote sync payload .data
424
+ * @returns {{ data: Object, localChanged: boolean, remoteChanged: boolean }}
425
+ */
426
+ export const mergeSyncData = (localData, remoteData, retentionDays = 90) => {
427
+ // Combine tombstones (permanently deleted task IDs) from both sides
428
+ const localDeleted = localData.deletedTaskIds || {};
429
+ const remoteDeleted = remoteData.deletedTaskIds || {};
430
+ const allDeletedIds = { ...localDeleted };
431
+ for (const [id, ts] of Object.entries(remoteDeleted)) {
432
+ if (!allDeletedIds[id] || new Date(ts) > new Date(allDeletedIds[id])) {
433
+ allDeletedIds[id] = ts;
434
+ }
435
+ }
436
+
437
+ // Fence: any local-only task older than the remote's pruned-before date may
438
+ // have been deleted on another device whose tombstone was already pruned.
439
+ // We suppress resurrection of such tasks rather than uploading them.
440
+ const syncHorizon = remoteData.tombstonePrunedBefore
441
+ ? new Date(remoteData.tombstonePrunedBefore)
442
+ : null;
443
+
444
+ // Merge each task list (tasks use 'lastModified' as their timestamp field)
445
+ const taskOpts = { idField: 'id', timestampField: 'lastModified' };
446
+ const tasksMerge = mergeArrayById(localData.tasks || [], remoteData.tasks || [], allDeletedIds, syncHorizon, taskOpts);
447
+ const unschedMerge = mergeArrayById(localData.unscheduledTasks || [], remoteData.unscheduledTasks || [], allDeletedIds, syncHorizon, taskOpts);
448
+ const binMerge = mergeArrayById(localData.recycleBin || [], remoteData.recycleBin || [], allDeletedIds, syncHorizon, taskOpts);
449
+ const recurMerge = mergeArrayById(localData.recurringTasks || [], remoteData.recurringTasks || [], allDeletedIds, syncHorizon, taskOpts);
450
+
451
+ // Combine routine chip tombstones from both sides
452
+ const localDeletedChips = localData.deletedRoutineChipIds || {};
453
+ const remoteDeletedChips = remoteData.deletedRoutineChipIds || {};
454
+ const allDeletedChipIds = { ...localDeletedChips };
455
+ for (const [id, ts] of Object.entries(remoteDeletedChips)) {
456
+ if (!allDeletedChipIds[id] || new Date(ts) > new Date(allDeletedChipIds[id])) {
457
+ allDeletedChipIds[id] = ts;
458
+ }
459
+ }
460
+
461
+ // Merge routine definitions by bucket (with tombstone support)
462
+ const routineMerge = mergeRoutineDefinitions(localData.routineDefinitions || {}, remoteData.routineDefinitions || {}, allDeletedChipIds);
463
+
464
+ // Combine "removed from today" tombstones from both sides.
465
+ // These are separate from deletedRoutineChipIds (which are for permanent
466
+ // definition deletes) — removedTodayRoutineIds tracks routines the user
467
+ // un-checked from Today's Routine without deleting the definition.
468
+ const localRemovedToday = localData.removedTodayRoutineIds || {};
469
+ const remoteRemovedToday = remoteData.removedTodayRoutineIds || {};
470
+ const allRemovedTodayIds = { ...localRemovedToday };
471
+ for (const [id, ts] of Object.entries(remoteRemovedToday)) {
472
+ if (!allRemovedTodayIds[id] || new Date(ts) > new Date(allRemovedTodayIds[id])) {
473
+ allRemovedTodayIds[id] = ts;
474
+ }
475
+ }
476
+
477
+ // Build combined tombstone set for todayRoutines merge: permanent chip
478
+ // deletes + today-specific removals both suppress a routine from reappearing.
479
+ const todayRoutineTombstones = { ...allDeletedChipIds, ...allRemovedTodayIds };
480
+
481
+ // Merge today's selected routines across devices.
482
+ // Only union by ID when both sides are on the same date — if dates differ,
483
+ // keep the newer date's routines (avoids mixing yesterday's with today's).
484
+ const localRoutinesDate = localData.routinesDate || '';
485
+ const remoteRoutinesDate = remoteData.routinesDate || '';
486
+ let todayRoutinesMerge;
487
+ let mergedRoutinesDate;
488
+ if (localRoutinesDate === remoteRoutinesDate) {
489
+ todayRoutinesMerge = mergeArrayById(localData.todayRoutines || [], remoteData.todayRoutines || [], todayRoutineTombstones, syncHorizon, taskOpts);
490
+ mergedRoutinesDate = localRoutinesDate;
491
+ } else if (localRoutinesDate > remoteRoutinesDate) {
492
+ todayRoutinesMerge = { merged: localData.todayRoutines || [], localChanged: false, remoteChanged: true };
493
+ mergedRoutinesDate = localRoutinesDate;
494
+ } else {
495
+ todayRoutinesMerge = { merged: remoteData.todayRoutines || [], localChanged: true, remoteChanged: false };
496
+ mergedRoutinesDate = remoteRoutinesDate;
497
+ }
498
+
499
+ // Merge routine completions: union both sides, keeping only entries that match mergedRoutinesDate.
500
+ // If the dates differed above, the losing side's completions are dropped (stale day).
501
+ const localCompletions = localData.routineCompletions || {};
502
+ const remoteCompletions = remoteData.routineCompletions || {};
503
+ const mergedCompletions = {};
504
+ for (const [id, date] of Object.entries(localCompletions)) {
505
+ if (date === mergedRoutinesDate) mergedCompletions[id] = date;
506
+ }
507
+ for (const [id, date] of Object.entries(remoteCompletions)) {
508
+ if (date === mergedRoutinesDate) mergedCompletions[id] = date;
509
+ }
510
+
511
+ // Merge daily notes by date key
512
+ const dailyNotesMerge = mergeDailyNotes(localData.dailyNotes || {}, remoteData.dailyNotes || {});
513
+
514
+ // Merge habits and habit logs
515
+ const localDeletedHabitIds = localData.deletedHabitIds || {};
516
+ const remoteDeletedHabitIds = remoteData.deletedHabitIds || {};
517
+ const habitsMerge = mergeHabits(localData.habits || [], remoteData.habits || [], localDeletedHabitIds, remoteDeletedHabitIds);
518
+ const habitLogsMerge = mergeHabitLogs(
519
+ localData.habitLogs || {},
520
+ remoteData.habitLogs || {},
521
+ localData.habitLogTimestamps || {},
522
+ remoteData.habitLogTimestamps || {}
523
+ );
524
+
525
+ // Detect routine completion changes: did the merge add completions that one side was missing?
526
+ const localCompletionsFiltered = Object.fromEntries(Object.entries(localCompletions).filter(([, d]) => d === mergedRoutinesDate));
527
+ const remoteCompletionsFiltered = Object.fromEntries(Object.entries(remoteCompletions).filter(([, d]) => d === mergedRoutinesDate));
528
+ const completionsLocalChanged = Object.keys(mergedCompletions).some(id => !(id in localCompletionsFiltered));
529
+ const completionsRemoteChanged = Object.keys(mergedCompletions).some(id => !(id in remoteCompletionsFiltered));
530
+
531
+ let localChanged = tasksMerge.localChanged || unschedMerge.localChanged || binMerge.localChanged || recurMerge.localChanged || routineMerge.localChanged || todayRoutinesMerge.localChanged || dailyNotesMerge.localChanged || habitsMerge.localChanged || habitLogsMerge.localChanged || completionsLocalChanged;
532
+ let remoteChanged = tasksMerge.remoteChanged || unschedMerge.remoteChanged || binMerge.remoteChanged || recurMerge.remoteChanged || routineMerge.remoteChanged || todayRoutinesMerge.remoteChanged || dailyNotesMerge.remoteChanged || habitsMerge.remoteChanged || habitLogsMerge.remoteChanged || completionsRemoteChanged;
533
+
534
+ // Reconcile cross-list conflicts: task active on one device, in recycle bin on other
535
+ const recycledMap = new Map(binMerge.merged.map(t => [String(t.id), t]));
536
+ const reconciledTasks = tasksMerge.merged.filter(t => {
537
+ const recycled = recycledMap.get(String(t.id));
538
+ if (!recycled) return true;
539
+ return new Date(t.lastModified || 0) > new Date(recycled.deletedAt || recycled.lastModified || 0);
540
+ });
541
+ const reconciledUnsched = unschedMerge.merged.filter(t => {
542
+ const recycled = recycledMap.get(String(t.id));
543
+ if (!recycled) return true;
544
+ return new Date(t.lastModified || 0) > new Date(recycled.deletedAt || recycled.lastModified || 0);
545
+ });
546
+ const keptActiveIds = new Set([
547
+ ...reconciledTasks.map(t => String(t.id)),
548
+ ...reconciledUnsched.map(t => String(t.id))
549
+ ]);
550
+ const reconciledBin = binMerge.merged.filter(t => !keptActiveIds.has(String(t.id)));
551
+
552
+ // Also reconcile tasks that moved between scheduled ↔ inbox across devices
553
+ const inScheduled = new Map(reconciledTasks.map(t => [String(t.id), t]));
554
+ const inInbox = new Map(reconciledUnsched.map(t => [String(t.id), t]));
555
+ const crossListIds = new Set();
556
+ for (const [id] of inScheduled) {
557
+ if (inInbox.has(id)) crossListIds.add(id);
558
+ }
559
+ let finalTasks = reconciledTasks;
560
+ let finalUnsched = reconciledUnsched;
561
+
562
+ // Apply the ordering from whichever side last explicitly reordered their inbox.
563
+ // Without this, reordering tasks in a project card would never propagate via sync
564
+ // because individual task lastModified timestamps don't change on reorder.
565
+ const localOrderTs = new Date(localData.unscheduledOrderTimestamp || 0);
566
+ const remoteOrderTs = new Date(remoteData.unscheduledOrderTimestamp || 0);
567
+ if (remoteOrderTs > localOrderTs && remoteData.unscheduledTasks?.length) {
568
+ const remoteOrderIds = remoteData.unscheduledTasks.map(t => String(t.id));
569
+ const unschedById = new Map(finalUnsched.map(t => [String(t.id), t]));
570
+ const ordered = [];
571
+ for (const id of remoteOrderIds) {
572
+ if (unschedById.has(id)) ordered.push(unschedById.get(id));
573
+ }
574
+ // append tasks not present in remote order (new local-only tasks)
575
+ const remoteOrderSet = new Set(remoteOrderIds);
576
+ for (const t of finalUnsched) {
577
+ if (!remoteOrderSet.has(String(t.id))) ordered.push(t);
578
+ }
579
+ finalUnsched = ordered;
580
+ localChanged = true;
581
+ }
582
+
583
+ if (crossListIds.size > 0) {
584
+ const keepInScheduled = new Set();
585
+ const keepInInbox = new Set();
586
+ for (const id of crossListIds) {
587
+ const sTask = inScheduled.get(id);
588
+ const iTask = inInbox.get(id);
589
+ if (new Date(sTask.lastModified || 0) >= new Date(iTask.lastModified || 0)) {
590
+ keepInScheduled.add(id);
591
+ } else {
592
+ keepInInbox.add(id);
593
+ }
594
+ }
595
+ finalTasks = reconciledTasks.filter(t => !crossListIds.has(String(t.id)) || keepInScheduled.has(String(t.id)));
596
+ finalUnsched = reconciledUnsched.filter(t => !crossListIds.has(String(t.id)) || keepInInbox.has(String(t.id)));
597
+ }
598
+
599
+ // Union of completed task UIDs, pruned to the retention window.
600
+ // UIDs are "icalUid::YYYY-MM-DD"; entries with a parseable date outside the
601
+ // retention window are dropped so the set doesn't grow unboundedly.
602
+ const uidCutoff = retentionDays > 0 ? new Date(Date.now() - retentionDays * 86400000) : null;
603
+ const mergedCompletedUids = [...new Set([
604
+ ...(localData.completedTaskUids || []),
605
+ ...(remoteData.completedTaskUids || [])
606
+ ])].filter(uid => {
607
+ if (!uidCutoff) return true;
608
+ const m = uid.match(/::(\d{4}-\d{2}-\d{2})$/);
609
+ return !m || new Date(m[1]) >= uidCutoff;
610
+ });
611
+ if (mergedCompletedUids.length !== (localData.completedTaskUids || []).length) localChanged = true;
612
+ if (mergedCompletedUids.length !== (remoteData.completedTaskUids || []).length) remoteChanged = true;
613
+
614
+ // Combine frame tombstones from both sides
615
+ const localDeletedFrames = localData.deletedFrameIds || {};
616
+ const remoteDeletedFrames = remoteData.deletedFrameIds || {};
617
+ const allDeletedFrameIds = { ...localDeletedFrames };
618
+ for (const [id, ts] of Object.entries(remoteDeletedFrames)) {
619
+ if (!allDeletedFrameIds[id] || new Date(ts) > new Date(allDeletedFrameIds[id])) {
620
+ allDeletedFrameIds[id] = ts;
621
+ }
622
+ }
623
+
624
+ // Check if tombstones changed
625
+ if (Object.keys(allDeletedIds).length !== Object.keys(localDeleted).length) localChanged = true;
626
+ if (Object.keys(allDeletedIds).length !== Object.keys(remoteDeleted).length) remoteChanged = true;
627
+ if (Object.keys(allDeletedChipIds).length !== Object.keys(localDeletedChips).length) localChanged = true;
628
+ if (Object.keys(allDeletedChipIds).length !== Object.keys(remoteDeletedChips).length) remoteChanged = true;
629
+ if (Object.keys(allRemovedTodayIds).length !== Object.keys(localRemovedToday).length) localChanged = true;
630
+ if (Object.keys(allRemovedTodayIds).length !== Object.keys(remoteRemovedToday).length) remoteChanged = true;
631
+ if (Object.keys(allDeletedFrameIds).length !== Object.keys(localDeletedFrames).length) localChanged = true;
632
+ if (Object.keys(allDeletedFrameIds).length !== Object.keys(remoteDeletedFrames).length) remoteChanged = true;
633
+
634
+ // Merge GTD frames by ID, respecting tombstones
635
+ const localFrames = localData.gtdFrames || [];
636
+ const remoteFrames = remoteData.gtdFrames || [];
637
+ const localFrameIds = new Set(localFrames.map(f => String(f.id)));
638
+ const remoteFrameMap = new Map(remoteFrames.map(f => [String(f.id), f]));
639
+ const mergedFrames = [];
640
+
641
+ // Keep local frames that aren't tombstoned, using lastModified to resolve conflicts.
642
+ // When both sides differ but neither has a lastModified timestamp (legacy frames),
643
+ // keep local rather than blindly preferring remote.
644
+ for (const localFrame of localFrames) {
645
+ const id = String(localFrame.id);
646
+ if (allDeletedFrameIds[id] && new Date(allDeletedFrameIds[id]) > new Date(localFrame.lastModified || 0)) {
647
+ localChanged = true; // frame was deleted (tombstone is newer)
648
+ continue;
649
+ }
650
+ const remoteFrame = remoteFrameMap.get(id);
651
+ if (remoteFrame && !deepEqual(localFrame, remoteFrame)) {
652
+ const localTime = new Date(localFrame.lastModified || 0);
653
+ const remoteTime = new Date(remoteFrame.lastModified || 0);
654
+ if (remoteTime > localTime) {
655
+ mergedFrames.push(remoteFrame);
656
+ localChanged = true;
657
+ } else {
658
+ // Local is newer, or neither side has a timestamp — keep local.
659
+ mergedFrames.push(localFrame);
660
+ remoteChanged = true;
661
+ }
662
+ } else {
663
+ mergedFrames.push(localFrame);
664
+ if (!remoteFrame) remoteChanged = true; // local-only → remote needs it
665
+ }
666
+ }
667
+ // Append remote-only frames that aren't tombstoned
668
+ for (const remoteFrame of remoteFrames) {
669
+ const id = String(remoteFrame.id);
670
+ if (localFrameIds.has(id)) continue;
671
+ if (allDeletedFrameIds[id] && new Date(allDeletedFrameIds[id]) > new Date(remoteFrame.lastModified || 0)) {
672
+ remoteChanged = true; // tell remote this was deleted
673
+ continue;
674
+ }
675
+ mergedFrames.push(remoteFrame);
676
+ localChanged = true;
677
+ }
678
+
679
+ // habitsEnabled / routinesEnabled: last-writer-wins via per-field timestamps.
680
+ const localHabitsEnabled = localData.habitsEnabled !== undefined ? localData.habitsEnabled : true;
681
+ const remoteHabitsEnabled = remoteData.habitsEnabled !== undefined ? remoteData.habitsEnabled : true;
682
+ const mergedHabitsEnabled = pickConfigByTs(localHabitsEnabled, localData.habitsEnabledUpdatedAt, remoteHabitsEnabled, remoteData.habitsEnabledUpdatedAt, true);
683
+ const mergedHabitsEnabledUpdatedAt = newerTs(localData.habitsEnabledUpdatedAt, remoteData.habitsEnabledUpdatedAt);
684
+ if (mergedHabitsEnabled !== localHabitsEnabled) localChanged = true;
685
+ if (mergedHabitsEnabled !== remoteHabitsEnabled) remoteChanged = true;
686
+
687
+ // When neither device has set routinesEnabled, emit undefined so the app
688
+ // supplies the default — avoids propagating a synthetic true to old clients.
689
+ const bothRoutinesUnset = localData.routinesEnabled === undefined && remoteData.routinesEnabled === undefined;
690
+ const localRoutinesEnabled = localData.routinesEnabled !== undefined ? localData.routinesEnabled : true;
691
+ const remoteRoutinesEnabled = remoteData.routinesEnabled !== undefined ? remoteData.routinesEnabled : true;
692
+ const mergedRoutinesEnabled = bothRoutinesUnset ? undefined : pickConfigByTs(localRoutinesEnabled, localData.routinesEnabledUpdatedAt, remoteRoutinesEnabled, remoteData.routinesEnabledUpdatedAt, true);
693
+ const mergedRoutinesEnabledUpdatedAt = newerTs(localData.routinesEnabledUpdatedAt, remoteData.routinesEnabledUpdatedAt);
694
+ if (!bothRoutinesUnset && mergedRoutinesEnabled !== localRoutinesEnabled) localChanged = true;
695
+ if (!bothRoutinesUnset && mergedRoutinesEnabled !== remoteRoutinesEnabled) remoteChanged = true;
696
+
697
+ // Combine goal and project tombstones from both sides
698
+ const localDeletedGoalIds = localData.deletedGoalIds || {};
699
+ const remoteDeletedGoalIds = remoteData.deletedGoalIds || {};
700
+ const allDeletedGoalIds = { ...localDeletedGoalIds };
701
+ for (const [id, ts] of Object.entries(remoteDeletedGoalIds)) {
702
+ if (!allDeletedGoalIds[id] || new Date(ts) > new Date(allDeletedGoalIds[id])) {
703
+ allDeletedGoalIds[id] = ts;
704
+ }
705
+ }
706
+ const localDeletedProjectIds = localData.deletedProjectIds || {};
707
+ const remoteDeletedProjectIds = remoteData.deletedProjectIds || {};
708
+ const allDeletedProjectIds = { ...localDeletedProjectIds };
709
+ for (const [id, ts] of Object.entries(remoteDeletedProjectIds)) {
710
+ if (!allDeletedProjectIds[id] || new Date(ts) > new Date(allDeletedProjectIds[id])) {
711
+ allDeletedProjectIds[id] = ts;
712
+ }
713
+ }
714
+
715
+ // Merge goals by ID using updatedAt for conflict resolution (same strategy as frames)
716
+ const localGoals = localData.goals || [];
717
+ const remoteGoals = remoteData.goals || [];
718
+ const localGoalIds = new Set(localGoals.map(g => String(g.id)));
719
+ const remoteGoalMap = new Map(remoteGoals.map(g => [String(g.id), g]));
720
+ const mergedGoals = [];
721
+ for (const localGoal of localGoals) {
722
+ const id = String(localGoal.id);
723
+ if (allDeletedGoalIds[id] && new Date(allDeletedGoalIds[id]) > new Date(localGoal.updatedAt || 0)) {
724
+ localChanged = true; // goal was deleted
725
+ continue;
726
+ }
727
+ const remoteGoal = remoteGoalMap.get(id);
728
+ if (remoteGoal && !deepEqual(localGoal, remoteGoal)) {
729
+ const localTime = new Date(localGoal.updatedAt || 0);
730
+ const remoteTime = new Date(remoteGoal.updatedAt || 0);
731
+ if (remoteTime > localTime) {
732
+ mergedGoals.push(remoteGoal);
733
+ localChanged = true;
734
+ } else {
735
+ mergedGoals.push(localGoal);
736
+ if (remoteTime < localTime) remoteChanged = true;
737
+ }
738
+ } else {
739
+ mergedGoals.push(localGoal);
740
+ if (!remoteGoal) remoteChanged = true;
741
+ }
742
+ }
743
+ for (const remoteGoal of remoteGoals) {
744
+ if (localGoalIds.has(String(remoteGoal.id))) continue;
745
+ if (allDeletedGoalIds[String(remoteGoal.id)] && new Date(allDeletedGoalIds[String(remoteGoal.id)]) > new Date(remoteGoal.updatedAt || 0)) {
746
+ remoteChanged = true; // tell remote this was deleted
747
+ continue;
748
+ }
749
+ mergedGoals.push(remoteGoal);
750
+ localChanged = true;
751
+ }
752
+
753
+ // Merge projects by ID using updatedAt for conflict resolution (same strategy as frames)
754
+ const localProjects = localData.projects || [];
755
+ const remoteProjects = remoteData.projects || [];
756
+ const localProjectIds = new Set(localProjects.map(p => String(p.id)));
757
+ const remoteProjectMap = new Map(remoteProjects.map(p => [String(p.id), p]));
758
+ const mergedProjects = [];
759
+ for (const localProject of localProjects) {
760
+ const id = String(localProject.id);
761
+ if (allDeletedProjectIds[id] && new Date(allDeletedProjectIds[id]) > new Date(localProject.updatedAt || 0)) {
762
+ localChanged = true; // project was deleted
763
+ continue;
764
+ }
765
+ const remoteProject = remoteProjectMap.get(id);
766
+ if (remoteProject && !deepEqual(localProject, remoteProject)) {
767
+ const localTime = new Date(localProject.updatedAt || 0);
768
+ const remoteTime = new Date(remoteProject.updatedAt || 0);
769
+ if (remoteTime > localTime) {
770
+ mergedProjects.push(remoteProject);
771
+ localChanged = true;
772
+ } else {
773
+ mergedProjects.push(localProject);
774
+ if (remoteTime < localTime) remoteChanged = true;
775
+ }
776
+ } else {
777
+ mergedProjects.push(localProject);
778
+ if (!remoteProject) remoteChanged = true;
779
+ }
780
+ }
781
+ for (const remoteProject of remoteProjects) {
782
+ if (localProjectIds.has(String(remoteProject.id))) continue;
783
+ if (allDeletedProjectIds[String(remoteProject.id)] && new Date(allDeletedProjectIds[String(remoteProject.id)]) > new Date(remoteProject.updatedAt || 0)) {
784
+ remoteChanged = true; // tell remote this was deleted
785
+ continue;
786
+ }
787
+ mergedProjects.push(remoteProject);
788
+ localChanged = true;
789
+ }
790
+
791
+ // goalsProjectsEnabled: last-writer-wins
792
+ const localGoalsProjectsEnabled = localData.goalsProjectsEnabled !== undefined ? localData.goalsProjectsEnabled : false;
793
+ const remoteGoalsProjectsEnabled = remoteData.goalsProjectsEnabled !== undefined ? remoteData.goalsProjectsEnabled : false;
794
+ const mergedGoalsProjectsEnabled = pickConfigByTs(localGoalsProjectsEnabled, localData.goalsProjectsEnabledUpdatedAt, remoteGoalsProjectsEnabled, remoteData.goalsProjectsEnabledUpdatedAt, false);
795
+ const mergedGoalsProjectsEnabledUpdatedAt = newerTs(localData.goalsProjectsEnabledUpdatedAt, remoteData.goalsProjectsEnabledUpdatedAt);
796
+ if (mergedGoalsProjectsEnabled !== localGoalsProjectsEnabled) localChanged = true;
797
+ if (mergedGoalsProjectsEnabled !== remoteGoalsProjectsEnabled) remoteChanged = true;
798
+
799
+ // obsidianConfig change detection (merge happens in return object via pickConfigByTs above)
800
+ const mergedObsidianConfig = pickConfigByTs(localData.obsidianConfig, localData.obsidianConfigUpdatedAt, remoteData.obsidianConfig, remoteData.obsidianConfigUpdatedAt, null);
801
+ if (JSON.stringify(mergedObsidianConfig) !== JSON.stringify(localData.obsidianConfig ?? null)) localChanged = true;
802
+ if (JSON.stringify(mergedObsidianConfig) !== JSON.stringify(remoteData.obsidianConfig ?? null)) remoteChanged = true;
803
+
804
+ // Detect calendar URL changes so the sync cycle completes even when URLs
805
+ // are the only difference between local and remote.
806
+ const mergedSyncUrl = remoteData.syncUrl || localData.syncUrl || '';
807
+ const mergedTaskCalUrl = remoteData.taskCalendarUrl || localData.taskCalendarUrl || '';
808
+ if (mergedSyncUrl !== (localData.syncUrl || '')) localChanged = true;
809
+ if (mergedSyncUrl !== (remoteData.syncUrl || '')) remoteChanged = true;
810
+ if (mergedTaskCalUrl !== (localData.taskCalendarUrl || '')) localChanged = true;
811
+ if (mergedTaskCalUrl !== (remoteData.taskCalendarUrl || '')) remoteChanged = true;
812
+
813
+ // Prune tombstones older than the retention window so they don't grow forever.
814
+ // Pruning happens after merge resolution so stale tombstones still participate
815
+ // in the current merge cycle before being discarded.
816
+ const tombstoneCutoff = retentionDays > 0 ? new Date(Date.now() - retentionDays * 86400000) : null;
817
+ // The merged fence is the later of the two sides' pruned-before dates so
818
+ // downstream devices know the most aggressive point up to which tombstones
819
+ // may be missing.
820
+ const localFence = localData.tombstonePrunedBefore ? new Date(localData.tombstonePrunedBefore) : null;
821
+ const remoteFence = remoteData.tombstonePrunedBefore ? new Date(remoteData.tombstonePrunedBefore) : null;
822
+ const mergedFence = localFence && remoteFence
823
+ ? (localFence > remoteFence ? localFence : remoteFence)
824
+ : (localFence || remoteFence || tombstoneCutoff);
825
+ const mergedTombstonePrunedBefore = mergedFence ? mergedFence.toISOString() : null;
826
+
827
+ const prunedDeletedIds = pruneTombstones(allDeletedIds, tombstoneCutoff);
828
+ const prunedDeletedChipIds = pruneTombstones(allDeletedChipIds, tombstoneCutoff);
829
+ const prunedDeletedFrameIds = pruneTombstones(allDeletedFrameIds, tombstoneCutoff);
830
+ const prunedRemovedTodayIds = pruneTombstones(allRemovedTodayIds, tombstoneCutoff);
831
+ const prunedDeletedHabitIds = pruneTombstones(habitsMerge.mergedDeletedIds, tombstoneCutoff);
832
+ const prunedDeletedGoalIds = pruneTombstones(allDeletedGoalIds, tombstoneCutoff);
833
+ const prunedDeletedProjectIds = pruneTombstones(allDeletedProjectIds, tombstoneCutoff);
834
+
835
+ return {
836
+ data: {
837
+ tasks: finalTasks,
838
+ unscheduledTasks: finalUnsched,
839
+ unscheduledOrderTimestamp: remoteOrderTs > localOrderTs
840
+ ? remoteData.unscheduledOrderTimestamp
841
+ : localData.unscheduledOrderTimestamp,
842
+ recycleBin: reconciledBin,
843
+ recurringTasks: recurMerge.merged,
844
+ completedTaskUids: mergedCompletedUids,
845
+ deletedTaskIds: prunedDeletedIds,
846
+ deletedRoutineChipIds: prunedDeletedChipIds,
847
+ deletedFrameIds: prunedDeletedFrameIds,
848
+ removedTodayRoutineIds: prunedRemovedTodayIds,
849
+ // Calendar URLs: prefer non-empty value (don't let a device without URLs configured wipe one that has them).
850
+ // If both are non-empty and differ, prefer remote so a URL change propagates across devices.
851
+ syncUrl: (remoteData.syncUrl || localData.syncUrl || ''),
852
+ taskCalendarUrl: (remoteData.taskCalendarUrl || localData.taskCalendarUrl || ''),
853
+ routineDefinitions: routineMerge.merged,
854
+ todayRoutines: todayRoutinesMerge.merged,
855
+ routinesDate: mergedRoutinesDate,
856
+ routineCompletions: mergedCompletions,
857
+ dailyNotes: dailyNotesMerge.merged,
858
+ habits: habitsMerge.merged,
859
+ deletedHabitIds: prunedDeletedHabitIds,
860
+ habitLogs: habitLogsMerge.merged,
861
+ habitLogTimestamps: habitLogsMerge.mergedTimestamps,
862
+ habitsEnabled: mergedHabitsEnabled,
863
+ habitsEnabledUpdatedAt: mergedHabitsEnabledUpdatedAt,
864
+ routinesEnabled: mergedRoutinesEnabled,
865
+ routinesEnabledUpdatedAt: mergedRoutinesEnabledUpdatedAt,
866
+ gtdFrames: mergedFrames,
867
+ goals: mergedGoals,
868
+ deletedGoalIds: prunedDeletedGoalIds,
869
+ projects: mergedProjects,
870
+ deletedProjectIds: prunedDeletedProjectIds,
871
+ goalsProjectsEnabled: mergedGoalsProjectsEnabled,
872
+ goalsProjectsEnabledUpdatedAt: mergedGoalsProjectsEnabledUpdatedAt,
873
+ // obsidianConfig: last-writer-wins via per-field timestamp.
874
+ // On Android/iOS, applyRemoteData only applies the non-native fields.
875
+ obsidianConfig: pickConfigByTs(localData.obsidianConfig, localData.obsidianConfigUpdatedAt, remoteData.obsidianConfig, remoteData.obsidianConfigUpdatedAt, null),
876
+ obsidianConfigUpdatedAt: newerTs(localData.obsidianConfigUpdatedAt, remoteData.obsidianConfigUpdatedAt),
877
+ minimizedSections: localData.minimizedSections, // UI pref — keep local
878
+ use24HourClock: localData.use24HourClock, // device pref — keep local
879
+ tombstonePrunedBefore: mergedTombstonePrunedBefore,
880
+ },
881
+ localChanged,
882
+ remoteChanged
883
+ };
884
+ };
@@ -0,0 +1 @@
1
+ // Placeholder — WebDAV providers are extracted in Step 4.
@@ -0,0 +1,5 @@
1
+ // Type declarations for @glance-apps/sync
2
+ // Stub — filled in during Step 6 (see docs/sync-package/SYNC_PACKAGE_SPEC.md
3
+ // and EXTRACTION_PLAN.md in the dayGLANCE repo).
4
+
5
+ export {};