Skip to content

Commit 2a1cce2

Browse files
fix(timezone): address dashboard review follow-ups
1 parent d240691 commit 2a1cce2

8 files changed

Lines changed: 227 additions & 35 deletions

File tree

internal/admin/dashboard/static/css/dashboard.css

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ body {
114114
flex-direction: column;
115115
position: sticky;
116116
top: 0;
117-
height: 100vh;
117+
max-height: 100vh;
118+
overflow-y: auto;
119+
-webkit-overflow-scrolling: touch;
118120
z-index: 10;
119121
transition: flex-basis 0.2s, width 0.2s;
120122
}

internal/admin/dashboard/static/js/modules/dashboard-layout.test.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,34 @@ function readFixture(relativePath) {
99

1010
function readCSSRule(source, selector) {
1111
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12-
const match = source.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\n\\}`, 'm'));
12+
const match = source.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\s*\\}`, 'm'));
1313
assert.ok(match, `Expected CSS rule for ${selector}`);
1414
return match[1];
1515
}
1616

17+
test('readCSSRule matches rules with CRLF endings and indented closing braces', () => {
18+
const css = '.content {\r\n width: 100%;\r\n max-width: 1200px;\r\n }\r\n';
19+
20+
const rule = readCSSRule(css, '.content');
21+
22+
assert.match(rule, /width:\s*100%/);
23+
assert.match(rule, /max-width:\s*1200px/);
24+
});
25+
1726
test('sidebar and main content share the flex layout without manual content offsets', () => {
1827
const template = readFixture('../../../templates/layout.html');
1928
const css = readFixture('../../css/dashboard.css');
2029

2130
assert.match(template, /<aside class="sidebar"[\s\S]*<div class="sidebar-toggle"[\s\S]*<main class="content"/);
22-
assert.match(template, /<main class="content" :class="\{ 'content-collapsed': sidebarCollapsed \}">/);
31+
assert.doesNotMatch(template, /content-collapsed/);
2332

2433
const sidebarRule = readCSSRule(css, '.sidebar');
2534
assert.match(sidebarRule, /flex:\s*0 0 var\(--sidebar-width\)/);
2635
assert.match(sidebarRule, /position:\s*sticky/);
27-
assert.match(sidebarRule, /height:\s*100vh/);
36+
assert.match(sidebarRule, /max-height:\s*100vh/);
37+
assert.match(sidebarRule, /overflow-y:\s*auto/);
2838
assert.doesNotMatch(sidebarRule, /position:\s*fixed/);
39+
assert.doesNotMatch(sidebarRule, /(^|\n)\s*height:\s*100vh/);
2940

3041
const toggleRule = readCSSRule(css, '.sidebar-toggle');
3142
assert.match(toggleRule, /flex:\s*0 0 6px/);
@@ -49,6 +60,14 @@ test('dashboard layout pins Chart.js to 4.5.0', () => {
4960

5061
assert.match(
5162
template,
52-
/<script src="https:\/\/cdn\.jsdelivr\.net\/npm\/chart\.js@4\.5\.0\/dist\/chart\.umd\.min\.js"><\/script>/
63+
/<script src="https:\/\/cdn\.jsdelivr\.net\/npm\/chart\.js@4\.5\.0\/dist\/chart\.umd\.min\.js" integrity="sha384-XcdcwHqIPULERb2yDEM4R0XaQKU3YnDsrTmjACBZyfdVVqjh6xQ4\/DCMd7XLcA6Y" crossorigin="anonymous"><\/script>/
64+
);
65+
assert.match(
66+
template,
67+
/<script defer src="https:\/\/cdn\.jsdelivr\.net\/npm\/alpinejs@3\.15\.8\/dist\/cdn\.min\.js" integrity="sha384-LXWjKwDZz29o7TduNe\+r\/UxaolHh5FsSvy2W7bDHSZ8jJeGgDeuNnsDNHoxpSgDi" crossorigin="anonymous"><\/script>/
68+
);
69+
assert.match(
70+
template,
71+
/<script src="https:\/\/unpkg\.com\/htmx\.org@2\.0\.8\/dist\/htmx\.min\.js" integrity="sha384-\/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u\/6OCvVKyz1W\+idaz" crossorigin="anonymous"><\/script>/
5372
);
5473
});

internal/admin/dashboard/static/js/modules/timezone-layout.test.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,29 @@ test('dashboard templates expose a settings page and timezone context in activit
1717
const template = readFixture('../../../templates/index.html');
1818

1919
assert.match(template, /<div x-show="page==='settings'">[\s\S]*<h2>User Settings<\/h2>/);
20-
assert.match(template, /x-ref="timezoneOverrideSelect"[\s\S]*x-model="timezoneOverride"[\s\S]*x-effect="timezoneOptions\.length; timezoneOverride; \$nextTick\(\(\) => syncTimezoneOverrideSelectValue\(\)\)"/);
21-
assert.match(template, /<option value=""[\s\S]*:selected="!timezoneOverride"/);
22-
assert.match(template, /<option :value="timeZone\.value"[\s\S]*:selected="timeZone\.value === timezoneOverride"/);
20+
assert.match(template, /x-ref="timezoneOverrideSelect"/);
21+
assert.match(template, /x-model="timezoneOverride"/);
22+
assert.match(template, /x-effect="timezoneOptions\.length; timezoneOverride; \$nextTick\(\(\) => syncTimezoneOverrideSelectValue\(\)\)"/);
23+
assert.match(template, /<option value=""/);
24+
assert.match(template, /:selected="!timezoneOverride"/);
25+
assert.match(template, /<option :value="timeZone\.value"/);
26+
assert.match(template, /:selected="timeZone\.value === timezoneOverride"/);
2327
assert.match(template, /<div class="settings-panel-header" x-data="\{ timezoneHelpOpen: false \}">/);
24-
assert.match(template, /<button type="button"[\s\S]*class="timezone-help-toggle"[\s\S]*@click="timezoneHelpOpen = !timezoneHelpOpen"/);
25-
assert.match(template, /<span class="timezone-help-toggle-icon" x-text="timezoneHelpOpen \? '' : '\?'"><\/span>/);
26-
assert.match(template, /<p id="timezone-help-copy"[\s\S]*class="settings-panel-copy settings-panel-copy-collapsible"[\s\S]*x-show="timezoneHelpOpen"[\s\S]*x-transition\.opacity\.duration\.200ms[\s\S]*Day-based analytics, charts, and date filters use your effective timezone\./);
28+
assert.match(template, /class="timezone-help-toggle"/);
29+
assert.match(template, /@click="timezoneHelpOpen = !timezoneHelpOpen"/);
30+
assert.match(template, /class="timezone-help-toggle-icon"/);
31+
assert.match(template, /x-text="timezoneHelpOpen \? '' : '\?'"/);
32+
assert.match(template, /id="timezone-help-copy"/);
33+
assert.match(template, /x-show="timezoneHelpOpen"/);
34+
assert.match(template, /x-transition\.opacity\.duration\.200ms/);
35+
assert.match(template, /Day-based analytics, charts, and date filters use your effective timezone\. Usage and audit logs keep UTC in the hover title while rendering row timestamps in your effective timezone\./);
2736
assert.doesNotMatch(template, /Detected: /);
2837
assert.doesNotMatch(template, /Effective: /);
2938
assert.doesNotMatch(template, /Mode: /);
3039
assert.match(template, /x-text="calendarTimeZoneText\(\)"/);
31-
assert.match(template, /usage-ts[^>]*x-text="formatTimestamp\(entry\.timestamp\)"[^>]*:title="timestampTitle\(entry\.timestamp\)"/);
32-
assert.match(template, /audit-entry-meta[\s\S]*x-text="formatTimestamp\(entry\.timestamp\)"[\s\S]*:title="timestampTitle\(entry\.timestamp\)"/);
40+
assert.match(template, /class="mono usage-ts"/);
41+
assert.match(template, /x-text="formatTimestamp\(entry\.timestamp\)"/);
42+
assert.match(template, /:title="timestampTitle\(entry\.timestamp\)"/);
43+
assert.match(template, /class="audit-entry-meta"/);
3344
assert.match(template, /<button(?=[^>]*class="audit-conversation-trigger")(?=[^>]*type="button")[^>]*>/);
3445
});

internal/admin/dashboard/templates/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ <h3>Timezone</h3>
232232
</button>
233233
</div>
234234
<p id="timezone-help-copy" class="settings-panel-copy settings-panel-copy-collapsible" x-show="timezoneHelpOpen" x-transition.opacity.duration.200ms>
235-
Day-based analytics, charts, and date filters use your effective timezone. Usage and audit logs keep UTC in the hover title while rendering row timestamps in your local browser timezone.
235+
Day-based analytics, charts, and date filters use your effective timezone. Usage and audit logs keep UTC in the hover title while rendering row timestamps in your effective timezone.
236236
</p>
237237
</div>
238238
</div>

internal/admin/dashboard/templates/layout.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
<link rel="preconnect" href="https://fonts.googleapis.com">
99
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1010
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
11-
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js"></script>
12-
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.8/dist/cdn.min.js"></script>
13-
<script src="https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js"></script>
11+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js" integrity="sha384-XcdcwHqIPULERb2yDEM4R0XaQKU3YnDsrTmjACBZyfdVVqjh6xQ4/DCMd7XLcA6Y" crossorigin="anonymous"></script>
12+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.8/dist/cdn.min.js" integrity="sha384-LXWjKwDZz29o7TduNe+r/UxaolHh5FsSvy2W7bDHSZ8jJeGgDeuNnsDNHoxpSgDi" crossorigin="anonymous"></script>
13+
<script src="https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
1414
<link rel="stylesheet" href="/admin/static/css/dashboard.css">
1515
</head>
1616
<body x-data="dashboard()">
@@ -86,7 +86,7 @@ <h1>GOModel</h1>
8686
@mousedown="toggleSidebar()"
8787
:class="{ collapsed: sidebarCollapsed }"
8888
:title="sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"></div>
89-
<main class="content" :class="{ 'content-collapsed': sidebarCollapsed }">
89+
<main class="content">
9090
{{template "index" .}}
9191
</main>
9292
</div>

internal/usage/reader_sqlite.go

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func (r *SQLiteReader) GetUsageLog(ctx context.Context, params UsageLogParams) (
107107
// Fetch page
108108
dataQuery := `SELECT id, request_id, provider_id, timestamp, model, provider, endpoint,
109109
input_tokens, output_tokens, total_tokens, COALESCE(input_cost, 0), COALESCE(output_cost, 0), COALESCE(total_cost, 0), raw_data, COALESCE(costs_calculation_caveat, '')
110-
FROM usage` + where + ` ORDER BY timestamp DESC LIMIT ? OFFSET ?`
110+
FROM usage` + where + ` ORDER BY ` + sqliteTimestampEpochExpr() + ` DESC, id DESC LIMIT ? OFFSET ?`
111111
dataArgs := append(append([]any(nil), args...), limit, offset)
112112

113113
rows, err := r.db.QueryContext(ctx, dataQuery, dataArgs...)
@@ -393,18 +393,3 @@ func sqliteOffsetMinutes(ts time.Time, location *time.Location) int {
393393
_, offsetSeconds := ts.In(location).Zone()
394394
return offsetSeconds / 60
395395
}
396-
397-
func parseUsageTimestamp(ts string) time.Time {
398-
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
399-
return t
400-
}
401-
if t, err := time.Parse("2006-01-02 15:04:05.999999999-07:00", ts); err == nil {
402-
return t
403-
}
404-
if t, err := time.Parse("2006-01-02T15:04:05Z", ts); err == nil {
405-
return t
406-
}
407-
408-
slog.Warn("failed to parse usage timestamp", "raw_timestamp", ts)
409-
return time.Time{}
410-
}

internal/usage/reader_sqlite_boundary_test.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,178 @@ func TestSQLiteReaderGroupingRange_UsesAbsoluteTimestampExtremaAcrossOffsets(t *
399399
t.Fatalf("expected range end %s, got %s", expectedEnd, end)
400400
}
401401
}
402+
403+
func TestSQLiteReaderGetUsageLog_OrdersMixedTimestampFormatsByAbsoluteTime(t *testing.T) {
404+
db, err := sql.Open("sqlite", ":memory:")
405+
if err != nil {
406+
t.Fatalf("failed to open sqlite database: %v", err)
407+
}
408+
defer db.Close()
409+
410+
if _, err := NewSQLiteStore(db, 0); err != nil {
411+
t.Fatalf("failed to create sqlite store: %v", err)
412+
}
413+
414+
ctx := context.Background()
415+
_, err = db.ExecContext(ctx, `
416+
INSERT INTO usage (
417+
id, request_id, provider_id, timestamp, model, provider, endpoint,
418+
input_tokens, output_tokens, total_tokens,
419+
input_cost, output_cost, total_cost, costs_calculation_caveat
420+
) VALUES
421+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
422+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
423+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
424+
"latest-negative-offset",
425+
"req-latest",
426+
"provider-latest",
427+
"2026-03-29 23:30:00-02:00",
428+
"gpt-5",
429+
"openai",
430+
"/v1/chat/completions",
431+
0,
432+
30,
433+
30,
434+
0.0,
435+
0.0,
436+
0.0,
437+
"",
438+
"middle-zulu",
439+
"req-middle",
440+
"provider-middle",
441+
"2026-03-29T23:00:00Z",
442+
"gpt-5",
443+
"openai",
444+
"/v1/chat/completions",
445+
0,
446+
20,
447+
20,
448+
0.0,
449+
0.0,
450+
0.0,
451+
"",
452+
"earliest-positive-offset",
453+
"req-earliest",
454+
"provider-earliest",
455+
"2026-03-29 00:30:00+02:00",
456+
"gpt-5",
457+
"openai",
458+
"/v1/chat/completions",
459+
0,
460+
10,
461+
10,
462+
0.0,
463+
0.0,
464+
0.0,
465+
"",
466+
)
467+
if err != nil {
468+
t.Fatalf("failed to seed mixed-format usage entries: %v", err)
469+
}
470+
471+
reader, err := NewSQLiteReader(db)
472+
if err != nil {
473+
t.Fatalf("failed to create sqlite reader: %v", err)
474+
}
475+
476+
log, err := reader.GetUsageLog(ctx, UsageLogParams{
477+
Limit: 2,
478+
Offset: 0,
479+
})
480+
if err != nil {
481+
t.Fatalf("GetUsageLog returned error: %v", err)
482+
}
483+
484+
if len(log.Entries) != 2 {
485+
t.Fatalf("expected 2 log entries, got %d", len(log.Entries))
486+
}
487+
if log.Entries[0].ID != "latest-negative-offset" {
488+
t.Fatalf("expected latest entry first, got %s", log.Entries[0].ID)
489+
}
490+
if log.Entries[1].ID != "middle-zulu" {
491+
t.Fatalf("expected middle entry second, got %s", log.Entries[1].ID)
492+
}
493+
}
494+
495+
func TestSQLiteStoreCleanup_KeepsNewerLegacyOffsetRows(t *testing.T) {
496+
db, err := sql.Open("sqlite", ":memory:")
497+
if err != nil {
498+
t.Fatalf("failed to open sqlite database: %v", err)
499+
}
500+
defer db.Close()
501+
502+
store, err := NewSQLiteStore(db, 1)
503+
if err != nil {
504+
t.Fatalf("failed to create sqlite store: %v", err)
505+
}
506+
defer store.Close()
507+
508+
cutoff := time.Now().AddDate(0, 0, -1).UTC().Truncate(time.Second)
509+
keepTimestamp := cutoff.Add(90 * time.Minute).In(time.FixedZone("minus2", -2*60*60)).Format("2006-01-02 15:04:05-07:00")
510+
deleteTimestamp := cutoff.Add(-90 * time.Minute).In(time.FixedZone("plus2", 2*60*60)).Format("2006-01-02 15:04:05-07:00")
511+
512+
_, err = db.Exec(`
513+
INSERT INTO usage (
514+
id, request_id, provider_id, timestamp, model, provider, endpoint,
515+
input_tokens, output_tokens, total_tokens,
516+
input_cost, output_cost, total_cost, costs_calculation_caveat
517+
) VALUES
518+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
519+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
520+
"keep-newer-legacy",
521+
"req-keep",
522+
"provider-keep",
523+
keepTimestamp,
524+
"gpt-5",
525+
"openai",
526+
"/v1/chat/completions",
527+
0,
528+
10,
529+
10,
530+
0.0,
531+
0.0,
532+
0.0,
533+
"",
534+
"delete-older-legacy",
535+
"req-delete",
536+
"provider-delete",
537+
deleteTimestamp,
538+
"gpt-5",
539+
"openai",
540+
"/v1/chat/completions",
541+
0,
542+
20,
543+
20,
544+
0.0,
545+
0.0,
546+
0.0,
547+
"",
548+
)
549+
if err != nil {
550+
t.Fatalf("failed to seed cleanup rows: %v", err)
551+
}
552+
553+
store.cleanup()
554+
555+
var remainingIDs []string
556+
rows, err := db.Query(`SELECT id FROM usage ORDER BY id`)
557+
if err != nil {
558+
t.Fatalf("failed to query remaining rows: %v", err)
559+
}
560+
defer rows.Close()
561+
562+
for rows.Next() {
563+
var id string
564+
if err := rows.Scan(&id); err != nil {
565+
t.Fatalf("failed to scan remaining id: %v", err)
566+
}
567+
remainingIDs = append(remainingIDs, id)
568+
}
569+
if err := rows.Err(); err != nil {
570+
t.Fatalf("failed to iterate remaining rows: %v", err)
571+
}
572+
573+
if len(remainingIDs) != 1 || remainingIDs[0] != "keep-newer-legacy" {
574+
t.Fatalf("expected only the newer legacy row to remain, got %v", remainingIDs)
575+
}
576+
}

internal/usage/store_sqlite.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ func (s *SQLiteStore) cleanup() {
185185

186186
cutoff := time.Now().AddDate(0, 0, -s.retentionDays).UTC().Format(time.RFC3339Nano)
187187

188-
result, err := s.db.Exec("DELETE FROM usage WHERE timestamp < ?", cutoff)
188+
result, err := s.db.Exec("DELETE FROM usage WHERE "+sqliteTimestampEpochExpr()+" < unixepoch(?)", cutoff)
189189
if err != nil {
190190
slog.Error("failed to cleanup old usage entries", "error", err)
191191
return

0 commit comments

Comments
 (0)