Skip to content

Commit b8cbd22

Browse files
committed
Fix timezone handling to be fully client-side aware
Frontend changes: - Replace date string format with full UTC timestamp boundaries - Calculate local day start/end in browser (e.g., Nov 29 PST = 08:00-08:00 UTC) - Send start/end timestamps instead of date strings to API - Backend now receives exact UTC time ranges for client's local day Backend changes: - Update GetHourlyStats to accept start/end timestamps instead of date - Update GetModelStats to accept start/end timestamps instead of date - Remove server-side date parsing and timezone interpretation - Backend is now completely timezone-agnostic This ensures "Today" shows correct data regardless of client timezone. No more date/timezone confusion between client and server.
1 parent d7fef0f commit b8cbd22

File tree

7 files changed

+114
-53
lines changed

7 files changed

+114
-53
lines changed

proxy/internal/handler/handlers.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -342,16 +342,18 @@ func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
342342
json.NewEncoder(w).Encode(stats)
343343
}
344344

345-
// GetHourlyStats returns hourly breakdown for a specific date
345+
// GetHourlyStats returns hourly breakdown for a specific date range
346346
func (h *Handler) GetHourlyStats(w http.ResponseWriter, r *http.Request) {
347-
// Get date parameter (YYYY-MM-DD format)
348-
date := r.URL.Query().Get("date")
349-
if date == "" {
350-
http.Error(w, "date parameter is required", http.StatusBadRequest)
347+
// Get start/end time range (UTC ISO 8601 format from browser)
348+
startTime := r.URL.Query().Get("start")
349+
endTime := r.URL.Query().Get("end")
350+
351+
if startTime == "" || endTime == "" {
352+
http.Error(w, "start and end parameters are required", http.StatusBadRequest)
351353
return
352354
}
353355

354-
stats, err := h.storageService.GetHourlyStats(date)
356+
stats, err := h.storageService.GetHourlyStats(startTime, endTime)
355357
if err != nil {
356358
log.Printf("Error getting hourly stats: %v", err)
357359
http.Error(w, "Failed to get hourly stats", http.StatusInternalServerError)
@@ -362,16 +364,18 @@ func (h *Handler) GetHourlyStats(w http.ResponseWriter, r *http.Request) {
362364
json.NewEncoder(w).Encode(stats)
363365
}
364366

365-
// GetModelStats returns model breakdown for a specific date
367+
// GetModelStats returns model breakdown for a specific date range
366368
func (h *Handler) GetModelStats(w http.ResponseWriter, r *http.Request) {
367-
// Get date parameter (YYYY-MM-DD format)
368-
date := r.URL.Query().Get("date")
369-
if date == "" {
370-
http.Error(w, "date parameter is required", http.StatusBadRequest)
369+
// Get start/end time range (UTC ISO 8601 format from browser)
370+
startTime := r.URL.Query().Get("start")
371+
endTime := r.URL.Query().Get("end")
372+
373+
if startTime == "" || endTime == "" {
374+
http.Error(w, "start and end parameters are required", http.StatusBadRequest)
371375
return
372376
}
373377

374-
stats, err := h.storageService.GetModelStats(date)
378+
stats, err := h.storageService.GetModelStats(startTime, endTime)
375379
if err != nil {
376380
log.Printf("Error getting model stats: %v", err)
377381
http.Error(w, "Failed to get model stats", http.StatusInternalServerError)

proxy/internal/service/storage.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ type StorageService interface {
1818
GetRequestsSummary(modelFilter string) ([]*model.RequestSummary, error)
1919
GetRequestsSummaryPaginated(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error)
2020
GetStats(startDate, endDate string) (*model.DashboardStats, error)
21-
GetHourlyStats(date string) (*model.HourlyStatsResponse, error)
22-
GetModelStats(date string) (*model.ModelStatsResponse, error)
21+
GetHourlyStats(startTime, endTime string) (*model.HourlyStatsResponse, error)
22+
GetModelStats(startTime, endTime string) (*model.ModelStatsResponse, error)
2323
}

proxy/internal/service/storage_sqlite.go

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,11 @@ func (s *sqliteStorageService) GetStats(startDate, endDate string) (*model.Dashb
606606

607607
tokens := int64(0)
608608
if usage != nil {
609-
tokens = int64(usage.InputTokens + usage.OutputTokens + usage.CacheReadInputTokens)
609+
tokens = int64(
610+
usage.InputTokens +
611+
usage.OutputTokens +
612+
usage.CacheReadInputTokens +
613+
usage.CacheCreationInputTokens)
610614
}
611615

612616
// Daily aggregation
@@ -651,25 +655,16 @@ func (s *sqliteStorageService) GetStats(startDate, endDate string) (*model.Dashb
651655
return stats, nil
652656
}
653657

654-
// GetHourlyStats returns hourly breakdown for a specific date
655-
func (s *sqliteStorageService) GetHourlyStats(date string) (*model.HourlyStatsResponse, error) {
656-
// Parse date to get start and end of day
657-
dateObj, err := time.Parse("2006-01-02", date)
658-
if err != nil {
659-
return nil, fmt.Errorf("invalid date format: %w", err)
660-
}
661-
662-
startOfDay := dateObj.Format("2006-01-02") + "T00:00:00"
663-
endOfDay := dateObj.Format("2006-01-02") + "T23:59:59"
664-
658+
// GetHourlyStats returns hourly breakdown for a specific time range
659+
func (s *sqliteStorageService) GetHourlyStats(startTime, endTime string) (*model.HourlyStatsResponse, error) {
665660
query := `
666661
SELECT timestamp, COALESCE(model, 'unknown') as model, response
667662
FROM requests
668663
WHERE datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)
669664
ORDER BY timestamp
670665
`
671666

672-
rows, err := s.db.Query(query, startOfDay, endOfDay)
667+
rows, err := s.db.Query(query, startTime, endTime)
673668
if err != nil {
674669
return nil, fmt.Errorf("failed to query hourly stats: %w", err)
675670
}
@@ -712,7 +707,11 @@ func (s *sqliteStorageService) GetHourlyStats(date string) (*model.HourlyStatsRe
712707

713708
tokens := int64(0)
714709
if usage != nil {
715-
tokens = int64(usage.InputTokens + usage.OutputTokens + usage.CacheReadInputTokens)
710+
tokens = int64(
711+
usage.InputTokens +
712+
usage.OutputTokens +
713+
usage.CacheReadInputTokens +
714+
usage.CacheCreationInputTokens)
716715
}
717716

718717
totalTokens += tokens
@@ -778,25 +777,16 @@ func (s *sqliteStorageService) GetHourlyStats(date string) (*model.HourlyStatsRe
778777
}, nil
779778
}
780779

781-
// GetModelStats returns model breakdown for a specific date
782-
func (s *sqliteStorageService) GetModelStats(date string) (*model.ModelStatsResponse, error) {
783-
// Parse date to get start and end of day
784-
dateObj, err := time.Parse("2006-01-02", date)
785-
if err != nil {
786-
return nil, fmt.Errorf("invalid date format: %w", err)
787-
}
788-
789-
startOfDay := dateObj.Format("2006-01-02") + "T00:00:00"
790-
endOfDay := dateObj.Format("2006-01-02") + "T23:59:59"
791-
780+
// GetModelStats returns model breakdown for a specific time range
781+
func (s *sqliteStorageService) GetModelStats(startTime, endTime string) (*model.ModelStatsResponse, error) {
792782
query := `
793783
SELECT timestamp, COALESCE(model, 'unknown') as model, response
794784
FROM requests
795785
WHERE datetime(timestamp) >= datetime(?) AND datetime(timestamp) <= datetime(?)
796786
ORDER BY timestamp
797787
`
798788

799-
rows, err := s.db.Query(query, startOfDay, endOfDay)
789+
rows, err := s.db.Query(query, startTime, endTime)
800790
if err != nil {
801791
return nil, fmt.Errorf("failed to query model stats: %w", err)
802792
}
@@ -829,7 +819,11 @@ func (s *sqliteStorageService) GetModelStats(date string) (*model.ModelStatsResp
829819

830820
tokens := int64(0)
831821
if usage != nil {
832-
tokens = int64(usage.InputTokens + usage.OutputTokens + usage.CacheReadInputTokens)
822+
tokens = int64(
823+
usage.InputTokens +
824+
usage.OutputTokens +
825+
usage.CacheReadInputTokens +
826+
usage.CacheCreationInputTokens)
833827
}
834828

835829
// Model aggregation

web/app/routes/_index.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -243,13 +243,28 @@ export default function Index() {
243243
return response.json();
244244
};
245245

246+
// Get UTC timestamps for start and end of local day
247+
const getLocalDayBoundaries = (date: Date) => {
248+
const startOfDay = new Date(date);
249+
startOfDay.setHours(0, 0, 0, 0);
250+
251+
const endOfDay = new Date(date);
252+
endOfDay.setHours(23, 59, 59, 999);
253+
254+
return {
255+
start: startOfDay.toISOString(),
256+
end: endOfDay.toISOString()
257+
};
258+
};
259+
246260
// Load hourly stats only (for date navigation within same week)
247261
const loadHourlyStats = async (date?: Date) => {
248262
const targetDate = date || selectedDate;
249-
const selectedDateStr = targetDate.toISOString().split('T')[0];
263+
const { start, end } = getLocalDayBoundaries(targetDate);
250264

251265
const hourlyUrl = new URL('/api/stats/hourly', window.location.origin);
252-
hourlyUrl.searchParams.append('date', selectedDateStr);
266+
hourlyUrl.searchParams.append('start', start);
267+
hourlyUrl.searchParams.append('end', end);
253268

254269
const response = await fetch(hourlyUrl.toString());
255270
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -260,10 +275,11 @@ export default function Index() {
260275
// Load model stats only
261276
const loadModelStats = async (date?: Date) => {
262277
const targetDate = date || selectedDate;
263-
const selectedDateStr = targetDate.toISOString().split('T')[0];
278+
const { start, end } = getLocalDayBoundaries(targetDate);
264279

265280
const modelUrl = new URL('/api/stats/models', window.location.origin);
266-
modelUrl.searchParams.append('date', selectedDateStr);
281+
modelUrl.searchParams.append('start', start);
282+
modelUrl.searchParams.append('end', end);
267283

268284
const response = await fetch(modelUrl.toString());
269285
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -938,7 +954,7 @@ export default function Index() {
938954
style={{
939955
transform: `translateY(${virtualItem.start}px)`,
940956
}}
941-
onClick={() => loadRequestDetails(summary.requestId)}
957+
onClick={() => showRequestDetails(summary.requestId)}
942958
>
943959
<div className="flex items-start justify-between">
944960
<div className="flex-1 min-w-0 mr-4">

web/app/routes/api.requests.summary.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import type { LoaderFunction } from "@remix-run/node";
22
import { json } from "@remix-run/node";
33

4+
const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
5+
46
export const loader: LoaderFunction = async ({ request }) => {
57
try {
68
const url = new URL(request.url);
7-
const modelFilter = url.searchParams.get("model");
89

9-
// Forward the request to the Go backend summary endpoint
10-
const backendUrl = new URL('http://localhost:3001/api/requests/summary');
11-
if (modelFilter) {
12-
backendUrl.searchParams.append('model', modelFilter);
13-
}
10+
// Forward all known filters (model, start/end, pagination) to the Go backend
11+
const backendUrl = new URL(`${PROXY_URL}/api/requests/summary`);
12+
url.searchParams.forEach((value, key) => {
13+
backendUrl.searchParams.append(key, value);
14+
});
1415

1516
const response = await fetch(backendUrl.toString());
1617

@@ -21,7 +22,7 @@ export const loader: LoaderFunction = async ({ request }) => {
2122
const data = await response.json();
2223
return json(data);
2324
} catch (error) {
24-
console.error('Failed to fetch request summaries:', error);
25+
console.error("Failed to fetch request summaries:", error);
2526

2627
// Return empty array if backend is not available
2728
return json({ requests: [], total: 0 });
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { json } from "@remix-run/node";
2+
import type { LoaderFunctionArgs } from "@remix-run/node";
3+
4+
const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
5+
6+
export async function loader({ request }: LoaderFunctionArgs) {
7+
const url = new URL(request.url);
8+
const date = url.searchParams.get("date");
9+
10+
if (!date) {
11+
throw new Response("date is required", { status: 400 });
12+
}
13+
14+
const params = new URLSearchParams({ date });
15+
const proxyUrl = `${PROXY_URL}/api/stats/hourly?${params.toString()}`;
16+
const response = await fetch(proxyUrl);
17+
18+
if (!response.ok) {
19+
throw new Error(`Failed to fetch hourly stats: ${response.statusText}`);
20+
}
21+
22+
return json(await response.json());
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { json } from "@remix-run/node";
2+
import type { LoaderFunctionArgs } from "@remix-run/node";
3+
4+
const PROXY_URL = process.env.PROXY_URL || "http://localhost:3001";
5+
6+
export async function loader({ request }: LoaderFunctionArgs) {
7+
const url = new URL(request.url);
8+
const date = url.searchParams.get("date");
9+
10+
if (!date) {
11+
throw new Response("date is required", { status: 400 });
12+
}
13+
14+
const params = new URLSearchParams({ date });
15+
const proxyUrl = `${PROXY_URL}/api/stats/models?${params.toString()}`;
16+
const response = await fetch(proxyUrl);
17+
18+
if (!response.ok) {
19+
throw new Error(`Failed to fetch model stats: ${response.statusText}`);
20+
}
21+
22+
return json(await response.json());
23+
}

0 commit comments

Comments
 (0)