Skip to content

Commit 0f81092

Browse files
committed
v2.0.2: fix Things packed-date / Unix-epoch decoding
startDate/deadline are bit-packed calendar dates (not Unix seconds) and stopDate/creationDate are Unix seconds (not Cocoa). The old code had both backwards, so today counted recurrence templates via todayIndex>0, due/ overdue/upcoming were silently empty, and completedToday counted all-time completions. Add decode/encode helpers, redefine Today by scheduled date, rebuild the fixture with real encodings, and add a regression test.
1 parent ca38170 commit 0f81092

14 files changed

Lines changed: 214 additions & 132 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Changelog
2+
3+
## 2.0.2
4+
5+
Fix date decoding across every list and stat. The previous releases misread the
6+
Things SQLite schema, which made several commands return wrong or empty results
7+
on real databases (tests passed only because the fixture encoded dates the same
8+
wrong way).
9+
10+
- **`startDate` / `deadline` are bit-packed calendar dates**, not Unix seconds
11+
(`2026-06-16``132802560`). Added `decodeThingsDate` / `encodeThingsDate` and
12+
repointed every read. This fixes scheduled-date display in `show`, `export`,
13+
`review`, and the ``/`📅` markers.
14+
- **`today` membership is now scheduled-date based.** Previously a task was
15+
considered "in Today" when `todayIndex > 0`, but that flag marks recurrence
16+
*templates* Things hides — real Today rows carry a negative `todayIndex`. Today
17+
now lists Anytime to-dos scheduled for today or earlier (overdue-scheduled roll
18+
in), matching the app.
19+
- **`due` / `overdue` / `upcoming` were silently empty** (they gated deadlines on
20+
`> 1000000000`, which packed dates never satisfy). Now corrected.
21+
- **`someday`** no longer swallows dated tasks; dated items surface under
22+
`upcoming` as Things intends.
23+
- **`stopDate` / `creationDate` / `userModificationDate` are Unix seconds**, not
24+
Cocoa. `stats.completedToday` previously counted *all* completed tasks ever;
25+
`logbook` and `review` rendered completion dates ~56 years in the future. Both
26+
fixed.
27+
- Rebuilt the test fixture with the real Things encodings and added a
28+
recurrence-template regression test so this class of bug can't reappear.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "thingsctl",
3-
"version": "2.0.1",
3+
"version": "2.0.2",
44
"description": "A full-featured command-line interface and MCP server for Things 3 on macOS.",
55
"main": "src/cli.js",
66
"type": "commonjs",

src/commands/export.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const db = require('../lib/db');
44
const queries = require('../lib/queries');
55
const csv = require('../lib/csv');
66
const { taskToJson } = require('../lib/format');
7-
const { formatShortDate } = require('../lib/dates');
7+
const { formatThingsShortDate, decodeThingsDate } = require('../lib/dates');
88
const { STATUS } = require('../lib/constants');
99

1010
function _gather(database, source, opts) {
@@ -44,7 +44,7 @@ function _markdown(database, tasks, title) {
4444
let line = `- ${check} ${t.title}`;
4545
const tags = t.tagList ? t.tagList.split(',').filter(Boolean) : [];
4646
if (tags.length) line += ` #${tags.join(' #')}`;
47-
const deadline = formatShortDate(t.deadline);
47+
const deadline = formatThingsShortDate(t.deadline);
4848
if (deadline) line += ` 📅 ${deadline}`;
4949
lines.push(line);
5050
if (t.notes) {
@@ -64,8 +64,8 @@ function _csv(tasks) {
6464
const status = t.status === STATUS.COMPLETED ? 'completed'
6565
: t.status === STATUS.CANCELED ? 'canceled' : 'open';
6666
const tags = (t.tagList || '').split(',').filter(Boolean).join(';');
67-
const deadline = t.deadline >= 1000000000
68-
? new Date(t.deadline * 1000).toISOString().split('T')[0] : '';
67+
const dl = decodeThingsDate(t.deadline);
68+
const deadline = dl ? dl.toISOString().split('T')[0] : '';
6969
rows.push([t.uuid, t.title, status, tags, t.projectName || '', t.areaName || '', deadline, t.notes || '']);
7070
}
7171
return csv.format(rows);

src/commands/logbook.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
const db = require('../lib/db');
44
const queries = require('../lib/queries');
55
const { colors, taskToJson } = require('../lib/format');
6-
const { cocoaToDate, daysAgoUnix, unixToCocoa } = require('../lib/dates');
6+
const { unixToDate, daysAgoUnix } = require('../lib/dates');
77

88
function _line(t) {
99
let line = `✓ ${t.title}`;
10-
const d = cocoaToDate(t.stopDate);
10+
const d = unixToDate(t.stopDate);
1111
if (d) {
1212
const s = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
1313
line += ` ${colors.dim('(' + s + ')')}`;
@@ -16,17 +16,17 @@ function _line(t) {
1616
}
1717

1818
function run(opts = {}) {
19-
let sinceCocoa;
19+
let sinceUnix;
2020
if (opts.since != null) {
2121
const days = parseInt(opts.since, 10);
22-
if (!Number.isNaN(days)) sinceCocoa = unixToCocoa(daysAgoUnix(days));
22+
if (!Number.isNaN(days)) sinceUnix = daysAgoUnix(days);
2323
}
2424
const limit = opts.limit ? parseInt(opts.limit, 10) : 20;
25-
const tasks = queries.logbookTasks(db.open(), { limit, sinceCocoa });
25+
const tasks = queries.logbookTasks(db.open(), { limit, sinceUnix });
2626
if (opts.json) {
2727
return tasks.map((t) => ({
2828
...taskToJson(t),
29-
completedAt: cocoaToDate(t.stopDate)?.toISOString() ?? null,
29+
completedAt: unixToDate(t.stopDate)?.toISOString() ?? null,
3030
}));
3131
}
3232
if (opts.ids) return tasks.map((t) => t.uuid);

src/commands/review.js

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
'use strict';
22

33
const db = require('../lib/db');
4-
const { todayBounds, daysAgoUnix, unixToCocoa, formatShortDate, cocoaToDate } = require('../lib/dates');
4+
const { daysAgoUnix, encodeThingsDate, formatThingsShortDate, unixToDate } = require('../lib/dates');
55
const { colors } = require('../lib/format');
66

77
function _gather(database, days) {
88
const now = new Date();
9-
const bounds = todayBounds(now);
9+
// Timestamps (stopDate, creationDate) are Unix seconds; deadlines are packed
10+
// calendar dates, so the two windows use different encodings.
1011
const sinceUnix = daysAgoUnix(days, now);
11-
const sinceCocoa = unixToCocoa(sinceUnix);
12-
const untilUnix = bounds.start + days * 86400; // for deadline window: today .. today+N
12+
const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
13+
const deadlineFrom = encodeThingsDate(todayMidnight);
14+
const until = new Date(todayMidnight);
15+
until.setDate(until.getDate() + days);
16+
const deadlineTo = encodeThingsDate(until); // deadline window: today .. today+N
1317

1418
const completed = database.prepare(`
1519
SELECT t.uuid, t.title, t.stopDate, t.area, t.project,
@@ -20,7 +24,7 @@ function _gather(database, days) {
2024
FROM TMTask t
2125
WHERE t.status = 3 AND t.trashed = 0 AND t.type = 0 AND t.stopDate >= ?
2226
ORDER BY t.stopDate DESC
23-
`).all(sinceCocoa);
27+
`).all(sinceUnix);
2428

2529
const added = database.prepare(`
2630
SELECT t.uuid, t.title, t.creationDate, t.area, t.project,
@@ -29,7 +33,7 @@ function _gather(database, days) {
2933
FROM TMTask t
3034
WHERE t.trashed = 0 AND t.type = 0 AND t.creationDate >= ?
3135
ORDER BY t.creationDate DESC
32-
`).all(sinceCocoa);
36+
`).all(sinceUnix);
3337

3438
const deadlines = database.prepare(`
3539
SELECT t.uuid, t.title, t.deadline,
@@ -39,7 +43,7 @@ function _gather(database, days) {
3943
WHERE t.status = 0 AND t.trashed = 0 AND t.type = 0
4044
AND t.deadline >= ? AND t.deadline < ?
4145
ORDER BY t.deadline ASC
42-
`).all(bounds.start, untilUnix);
46+
`).all(deadlineFrom, deadlineTo);
4347

4448
return { days, completed, added, deadlines };
4549
}
@@ -60,9 +64,9 @@ function run(opts = {}) {
6064
if (opts.json) {
6165
return {
6266
windowDays: days,
63-
completed: data.completed.map((c) => ({ uuid: c.uuid, title: c.title, completedAt: cocoaToDate(c.stopDate)?.toISOString(), area: c.areaName, project: c.projectName })),
64-
added: data.added.map((a) => ({ uuid: a.uuid, title: a.title, createdAt: cocoaToDate(a.creationDate)?.toISOString(), area: a.areaName, project: a.projectName })),
65-
deadlines: data.deadlines.map((d) => ({ uuid: d.uuid, title: d.title, deadline: formatShortDate(d.deadline), area: d.areaName, project: d.projectName })),
67+
completed: data.completed.map((c) => ({ uuid: c.uuid, title: c.title, completedAt: unixToDate(c.stopDate)?.toISOString(), area: c.areaName, project: c.projectName })),
68+
added: data.added.map((a) => ({ uuid: a.uuid, title: a.title, createdAt: unixToDate(a.creationDate)?.toISOString(), area: a.areaName, project: a.projectName })),
69+
deadlines: data.deadlines.map((d) => ({ uuid: d.uuid, title: d.title, deadline: formatThingsShortDate(d.deadline), area: d.areaName, project: d.projectName })),
6670
};
6771
}
6872

@@ -73,7 +77,7 @@ function run(opts = {}) {
7377
for (const [area, items] of _groupBy(data.completed, 'areaName')) {
7478
lines.push(` ${colors.magenta('[' + area + ']')} ${items.length}`);
7579
for (const t of items.slice(0, 50)) {
76-
const d = cocoaToDate(t.stopDate);
80+
const d = unixToDate(t.stopDate);
7781
const day = d ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
7882
lines.push(` ✓ ${t.title} ${colors.dim('(' + day + ')')}`);
7983
}
@@ -86,7 +90,7 @@ function run(opts = {}) {
8690
lines.push('');
8791
lines.push(`${colors.bold('Deadlines next ' + days + ' day' + (days === 1 ? '' : 's'))} (${data.deadlines.length})`);
8892
for (const t of data.deadlines) {
89-
const d = formatShortDate(t.deadline);
93+
const d = formatThingsShortDate(t.deadline);
9094
lines.push(` 📅 ${d} ${t.title}${t.areaName ? ` ${colors.magenta('[' + t.areaName + ']')}` : ''}`);
9195
}
9296
return lines;

src/commands/show.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const db = require('../lib/db');
44
const queries = require('../lib/queries');
55
const { resolveTaskId } = require('../lib/uuid');
66
const { colors, taskToJson } = require('../lib/format');
7-
const { formatShortDate } = require('../lib/dates');
7+
const { formatThingsShortDate } = require('../lib/dates');
88
const { STATUS, START } = require('../lib/constants');
99

1010
function _details(database, task) {
@@ -24,9 +24,9 @@ function _details(database, task) {
2424
if (task.areaName) lines.push(`Area: ${task.areaName}`);
2525
const tags = task.tagList ? task.tagList.split(',').filter(Boolean) : [];
2626
if (tags.length) lines.push(`Tags: ${tags.join(', ')}`);
27-
const scheduled = formatShortDate(task.startDate);
27+
const scheduled = formatThingsShortDate(task.startDate);
2828
if (scheduled) lines.push(`Scheduled: ${scheduled}`);
29-
const deadline = formatShortDate(task.deadline);
29+
const deadline = formatThingsShortDate(task.deadline);
3030
if (deadline) lines.push(`Deadline: ${deadline}`);
3131
if (task.notes) {
3232
lines.push('');

src/commands/stats.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
'use strict';
22

33
const db = require('../lib/db');
4-
const { todayBounds, unixToCocoa } = require('../lib/dates');
4+
const { todayBounds, encodeThingsDate, packedTomorrow } = require('../lib/dates');
55
const { colors } = require('../lib/format');
66

77
function _gather(database) {
88
const bounds = todayBounds();
9-
const cocoaTodayStart = unixToCocoa(bounds.start);
9+
const today = encodeThingsDate(); // packed calendar date for startDate/deadline
10+
const tomorrow = packedTomorrow(); // exclusive upper bound for "today or earlier"
1011
const q = (sql, ...p) => database.prepare(sql).get(...p).count;
1112

1213
return {
1314
today: q(`
1415
SELECT COUNT(*) as count FROM TMTask
1516
WHERE status = 0 AND trashed = 0 AND type = 0
16-
AND (todayIndex > 0 OR (startDate >= 1000000000 AND startDate < ?))
17-
`, bounds.end),
17+
AND start = 1 AND startDate IS NOT NULL AND startDate > 0 AND startDate < ?
18+
`, tomorrow),
1819
inbox: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=0 AND trashed=0 AND type=0 AND start=0`),
1920
anytime: q(`
2021
SELECT COUNT(*) as count FROM TMTask
@@ -23,13 +24,13 @@ function _gather(database) {
2324
someday: q(`
2425
SELECT COUNT(*) as count FROM TMTask
2526
WHERE status=0 AND trashed=0 AND type=0 AND start=2 AND todayIndex<=0
26-
AND (startDate IS NULL OR startDate < 1000000000)
27+
AND startDate IS NULL
2728
`),
28-
upcoming: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=0 AND trashed=0 AND type=0 AND startDate >= ?`, bounds.end),
29+
upcoming: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=0 AND trashed=0 AND type=0 AND startDate IS NOT NULL AND startDate >= ? AND todayIndex<=0`, tomorrow),
2930
evening: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=0 AND trashed=0 AND type=0 AND startBucket=1 AND todayIndex<=0`),
30-
overdue: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=0 AND trashed=0 AND type=0 AND deadline > 1000000000 AND deadline < ?`, bounds.start),
31+
overdue: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=0 AND trashed=0 AND type=0 AND deadline IS NOT NULL AND deadline > 0 AND deadline < ?`, today),
3132
totalOpen: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=0 AND trashed=0 AND type=0`),
32-
completedToday: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=3 AND trashed=0 AND type=0 AND stopDate >= ?`, cocoaTodayStart),
33+
completedToday: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=3 AND trashed=0 AND type=0 AND stopDate >= ?`, bounds.start),
3334
projects: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=0 AND trashed=0 AND type=1`),
3435
};
3536
}

src/commands/watch.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict';
22

33
const db = require('../lib/db');
4-
const { cocoaToDate } = require('../lib/dates');
4+
const { unixToDate } = require('../lib/dates');
55

66
// Polls TMTask.userModificationDate; emits NDJSON for each event detected.
77
function run(opts = {}) {
@@ -30,7 +30,7 @@ function run(opts = {}) {
3030
for (const r of rows) {
3131
if (r.userModificationDate > lastSeen) lastSeen = r.userModificationDate;
3232
if (r.type !== 0) continue;
33-
const at = cocoaToDate(r.userModificationDate)?.toISOString();
33+
const at = unixToDate(r.userModificationDate)?.toISOString();
3434
if (wantAdditions && r.creationDate >= startedAt) {
3535
_emit({ event: 'added', uuid: r.uuid, title: r.title, at });
3636
}

src/lib/dates.js

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
'use strict';
22

33
// Cocoa epoch is 2001-01-01T00:00:00Z; Unix epoch is 1970-01-01T00:00:00Z.
4-
// Things stores some timestamps as Cocoa seconds (stopDate, creationDate,
5-
// userModificationDate) and others as Unix seconds (startDate, deadline).
4+
// The COCOA_EPOCH_OFFSET helpers remain for any Cocoa-encoded column, but note:
5+
// in the current Things schema the *timestamp* columns (stopDate, creationDate,
6+
// userModificationDate) are plain Unix seconds, and the *calendar-date* columns
7+
// (startDate, deadline) are NOT timestamps at all — they're bit-packed dates.
8+
// See decodeThingsDate/encodeThingsDate below.
69
const COCOA_EPOCH_OFFSET = 978307200;
710

811
function cocoaToUnix(seconds) {
@@ -53,6 +56,43 @@ function formatIsoDate(unixSeconds) {
5356
return date.toISOString();
5457
}
5558

59+
// Things stores calendar dates (startDate, deadline) as a bit-packed integer,
60+
// NOT a timestamp: (year << 16) | (month << 12) | (day << 7), low 7 bits
61+
// reserved. e.g. 2026-06-16 -> 132802560. Because the packing is year-major it
62+
// is monotonic in real date order, so packed ints compare correctly (<, <=, >=).
63+
function decodeThingsDate(packed) {
64+
if (packed == null || packed <= 0) return null;
65+
const day = (packed >> 7) & 0x1f;
66+
const month = (packed >> 12) & 0x0f;
67+
const year = packed >> 16;
68+
if (!year || !month || !day) return null;
69+
return new Date(year, month - 1, day);
70+
}
71+
72+
function encodeThingsDate(date = new Date()) {
73+
const y = date.getFullYear();
74+
const m = date.getMonth() + 1;
75+
const d = date.getDate();
76+
return (y << 16) | (m << 12) | (d << 7);
77+
}
78+
79+
// Midnight tonight, packed — the exclusive upper bound for "today or earlier".
80+
function packedTomorrow(now = new Date()) {
81+
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
82+
return encodeThingsDate(d);
83+
}
84+
85+
function formatThingsShortDate(packed) {
86+
const date = decodeThingsDate(packed);
87+
if (!date) return null;
88+
return `${MONTHS[date.getMonth()]} ${date.getDate()}`;
89+
}
90+
91+
function thingsDateToIso(packed) {
92+
const date = decodeThingsDate(packed);
93+
return date ? date.toISOString() : null;
94+
}
95+
5696
module.exports = {
5797
COCOA_EPOCH_OFFSET,
5898
cocoaToUnix,
@@ -63,4 +103,9 @@ module.exports = {
63103
daysAgoUnix,
64104
formatShortDate,
65105
formatIsoDate,
106+
decodeThingsDate,
107+
encodeThingsDate,
108+
packedTomorrow,
109+
formatThingsShortDate,
110+
thingsDateToIso,
66111
};

src/lib/format.js

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict';
22

33
const { STATUS, START } = require('./constants');
4-
const { formatShortDate } = require('./dates');
4+
const { formatThingsShortDate, thingsDateToIso, encodeThingsDate, packedTomorrow } = require('./dates');
55

66
// TTY-aware coloring. Disabled if NO_COLOR is set or stdout is not a TTY.
77
const _colorEnabled = process.stdout.isTTY && !process.env.NO_COLOR;
@@ -51,17 +51,14 @@ function formatTask(task, opts = {}) {
5151
else if (task.areaName) line += ` ${colors.dim('[' + task.areaName + ']')}`;
5252
}
5353

54-
const deadline = formatShortDate(task.deadline);
54+
const deadline = formatThingsShortDate(task.deadline);
5555
if (deadline) line += ` ${colors.yellow('📅 ' + deadline)}`;
5656

57-
if (showScheduled) {
58-
const scheduled = formatShortDate(task.startDate);
59-
if (scheduled && task.startDate >= 1000000000) {
60-
// Only show the arrow when the scheduled date is in the future.
61-
const todayEnd = new Date(new Date().setHours(24, 0, 0, 0)).getTime() / 1000;
62-
if (task.startDate >= todayEnd) {
63-
line += ` ${colors.blue('→ ' + scheduled)}`;
64-
}
57+
if (showScheduled && task.startDate) {
58+
const scheduled = formatThingsShortDate(task.startDate);
59+
// Only show the arrow when the scheduled date is in the future.
60+
if (scheduled && task.startDate > encodeThingsDate()) {
61+
line += ` ${colors.blue('→ ' + scheduled)}`;
6562
}
6663
}
6764

@@ -70,7 +67,6 @@ function formatTask(task, opts = {}) {
7067

7168
// Render a task as a plain JSON-ready object.
7269
function taskToJson(task) {
73-
const { unixToDate } = require('./dates');
7470
return {
7571
uuid: task.uuid,
7672
title: task.title,
@@ -81,12 +77,12 @@ function taskToJson(task) {
8177
: task.status === STATUS.CANCELED
8278
? 'canceled'
8379
: 'open',
84-
startDate: task.startDate >= 1000000000 ? unixToDate(task.startDate).toISOString() : null,
85-
deadline: task.deadline >= 1000000000 ? unixToDate(task.deadline).toISOString() : null,
80+
startDate: thingsDateToIso(task.startDate),
81+
deadline: thingsDateToIso(task.deadline),
8682
tags: task.tagList ? task.tagList.split(',').filter(Boolean).sort() : (task._tags || []),
8783
project: task.projectName || null,
8884
area: task.areaName || null,
89-
inToday: task.todayIndex > 0,
85+
inToday: task.start === START.ANYTIME && task.startDate != null && task.startDate > 0 && task.startDate < packedTomorrow(),
9086
list: listName(task.start),
9187
evening: task.startBucket === 1,
9288
};

0 commit comments

Comments
 (0)