Skip to content

Commit dc7d627

Browse files
authored
Fix browser-timezone session date filters (#1245)
Session list and sidebar calendar-date filters now accept the browser IANA timezone, default omitted values to UTC, and reject invalid zones. Local calendar dates are translated into DST-aware UTC boundaries while preserving latest-message activity fallback and equivalent SQLite, PostgreSQL, and DuckDB query behavior. The sidebar sends the browser timezone on initial and paginated requests, and the generated API client reflects the new parameter. Fixes #1232. Co-authored-by: Gabriel Mitelman Tkacz <gtkacz@users.noreply.github.com>
1 parent 64f4bf4 commit dc7d627

27 files changed

Lines changed: 597 additions & 82 deletions

frontend/src/lib/api/generated/services/SearchService.ts

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/lib/api/generated/services/SessionsService.ts

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/lib/stores/sessions.svelte.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ class SessionsStore {
381381
date: f.date || undefined,
382382
dateFrom: f.dateFrom || undefined,
383383
dateTo: f.dateTo || undefined,
384+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
384385
activeSince: f.recentlyActive
385386
? new Date(
386387
Date.now() - 24 * 60 * 60 * 1000,

frontend/src/lib/stores/sessions.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,13 @@ describe("SessionsStore", () => {
222222
let sessions: ReturnType<typeof createSessionsStore>;
223223

224224
beforeEach(() => {
225+
vi.restoreAllMocks();
225226
vi.clearAllMocks();
227+
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
228+
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
229+
...resolvedOptions,
230+
timeZone: "America/New_York",
231+
});
226232
vi.mocked(callGenerated).mockImplementation(
227233
(request: () => Promise<unknown>) => request(),
228234
);
@@ -1559,6 +1565,14 @@ describe("SessionsStore", () => {
15591565
});
15601566

15611567
describe("load serialization", () => {
1568+
it("should pass the browser timezone", async () => {
1569+
await sessions.load();
1570+
1571+
expectSidebarIndexCalledWith({
1572+
timezone: "America/New_York",
1573+
});
1574+
});
1575+
15621576
it("should omit min/max_messages when 0", async () => {
15631577
sessions.filters.minMessages = 0;
15641578
sessions.filters.maxMessages = 0;
@@ -1668,6 +1682,17 @@ describe("SessionsStore", () => {
16681682
});
16691683

16701684
describe("loadMore serialization", () => {
1685+
it("should pass the browser timezone in loadMore", async () => {
1686+
sessions.nextCursor = "cur-timezone";
1687+
1688+
mockSidebarPage();
1689+
await sessions.loadMore();
1690+
1691+
expectPaginatedSidebarIndexCalledWith({
1692+
timezone: "America/New_York",
1693+
});
1694+
});
1695+
16711696
it("should load the sidebar index once with consistent filters", async () => {
16721697
mockSidebarIndex([
16731698
makeSkinnyRow({ id: "s1" }),

internal/db/filter_test.go

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -870,23 +870,23 @@ func TestSessionDateFilterIncludesOverlappingSessions(t *testing.T) {
870870
d := testDB(t)
871871

872872
insertSession(t, d, "before", "proj", func(s *Session) {
873-
s.StartedAt = new("2024-06-15T08:00:00Z")
874-
s.EndedAt = new("2024-06-15T09:00:00Z")
873+
s.StartedAt = new("2024-06-16T02:00:00Z")
874+
s.EndedAt = new("2024-06-16T03:59:59Z")
875875
s.MessageCount = 2
876876
})
877877
insertSession(t, d, "spanning", "proj", func(s *Session) {
878-
s.StartedAt = new("2024-06-15T23:00:00Z")
878+
s.StartedAt = new("2024-06-16T03:00:00Z")
879879
s.EndedAt = new("2024-06-16T10:00:00Z")
880880
s.MessageCount = 2
881881
})
882882
insertSession(t, d, "open", "proj", func(s *Session) {
883-
s.StartedAt = new("2024-06-15T22:00:00Z")
883+
s.StartedAt = new("2024-06-16T02:00:00Z")
884884
s.MessageCount = 2
885885
})
886-
seedMessage(t, d, "open", 1, "user", "2024-06-16T11:00:00Z", "")
886+
seedMessage(t, d, "open", 1, "user", "2024-06-17T03:59:59Z", "")
887887
insertSession(t, d, "after", "proj", func(s *Session) {
888-
s.StartedAt = new("2024-06-17T08:00:00Z")
889-
s.EndedAt = new("2024-06-17T09:00:00Z")
888+
s.StartedAt = new("2024-06-17T04:00:00Z")
889+
s.EndedAt = new("2024-06-17T05:00:00Z")
890890
s.MessageCount = 2
891891
})
892892
insertSession(t, d, "child", "proj", func(s *Session) {
@@ -903,24 +903,33 @@ func TestSessionDateFilterIncludesOverlappingSessions(t *testing.T) {
903903
want []string
904904
}{
905905
{
906-
name: "ExactDate",
907-
filter: SessionFilter{Date: "2024-06-16"},
908-
want: []string{"spanning", "open"},
906+
name: "ExactDate",
907+
filter: SessionFilter{
908+
Date: "2024-06-16", Timezone: "America/New_York",
909+
},
910+
want: []string{"spanning", "open"},
909911
},
910912
{
911-
name: "DateRange",
912-
filter: SessionFilter{DateFrom: "2024-06-16", DateTo: "2024-06-16"},
913-
want: []string{"spanning", "open"},
913+
name: "DateRange",
914+
filter: SessionFilter{
915+
DateFrom: "2024-06-16", DateTo: "2024-06-16",
916+
Timezone: "America/New_York",
917+
},
918+
want: []string{"spanning", "open"},
914919
},
915920
{
916-
name: "DateFrom",
917-
filter: SessionFilter{DateFrom: "2024-06-16"},
918-
want: []string{"spanning", "open", "after"},
921+
name: "DateFrom",
922+
filter: SessionFilter{
923+
DateFrom: "2024-06-16", Timezone: "America/New_York",
924+
},
925+
want: []string{"spanning", "open", "after"},
919926
},
920927
{
921-
name: "DateTo",
922-
filter: SessionFilter{DateTo: "2024-06-15"},
923-
want: []string{"before", "spanning", "open"},
928+
name: "DateTo",
929+
filter: SessionFilter{
930+
DateTo: "2024-06-15", Timezone: "America/New_York",
931+
},
932+
want: []string{"before", "spanning", "open"},
924933
},
925934
}
926935

@@ -931,7 +940,7 @@ func TestSessionDateFilterIncludesOverlappingSessions(t *testing.T) {
931940
}
932941

933942
index, err := d.GetSidebarSessionIndex(context.Background(), SessionFilter{
934-
Date: "2024-06-16",
943+
Date: "2024-06-16", Timezone: "America/New_York",
935944
})
936945
require.NoError(t, err, "GetSidebarSessionIndex")
937946
requireSidebarIndexIDs(t, index.Sessions, []string{

internal/db/query_dialect.go

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ type QueryDialect struct {
3535
dateStartExpr func(func(string) string) string
3636
dateEndExpr func(func(string) string) string
3737
dateParam func(string) string
38-
activityExpr string
3938
activityParam func(string) string
4039
cursorActivityExpr string
4140
cursorParam func(string) string
@@ -71,18 +70,18 @@ func SQLiteQueryDialect() QueryDialect {
7170
trueLiteral: "1",
7271
falseLiteral: "0",
7372
dateStartExpr: func(q func(string) string) string {
74-
return "date(COALESCE(NULLIF(" + q("started_at") +
73+
return "julianday(COALESCE(NULLIF(" + q("started_at") +
7574
", ''), " + q("created_at") + "))"
7675
},
7776
dateEndExpr: func(q func(string) string) string {
78-
return "date(COALESCE(NULLIF(" + q("ended_at") +
79-
", ''), (SELECT MAX(m.timestamp) FROM messages m" +
77+
return "julianday(COALESCE(NULLIF(" + q("ended_at") +
78+
", ''), (SELECT m.timestamp FROM messages m" +
8079
" WHERE m.session_id = " + outerSessionID(q) +
81-
" AND m.timestamp != ''), NULLIF(" + q("started_at") +
80+
" AND m.timestamp != '' ORDER BY julianday(m.timestamp)" +
81+
" DESC, m.timestamp DESC LIMIT 1), NULLIF(" + q("started_at") +
8282
", ''), " + q("created_at") + "))"
8383
},
84-
dateParam: func(ph string) string { return ph },
85-
activityExpr: "COALESCE(NULLIF(ended_at, ''), NULLIF(started_at, ''), created_at)",
84+
dateParam: func(ph string) string { return "julianday(" + ph + ")" },
8685
activityParam: func(ph string) string { return ph },
8786
cursorActivityExpr: "COALESCE(NULLIF(ended_at, ''), NULLIF(started_at, ''), created_at)",
8887
cursorParam: func(ph string) string { return ph },
@@ -109,18 +108,17 @@ func PostgresQueryDialect() QueryDialect {
109108
trueLiteral: "TRUE",
110109
falseLiteral: "FALSE",
111110
dateStartExpr: func(q func(string) string) string {
112-
return "DATE(COALESCE(" + q("started_at") + ", " +
113-
q("created_at") + ") AT TIME ZONE 'UTC')"
111+
return "COALESCE(" + q("started_at") + ", " +
112+
q("created_at") + ")"
114113
},
115114
dateEndExpr: func(q func(string) string) string {
116-
return "DATE(COALESCE(" + q("ended_at") +
115+
return "COALESCE(" + q("ended_at") +
117116
", (SELECT MAX(m.timestamp) FROM messages m" +
118117
" WHERE m.session_id = " + outerSessionID(q) +
119118
" AND m.timestamp IS NOT NULL), " + q("started_at") +
120-
", " + q("created_at") + ") AT TIME ZONE 'UTC')"
119+
", " + q("created_at") + ")"
121120
},
122-
dateParam: func(ph string) string { return ph + "::date" },
123-
activityExpr: "COALESCE(ended_at, started_at, created_at)",
121+
dateParam: func(ph string) string { return ph + "::timestamptz" },
124122
activityParam: func(ph string) string {
125123
return ph + "::timestamptz"
126124
},
@@ -152,19 +150,18 @@ func DuckDBQueryDialect() QueryDialect {
152150
falseLiteral: "FALSE",
153151
dateStartExpr: func(q func(string) string) string {
154152
return "CAST(COALESCE(" + q("started_at") + ", " +
155-
q("created_at") + ") AS DATE)"
153+
q("created_at") + ") AS TIMESTAMP)"
156154
},
157155
dateEndExpr: func(q func(string) string) string {
158156
return "CAST(COALESCE(" + q("ended_at") +
159157
", (SELECT MAX(m.timestamp) FROM messages m" +
160158
" WHERE m.session_id = " + outerSessionID(q) +
161159
" AND m.timestamp IS NOT NULL), " + q("started_at") +
162-
", " + q("created_at") + ") AS DATE)"
160+
", " + q("created_at") + ") AS TIMESTAMP)"
163161
},
164162
dateParam: func(ph string) string {
165-
return "CAST(" + ph + " AS DATE)"
163+
return "CAST(" + ph + " AS TIMESTAMP)"
166164
},
167-
activityExpr: "COALESCE(ended_at, started_at, created_at)",
168165
activityParam: func(ph string) string { return "CAST(" + ph + " AS TIMESTAMP)" },
169166
cursorActivityExpr: "COALESCE(ended_at, started_at, created_at)",
170167
cursorParam: func(ph string) string {
@@ -210,6 +207,50 @@ func (d QueryDialect) Qualify(parts ...string) string {
210207

211208
var safeIdentifierRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
212209

210+
// NormalizeSessionTimezone validates an IANA timezone name and returns the
211+
// canonical UTC default used when callers omit it. "Local" is intentionally
212+
// rejected because it depends on the server environment rather than naming a
213+
// browser-selected zone.
214+
func NormalizeSessionTimezone(name string) (string, error) {
215+
name = strings.TrimSpace(name)
216+
if name == "" {
217+
return "UTC", nil
218+
}
219+
if name == "Local" {
220+
return "", fmt.Errorf("invalid timezone: %s", name)
221+
}
222+
loc, err := time.LoadLocation(name)
223+
if err != nil {
224+
return "", fmt.Errorf("invalid timezone: %s", name)
225+
}
226+
return loc.String(), nil
227+
}
228+
229+
func sessionDateBoundary(date, timezone string, nextDay bool) string {
230+
name, err := NormalizeSessionTimezone(timezone)
231+
if err != nil {
232+
// Public HTTP and service inputs validate before reaching the store.
233+
// Keep internal store callers deterministic if they violate that
234+
// contract rather than making SQL construction panic.
235+
name = "UTC"
236+
}
237+
loc, err := time.LoadLocation(name)
238+
if err != nil {
239+
loc = time.UTC
240+
}
241+
boundary, err := time.ParseInLocation(time.DateOnly, date, loc)
242+
if err != nil {
243+
// Dates are likewise validated at public boundaries. Returning the
244+
// original value preserves the historical behavior for invalid
245+
// internal filters.
246+
return date
247+
}
248+
if nextDay {
249+
boundary = boundary.AddDate(0, 0, 1)
250+
}
251+
return boundary.UTC().Format(time.RFC3339)
252+
}
253+
213254
// QueryBuilder allocates dialect placeholders and collects bind parameters.
214255
type QueryBuilder struct {
215256
dialect QueryDialect
@@ -554,21 +595,29 @@ func sessionFilterPredicates(
554595
}
555596
if f.Date != "" {
556597
preds = append(preds, "("+b.dialect.dateEndExpr(q)+" >= "+
557-
b.dialect.dateParam(b.Add(f.Date))+" AND "+
558-
b.dialect.dateStartExpr(q)+" <= "+
559-
b.dialect.dateParam(b.Add(f.Date))+")")
598+
b.dialect.dateParam(b.Add(sessionDateBoundary(
599+
f.Date, f.Timezone, false,
600+
)))+" AND "+
601+
b.dialect.dateStartExpr(q)+" < "+
602+
b.dialect.dateParam(b.Add(sessionDateBoundary(
603+
f.Date, f.Timezone, true,
604+
)))+")")
560605
}
561606
if f.DateFrom != "" {
562607
preds = append(preds, b.dialect.dateEndExpr(q)+" >= "+
563-
b.dialect.dateParam(b.Add(f.DateFrom)))
608+
b.dialect.dateParam(b.Add(sessionDateBoundary(
609+
f.DateFrom, f.Timezone, false,
610+
))))
564611
}
565612
if f.DateTo != "" {
566-
preds = append(preds, b.dialect.dateStartExpr(q)+" <= "+
567-
b.dialect.dateParam(b.Add(f.DateTo)))
613+
preds = append(preds, b.dialect.dateStartExpr(q)+" < "+
614+
b.dialect.dateParam(b.Add(sessionDateBoundary(
615+
f.DateTo, f.Timezone, true,
616+
))))
568617
}
569618
if f.ActiveSince != "" {
570-
preds = append(preds, b.dialect.activityExpr+" >= "+
571-
b.dialect.activityParam(b.Add(f.ActiveSince)))
619+
preds = append(preds, b.dialect.dateEndExpr(q)+" >= "+
620+
b.dialect.dateParam(b.Add(f.ActiveSince)))
572621
}
573622
if f.MinMessages > 0 {
574623
preds = append(preds,

0 commit comments

Comments
 (0)