From 7ac97e10a4167ca651db07c6de16fd1837181e5c Mon Sep 17 00:00:00 2001 From: Nat Torkington Date: Sat, 20 Jun 2026 17:08:21 +1200 Subject: [PATCH 1/3] fix(sync): normalize oldest-message date to UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processBatch tracked the oldest message date via time.UnixMilli without .UTC(), leaving it in the local zone — so code reading its calendar day (and TestProcessBatch_OldestDatePropagation) was off by one in zones east of UTC. Match the .UTC() normalization already used for the stored message date and parsed.Date. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UVTtwc4MNS8L4ztnJ87QMj --- internal/sync/sync.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 912c1ec3..a84a9982 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -216,7 +216,11 @@ func (s *Syncer) processBatch(ctx context.Context, sourceID int64, listResp *gma // Track oldest message date for progress display // Gmail returns messages newest-to-oldest, so oldest shows where we've reached if raw.InternalDate > 0 { - msgDate := time.UnixMilli(raw.InternalDate) + // .UTC() to match how InternalDate is normalized everywhere + // else (the stored message date and parsed.Date both use + // .UTC()); without it oldestDate carries the local zone and + // callers reading its calendar day are off by one east of UTC. + msgDate := time.UnixMilli(raw.InternalDate).UTC() if result.oldestDate.IsZero() || msgDate.Before(result.oldestDate) { result.oldestDate = msgDate } From fa5ba1a72e0312acb5279ca62b83ddeac6a9b2ab Mon Sep 17 00:00:00 2001 From: Nat Torkington Date: Sat, 20 Jun 2026 17:30:41 +1200 Subject: [PATCH 2/3] fix(whatsapp): normalize message and reaction dates to UTC Same class as the sync oldest-date bug: WhatsApp message SentAt (mapping.go) and reaction createdAt (importer.go) were built with time.Unix without .UTC(), leaving them in the local zone while every other importer stores UTC. Off-by-one calendar day and wrong Parquet year-partition near boundaries east of UTC. Adds TestMapMessageSentAtIsUTC. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UVTtwc4MNS8L4ztnJ87QMj --- internal/whatsapp/importer.go | 2 +- internal/whatsapp/mapping.go | 6 +++++- internal/whatsapp/mapping_test.go | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/internal/whatsapp/importer.go b/internal/whatsapp/importer.go index f37a2838..8cbf2889 100644 --- a/internal/whatsapp/importer.go +++ b/internal/whatsapp/importer.go @@ -468,7 +468,7 @@ func (imp *Importer) Import(ctx context.Context, waDBPath string, opts ImportOpt reactorID = selfParticipantID } - createdAt := time.Unix(r.Timestamp/1000, 0) + createdAt := time.Unix(r.Timestamp/1000, 0).UTC() if err := imp.store.UpsertReaction(messageID, reactorID, reactionTypeEmoji, reactionValue, createdAt); err != nil { summary.Errors++ imp.progress.OnError(fmt.Errorf("upsert reaction: %w", err)) diff --git a/internal/whatsapp/mapping.go b/internal/whatsapp/mapping.go index ad4d9d51..023158b7 100644 --- a/internal/whatsapp/mapping.go +++ b/internal/whatsapp/mapping.go @@ -42,8 +42,12 @@ func mapMessage(msg waMessage, conversationID, sourceID int64, senderID sql.Null sentAt := sql.NullTime{} if msg.Timestamp > 0 { // WhatsApp timestamps are in milliseconds since epoch. + // .UTC() to match how every other importer normalizes stored + // message dates; without it SentAt carries the local zone and + // Parquet year-partitioning / calendar-day reads are off by one + // near boundaries east of UTC. sentAt = sql.NullTime{ - Time: time.Unix(msg.Timestamp/1000, (msg.Timestamp%1000)*1e6), + Time: time.Unix(msg.Timestamp/1000, (msg.Timestamp%1000)*1e6).UTC(), Valid: true, } } diff --git a/internal/whatsapp/mapping_test.go b/internal/whatsapp/mapping_test.go index 91e58852..0e6635d4 100644 --- a/internal/whatsapp/mapping_test.go +++ b/internal/whatsapp/mapping_test.go @@ -284,3 +284,18 @@ func TestChatTitle(t *testing.T) { } assertpkg.Equal(t, "+447700900000", chatTitle(direct), "chatTitle(direct)") } + +func TestMapMessageSentAtIsUTC(t *testing.T) { + // 2024-01-10T12:00:00Z in milliseconds. On a machine east of UTC + // (e.g. NZ) a non-UTC SentAt would read back as Jan 11 and carry the + // local zone — wrong for Parquet year-partitioning and date display. + msg := waMessage{Timestamp: 1704888000000} + got := mapMessage(msg, 1, 1, sql.NullInt64{}) + + requirepkg.True(t, got.SentAt.Valid, "SentAt should be set") + assertpkg.Equal(t, "UTC", got.SentAt.Time.Location().String(), "SentAt must be normalized to UTC") + y, m, d := got.SentAt.Time.Date() + assertpkg.Equal(t, 2024, y, "year") + assertpkg.Equal(t, 1, int(m), "month") + assertpkg.Equal(t, 10, d, "day") +} From bb9a7c06fd253c14337df29e7db707da6878c6a5 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 21 Jun 2026 21:05:06 -0500 Subject: [PATCH 3/3] fix(whatsapp): satisfy UTC test assertion lint The WhatsApp UTC regression test used enough direct testify package calls to trip the repo's custom assertion-helper lint in CI, even though the behavior test itself passed. Use a local assert helper in that assertion-heavy test so the regression coverage stays in place while matching the enforced test style. Generated with Codex (GPT-5) Co-authored-by: Codex --- internal/whatsapp/mapping_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/whatsapp/mapping_test.go b/internal/whatsapp/mapping_test.go index 0e6635d4..e1315ae1 100644 --- a/internal/whatsapp/mapping_test.go +++ b/internal/whatsapp/mapping_test.go @@ -286,6 +286,8 @@ func TestChatTitle(t *testing.T) { } func TestMapMessageSentAtIsUTC(t *testing.T) { + assert := assertpkg.New(t) + // 2024-01-10T12:00:00Z in milliseconds. On a machine east of UTC // (e.g. NZ) a non-UTC SentAt would read back as Jan 11 and carry the // local zone — wrong for Parquet year-partitioning and date display. @@ -293,9 +295,9 @@ func TestMapMessageSentAtIsUTC(t *testing.T) { got := mapMessage(msg, 1, 1, sql.NullInt64{}) requirepkg.True(t, got.SentAt.Valid, "SentAt should be set") - assertpkg.Equal(t, "UTC", got.SentAt.Time.Location().String(), "SentAt must be normalized to UTC") + assert.Equal("UTC", got.SentAt.Time.Location().String(), "SentAt must be normalized to UTC") y, m, d := got.SentAt.Time.Date() - assertpkg.Equal(t, 2024, y, "year") - assertpkg.Equal(t, 1, int(m), "month") - assertpkg.Equal(t, 10, d, "day") + assert.Equal(2024, y, "year") + assert.Equal(1, int(m), "month") + assert.Equal(10, d, "day") }