diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 912c1ec34..a84a9982c 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 } diff --git a/internal/whatsapp/importer.go b/internal/whatsapp/importer.go index f37a28389..8cbf28897 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 ad4d9d51f..023158b76 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 91e58852e..e1315ae11 100644 --- a/internal/whatsapp/mapping_test.go +++ b/internal/whatsapp/mapping_test.go @@ -284,3 +284,20 @@ func TestChatTitle(t *testing.T) { } assertpkg.Equal(t, "+447700900000", chatTitle(direct), "chatTitle(direct)") } + +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. + msg := waMessage{Timestamp: 1704888000000} + got := mapMessage(msg, 1, 1, sql.NullInt64{}) + + requirepkg.True(t, got.SentAt.Valid, "SentAt should be set") + assert.Equal("UTC", got.SentAt.Time.Location().String(), "SentAt must be normalized to UTC") + y, m, d := got.SentAt.Time.Date() + assert.Equal(2024, y, "year") + assert.Equal(1, int(m), "month") + assert.Equal(10, d, "day") +}