From 347d3c7a550b82ea56de322a7491e6023ccfce0f Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sun, 9 Aug 2026 01:29:40 +0100 Subject: [PATCH 1/2] fix(db): decode creationDate/stopDate as Unix epoch, not Core Data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Things stores its absolute-timestamp REAL columns (creationDate, stopDate) as seconds since the Unix epoch, but they were decoded against Apple's Core Data reference date (2001-01-01), shifting every value exactly +31 years into the future (e.g. a task created 2026-08-09 reported creationDate 2057-08-09). Anything sorting or filtering on those fields via JSON output would misbehave. Replace CoreDataToTime/TimeToCoreData with UnixToTime/TimeToUnix — an audit of call sites shows no column in the Things schema actually uses the 2001 epoch (startDate/deadline use the separate ThingsDate bit-encoding), so the old codec is deleted rather than kept alongside. The logbook manualLogDate comparison is column-vs-column in the same epoch and is unaffected. Adds a regression test pinning a real TMTask creationDate value to its correct 2026 decode. --- CLAUDE.md | 2 +- internal/db/checklist.go | 2 +- internal/db/checklist_test.go | 2 +- internal/db/tasks.go | 4 ++-- internal/db/tasks_test.go | 8 ++++---- internal/model/model.go | 19 +++++++++++-------- internal/model/model_test.go | 35 +++++++++++++++++++++++++---------- 7 files changed, 45 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3a1145f..1c22a23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ make fmt # gofmt -w . && goimports -w . ## Architecture -- `internal/model/` — shared types (Task, Project, Area, Tag, ChecklistItem) and date codecs (ThingsDate bit-encoding, Core Data timestamps) +- `internal/model/` — shared types (Task, Project, Area, Tag, ChecklistItem) and date codecs (ThingsDate bit-encoding, Unix-epoch absolute timestamps) - `internal/db/` — SQLite queries via `modernc.org/sqlite` (pure Go, no cgo). Opens DB read-only with `PRAGMA query_only = ON`. `NewFromSQL` exists purely to let test helpers wrap an externally-built `*sql.DB`. - `internal/db/dbtest/` — test-only helper: `dbtest.NewSQL(t)` returns an in-memory SQLite with the pared-down Things3 schema (embedded via `//go:embed schema.sql`). Use from any package's tests. - `internal/things/` — write operations: URL scheme (`things:///add`) for task creation, AppleScript for complete/cancel diff --git a/internal/db/checklist.go b/internal/db/checklist.go index 313bd02..c90971e 100644 --- a/internal/db/checklist.go +++ b/internal/db/checklist.go @@ -28,7 +28,7 @@ func (d *DB) GetChecklistItems(taskUUID string) ([]model.ChecklistItem, error) { return nil, fmt.Errorf("scanning checklist item: %w", err) } if stopDate.Valid { - ts := model.CoreDataToTime(stopDate.Float64) + ts := model.UnixToTime(stopDate.Float64) item.StopDate = &ts } items = append(items, item) diff --git a/internal/db/checklist_test.go b/internal/db/checklist_test.go index fe82317..1225705 100644 --- a/internal/db/checklist_test.go +++ b/internal/db/checklist_test.go @@ -9,7 +9,7 @@ import ( func TestGetChecklistItemsOrderedAndDated(t *testing.T) { d := newTestDB(t) - stopTS := model.TimeToCoreData(mustTime("2026-04-10T10:00:00Z")) + stopTS := model.TimeToUnix(mustTime("2026-04-10T10:00:00Z")) mustExec(t, d, `INSERT INTO TMChecklistItem (uuid, title, status, stopDate, "index", task) VALUES ('c1', 'step B', 0, NULL, 2, 'task1'), ('c2', 'step A', 3, ?, 1, 'task1'), diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 2e090a2..6ffb503 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -103,11 +103,11 @@ func scanTask(row interface{ Scan(...any) error }) (model.Task, error) { t.Deadline = &d } if stopDate.Valid { - ts := model.CoreDataToTime(stopDate.Float64) + ts := model.UnixToTime(stopDate.Float64) t.StopDate = &ts } if creationDate.Valid { - ts := model.CoreDataToTime(creationDate.Float64) + ts := model.UnixToTime(creationDate.Float64) t.CreationDate = &ts } if tagsStr != "" { diff --git a/internal/db/tasks_test.go b/internal/db/tasks_test.go index cbf66a3..27acb4d 100644 --- a/internal/db/tasks_test.go +++ b/internal/db/tasks_test.go @@ -62,7 +62,7 @@ func seedTasks(t *testing.T, d *DB) { ('t-today', 'tg-home')`) // stopDate on the done task so logbook has something to order by - done := model.TimeToCoreData(time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)) + done := model.TimeToUnix(time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)) mustExec(t, d, `UPDATE TMTask SET stopDate = ? WHERE uuid = 't-done'`, done) } @@ -136,8 +136,8 @@ func TestListTasksTodayCompletedItemFiltering(t *testing.T) { // subtraction would underflow the day field to 0 on the 1st. today := int64(model.ThingsDateFromTime(time.Now())) yesterday := int64(model.ThingsDateFromTime(time.Now().AddDate(0, 0, -1))) - stopToday := model.TimeToCoreData(time.Now().Add(-1 * time.Minute)) - stopYesterday := model.TimeToCoreData(time.Now().Add(-25 * time.Hour)) + stopToday := model.TimeToUnix(time.Now().Add(-1 * time.Minute)) + stopYesterday := model.TimeToUnix(time.Now().Add(-25 * time.Hour)) // Completed today, not yet logged. mustExec(t, d, `INSERT INTO TMTask @@ -181,7 +181,7 @@ func TestListTasksTodayCompletedItemFiltering(t *testing.T) { } // Simulate "Log Completed Now": bump manualLogDate past both stopDates. - future := model.TimeToCoreData(time.Now().Add(1 * time.Minute)) + future := model.TimeToUnix(time.Now().Add(1 * time.Minute)) mustExec(t, d, `INSERT INTO TMSettings (uuid, manualLogDate) VALUES ('s', ?)`, future) got, err = d.ListTasks("today", TaskFilter{IncludeCompleted: true}) diff --git a/internal/model/model.go b/internal/model/model.go index 4b42237..7089676 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "time" ) @@ -125,16 +126,18 @@ func (d *ThingsDate) UnmarshalJSON(data []byte) error { return nil } -var coreDataEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - -// CoreDataToTime converts a Core Data timestamp (seconds since 2001-01-01) to time.Time. -func CoreDataToTime(ts float64) time.Time { - return coreDataEpoch.Add(time.Duration(ts * float64(time.Second))) +// UnixToTime converts a Things absolute timestamp (fractional seconds since +// the Unix epoch, as stored in creationDate/stopDate/userModificationDate) to +// time.Time. Despite the Core Data heritage of the schema, Things stores these +// REAL columns against 1970, not Apple's 2001 reference date. +func UnixToTime(ts float64) time.Time { + sec, frac := math.Modf(ts) + return time.Unix(int64(sec), int64(frac*float64(time.Second))).UTC() } -// TimeToCoreData converts a time.Time to Core Data timestamp. -func TimeToCoreData(t time.Time) float64 { - return t.Sub(coreDataEpoch).Seconds() +// TimeToUnix converts a time.Time to a Things absolute timestamp. +func TimeToUnix(t time.Time) float64 { + return float64(t.UnixNano()) / float64(time.Second) } type Task struct { diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 130d233..b8e4011 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -111,28 +111,43 @@ func TestThingsDateRoundTripJSON(t *testing.T) { } } -func TestCoreDataRoundTrip(t *testing.T) { +func TestUnixTimeRoundTrip(t *testing.T) { cases := []time.Time{ - time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC), // epoch + time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), // epoch time.Date(2026, 4, 14, 12, 34, 56, 0, time.UTC), - time.Date(2000, 6, 15, 8, 0, 0, 0, time.UTC), // pre-epoch + time.Date(1969, 6, 15, 8, 0, 0, 0, time.UTC), // pre-epoch } for _, in := range cases { - ts := TimeToCoreData(in) - got := CoreDataToTime(ts) + ts := TimeToUnix(in) + got := UnixToTime(ts) if !got.Equal(in) { t.Fatalf("roundtrip mismatch: in=%s got=%s (ts=%f)", in, got, ts) } } } -func TestCoreDataEpochZero(t *testing.T) { - epoch := time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - if ts := TimeToCoreData(epoch); ts != 0 { +func TestUnixTimeEpochZero(t *testing.T) { + epoch := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + if ts := TimeToUnix(epoch); ts != 0 { t.Fatalf("epoch should be 0, got %f", ts) } - if got := CoreDataToTime(0); !got.Equal(epoch) { - t.Fatalf("CoreDataToTime(0) = %s, want %s", got, epoch) + if got := UnixToTime(0); !got.Equal(epoch) { + t.Fatalf("UnixToTime(0) = %s, want %s", got, epoch) + } +} + +// Regression for the "+31 years" bug: Things stores creationDate/stopDate as +// Unix-epoch seconds, but they were being decoded against the Core Data 2001 +// reference date, shifting every absolute timestamp 31 years into the future. +// The raw value below came from a real TMTask row created 2026-08-09. +func TestUnixTimeNotCoreDataEpoch(t *testing.T) { + got := UnixToTime(1786235005.119778) + want := time.Date(2026, 8, 9, 0, 23, 25, 0, time.UTC) + if got.Year() != want.Year() { + t.Fatalf("UnixToTime decoded into year %d, want %d (Core Data epoch regression)", got.Year(), want.Year()) + } + if got.Sub(want).Abs() > time.Second { + t.Fatalf("UnixToTime(1786235005.119778) = %s, want ~%s", got, want) } } From 54e772c15944397301fa39d58599e151f13573a8 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sun, 9 Aug 2026 01:31:39 +0100 Subject: [PATCH 2/2] fix(model): round fractional nanoseconds in UnixToTime Truncation could land one nanosecond short of the stored value due to float64 representation (0.119778s -> 119777999ns); round instead. --- internal/model/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/model/model.go b/internal/model/model.go index 7089676..a05d89f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -132,7 +132,7 @@ func (d *ThingsDate) UnmarshalJSON(data []byte) error { // REAL columns against 1970, not Apple's 2001 reference date. func UnixToTime(ts float64) time.Time { sec, frac := math.Modf(ts) - return time.Unix(int64(sec), int64(frac*float64(time.Second))).UTC() + return time.Unix(int64(sec), int64(math.Round(frac*float64(time.Second)))).UTC() } // TimeToUnix converts a time.Time to a Things absolute timestamp.