Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/db/checklist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/db/checklist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
4 changes: 2 additions & 2 deletions internal/db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
8 changes: 4 additions & 4 deletions internal/db/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})
Expand Down
19 changes: 11 additions & 8 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"time"
)

Expand Down Expand Up @@ -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(math.Round(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 {
Expand Down
35 changes: 25 additions & 10 deletions internal/model/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading