Skip to content

Commit d269ceb

Browse files
committed
fix(sessions): split resumed session usage by day
1 parent 2836fdc commit d269ceb

6 files changed

Lines changed: 258 additions & 16 deletions

File tree

src-tauri/src/db.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use crate::types::{DailyUsageRow, ModelUsage, ProjectUsage, SessionDetailRow};
1+
use crate::types::{
2+
DailyUsageRow, ModelUsage, ProjectUsage, SessionDailyUsageRow, SessionDetailRow,
3+
};
24
use rusqlite::{params, Connection};
35
use std::{collections::BTreeMap, path::Path};
46

@@ -447,6 +449,7 @@ pub fn query_session_details(db: &Connection) -> Result<Vec<SessionDetailRow>, S
447449
let mut cost_usd = 0.0;
448450
let mut models = std::collections::BTreeSet::new();
449451
let mut projects = std::collections::BTreeSet::new();
452+
let mut daily_usage = Vec::with_capacity(daily_rows.len());
450453

451454
for r in daily_rows {
452455
input_tokens += r.input_tokens;
@@ -461,6 +464,17 @@ pub fn query_session_details(db: &Connection) -> Result<Vec<SessionDetailRow>, S
461464
for project in r.projects.keys() {
462465
projects.insert(project.clone());
463466
}
467+
daily_usage.push(SessionDailyUsageRow {
468+
date: r.date,
469+
input_tokens: r.input_tokens,
470+
cached_input_tokens: r.cached_input_tokens,
471+
output_tokens: r.output_tokens,
472+
reasoning_output_tokens: r.reasoning_output_tokens,
473+
total_tokens: r.total_tokens,
474+
cost_usd: r.cost_usd,
475+
models: r.models.into_keys().collect(),
476+
projects: r.projects.into_keys().collect(),
477+
});
464478
}
465479

466480
let session_id = Path::new(&path)
@@ -483,6 +497,7 @@ pub fn query_session_details(db: &Connection) -> Result<Vec<SessionDetailRow>, S
483497
cost_usd,
484498
models: models.into_iter().collect(),
485499
projects: projects.into_iter().collect(),
500+
daily_usage,
486501
})
487502
})
488503
.map_err(|error| error.to_string())?;
@@ -595,6 +610,66 @@ mod tests {
595610
let _ = std::fs::remove_file(path);
596611
}
597612

613+
#[test]
614+
fn session_details_preserve_daily_usage_for_resumed_sessions() {
615+
let path = std::env::temp_dir().join(format!(
616+
"codex-usage-db-session-daily-{}.sqlite",
617+
Utc::now().timestamp_nanos_opt().unwrap_or_default()
618+
));
619+
let mut db = open_database(&path).unwrap();
620+
let model_usage = ModelUsage {
621+
input_tokens: 100,
622+
output_tokens: 40,
623+
total_tokens: 140,
624+
..Default::default()
625+
};
626+
let project_usage = ProjectUsage {
627+
input_tokens: 100,
628+
output_tokens: 40,
629+
total_tokens: 140,
630+
models: BTreeMap::from([("gpt-5".to_string(), model_usage.clone())]),
631+
..Default::default()
632+
};
633+
let daily_row = |date: &str, multiplier: i64| DailyUsageRow {
634+
date: date.to_string(),
635+
input_tokens: 100 * multiplier,
636+
cached_input_tokens: 20 * multiplier,
637+
output_tokens: 40 * multiplier,
638+
reasoning_output_tokens: 10 * multiplier,
639+
total_tokens: 150 * multiplier,
640+
cost_usd: 0.01 * multiplier as f64,
641+
models: BTreeMap::from([("gpt-5".to_string(), model_usage.clone())]),
642+
projects: BTreeMap::from([("/repo/app".to_string(), project_usage.clone())]),
643+
updated_at: "2026-07-02T00:00:00.000Z".to_string(),
644+
};
645+
upsert_session_file_rollups(
646+
&mut db,
647+
&[SessionFileRollup {
648+
path: "/tmp/resumed.jsonl".to_string(),
649+
modified_at_ms: 2,
650+
size_bytes: 200,
651+
rows: vec![daily_row("2026-07-01", 1), daily_row("2026-07-02", 2)],
652+
prompt_title: None,
653+
}],
654+
"2026-07-02T00:00:00.000Z",
655+
)
656+
.unwrap();
657+
658+
let sessions = query_session_details(&db).unwrap();
659+
660+
assert_eq!(sessions.len(), 1);
661+
assert_eq!(sessions[0].total_tokens, 450);
662+
assert!((sessions[0].cost_usd - 0.03).abs() < f64::EPSILON);
663+
assert_eq!(sessions[0].daily_usage.len(), 2);
664+
assert_eq!(sessions[0].daily_usage[0].date, "2026-07-01");
665+
assert_eq!(sessions[0].daily_usage[0].total_tokens, 150);
666+
assert_eq!(sessions[0].daily_usage[1].date, "2026-07-02");
667+
assert_eq!(sessions[0].daily_usage[1].total_tokens, 300);
668+
assert_eq!(sessions[0].daily_usage[1].models, ["gpt-5"]);
669+
assert_eq!(sessions[0].daily_usage[1].projects, ["/repo/app"]);
670+
let _ = std::fs::remove_file(path);
671+
}
672+
598673
#[test]
599674
fn reset_usage_state_clears_cached_tables() {
600675
let path = std::env::temp_dir().join(format!(

src-tauri/src/types.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,21 @@ pub struct UpdateDownloadProgress {
237237
pub finished: bool,
238238
}
239239

240+
#[derive(Debug, Clone, Serialize, Deserialize)]
241+
#[serde(rename_all = "camelCase")]
242+
pub struct SessionDailyUsageRow {
243+
pub date: String,
244+
pub input_tokens: i64,
245+
pub cached_input_tokens: i64,
246+
pub output_tokens: i64,
247+
pub reasoning_output_tokens: i64,
248+
pub total_tokens: i64,
249+
#[serde(rename = "costUSD")]
250+
pub cost_usd: f64,
251+
pub models: Vec<String>,
252+
pub projects: Vec<String>,
253+
}
254+
240255
#[derive(Debug, Clone, Serialize, Deserialize)]
241256
#[serde(rename_all = "camelCase")]
242257
pub struct SessionDetailRow {
@@ -254,6 +269,7 @@ pub struct SessionDetailRow {
254269
pub cost_usd: f64,
255270
pub models: Vec<String>,
256271
pub projects: Vec<String>,
272+
pub daily_usage: Vec<SessionDailyUsageRow>,
257273
}
258274

259275
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]

src/App.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,19 @@ describe("App", () => {
864864
costUSD: 0.001,
865865
models: ["gpt-5"],
866866
projects: ["/repo/app"],
867+
dailyUsage: [
868+
{
869+
date: "2026-06-11",
870+
inputTokens: 100,
871+
cachedInputTokens: 20,
872+
outputTokens: 40,
873+
reasoningOutputTokens: 0,
874+
totalTokens: 140,
875+
costUSD: 0.001,
876+
models: ["gpt-5"],
877+
projects: ["/repo/app"],
878+
},
879+
],
867880
},
868881
];
869882
}

src/components/session-titles.test.tsx

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @vitest-environment jsdom
22

3-
import { render, screen, waitFor } from "@testing-library/react";
3+
import { render, screen, waitFor, within } from "@testing-library/react";
44
import userEvent from "@testing-library/user-event";
55
import { describe, expect, it, vi } from "vitest";
66
import { ProjectSessionsModal } from "./project-sessions-modal";
@@ -29,10 +29,115 @@ function session(overrides: Partial<SessionDetailRow>): SessionDetailRow {
2929
costUSD: 0.001,
3030
models: ["gpt-5"],
3131
projects: ["/repo/app"],
32+
dailyUsage: [
33+
{
34+
date: "2026-07-15",
35+
inputTokens: 100,
36+
cachedInputTokens: 20,
37+
outputTokens: 40,
38+
reasoningOutputTokens: 0,
39+
totalTokens: 140,
40+
costUSD: 0.001,
41+
models: ["gpt-5"],
42+
projects: ["/repo/app"],
43+
},
44+
],
3245
...overrides,
3346
};
3447
}
3548

49+
describe("session daily usage", () => {
50+
it("splits resumed usage by rollup date and opens the complete session", async () => {
51+
const onSessionClick = vi.fn();
52+
const resumed = session({
53+
inputTokens: 300,
54+
cachedInputTokens: 60,
55+
outputTokens: 120,
56+
totalTokens: 420,
57+
costUSD: 0.003,
58+
models: ["gpt-5", "gpt-5-mini"],
59+
projects: ["/repo/first", "/repo/second"],
60+
dailyUsage: [
61+
{
62+
date: "2026-07-01",
63+
inputTokens: 100,
64+
cachedInputTokens: 20,
65+
outputTokens: 40,
66+
reasoningOutputTokens: 0,
67+
totalTokens: 140,
68+
costUSD: 0.001,
69+
models: ["gpt-5"],
70+
projects: ["/repo/first"],
71+
},
72+
{
73+
date: "2026-07-02",
74+
inputTokens: 200,
75+
cachedInputTokens: 40,
76+
outputTokens: 80,
77+
reasoningOutputTokens: 0,
78+
totalTokens: 280,
79+
costUSD: 0.002,
80+
models: ["gpt-5-mini"],
81+
projects: ["/repo/second"],
82+
},
83+
],
84+
});
85+
86+
const { rerender } = render(
87+
<SessionUsageTable sessions={[resumed]} onSessionClick={onSessionClick} />,
88+
);
89+
90+
const firstDay = document.getElementById("date-group-2026-07-01");
91+
const secondDay = document.getElementById("date-group-2026-07-02");
92+
expect(firstDay).not.toBeNull();
93+
expect(secondDay).not.toBeNull();
94+
expect(firstDay).toHaveTextContent("140");
95+
expect(firstDay).toHaveTextContent("gpt-5");
96+
expect(firstDay).not.toHaveTextContent("420");
97+
expect(secondDay).toHaveTextContent("280");
98+
expect(secondDay).toHaveTextContent("gpt-5-mini");
99+
expect(secondDay).not.toHaveTextContent("420");
100+
101+
const sessionRow = within(secondDay!).getByText("fallback-session").closest("tr");
102+
expect(sessionRow).not.toBeNull();
103+
await userEvent.click(sessionRow!);
104+
expect(onSessionClick).toHaveBeenCalledWith(resumed);
105+
106+
rerender(
107+
<SessionUsageTable
108+
sessions={[resumed]}
109+
selectedProject="/repo/first"
110+
onSessionClick={onSessionClick}
111+
/>,
112+
);
113+
expect(document.getElementById("date-group-2026-07-01")).not.toBeNull();
114+
expect(document.getElementById("date-group-2026-07-02")).toBeNull();
115+
});
116+
117+
it("keeps sessions without usage on their modified date", () => {
118+
render(<SessionUsageTable sessions={[session({
119+
inputTokens: 0,
120+
outputTokens: 0,
121+
totalTokens: 0,
122+
dailyUsage: [{
123+
date: "2026-07-01",
124+
inputTokens: 0,
125+
cachedInputTokens: 0,
126+
outputTokens: 0,
127+
reasoningOutputTokens: 0,
128+
totalTokens: 0,
129+
costUSD: 0,
130+
models: [],
131+
projects: [],
132+
}],
133+
})]} />);
134+
135+
expect(document.getElementById("date-group-2026-07-15")).not.toBeNull();
136+
expect(document.getElementById("date-group-2026-07-01")).toBeNull();
137+
expect(screen.getAllByText("No activity").length).toBeGreaterThan(0);
138+
});
139+
});
140+
36141
describe("session titles", () => {
37142
it("shows the summary name with the session ID and falls back to the ID", () => {
38143
render(

src/components/session-usage-table.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { Button } from "@/components/ui/button";
77
import dayjs from "dayjs";
88
import { useTranslation } from "react-i18next";
99

10+
type SessionDisplayRow = SessionDetailRow & {
11+
usageDate: string;
12+
originalSession: SessionDetailRow;
13+
};
14+
1015
type SessionUsageTableProps = {
1116
sessions: SessionDetailRow[];
1217
initialExpandedDate?: string | null;
@@ -63,16 +68,31 @@ export function SessionUsageTable({
6368
}
6469
}, [initialExpandedDate]);
6570

66-
// Group and sort sessions
71+
const displaySessions = useMemo<SessionDisplayRow[]>(() => sessions.flatMap((session) => {
72+
if (session.totalTokens === 0 || session.dailyUsage.length === 0) {
73+
return [{
74+
...session,
75+
usageDate: dayjs(session.modifiedAtMs).format("YYYY-MM-DD"),
76+
originalSession: session,
77+
}];
78+
}
79+
80+
return session.dailyUsage.map((usage) => ({
81+
...session,
82+
...usage,
83+
usageDate: usage.date,
84+
originalSession: session,
85+
}));
86+
}), [sessions]);
87+
88+
// Group and sort session-day rows using the scanner's application-timezone dates.
6789
const groups = useMemo(() => {
68-
const map: Record<string, SessionDetailRow[]> = {};
69-
for (const session of sessions) {
70-
// If a project filter is active, skip sessions that don't belong to the project
71-
if (selectedProject && (!session.projects || !session.projects.includes(selectedProject))) {
90+
const map: Record<string, SessionDisplayRow[]> = {};
91+
for (const session of displaySessions) {
92+
if (selectedProject && !session.projects.includes(selectedProject)) {
7293
continue;
7394
}
74-
// Extract date part (local time based on timestamp) using dayjs with YYYY-MM-DD format
75-
const dateStr = dayjs(session.modifiedAtMs).format("YYYY-MM-DD");
95+
const dateStr = session.usageDate;
7696
if (!map[dateStr]) {
7797
map[dateStr] = [];
7898
}
@@ -105,12 +125,12 @@ export function SessionUsageTable({
105125
projects,
106126
};
107127
});
108-
}, [sessions, selectedProject]);
128+
}, [displaySessions, selectedProject]);
109129

110130
const filteredCount = useMemo(() => {
111-
if (!selectedProject) return sessions.length;
112-
return sessions.filter((s) => s.projects && s.projects.includes(selectedProject)).length;
113-
}, [sessions, selectedProject]);
131+
if (!selectedProject) return displaySessions.length;
132+
return displaySessions.filter((session) => session.projects.includes(selectedProject)).length;
133+
}, [displaySessions, selectedProject]);
114134

115135
const maxGroupTokens = useMemo(() => Math.max(...groups.map(g => g.totalTokens), 1), [groups]);
116136
const maxGroupCost = useMemo(() => Math.max(...groups.map(g => g.costUSD), 0), [groups]);
@@ -157,7 +177,7 @@ export function SessionUsageTable({
157177
<h2 className="text-xl font-bold tracking-tight text-foreground flex items-center gap-2.5">
158178
{t("sessions.title")}
159179
<span className="inline-flex items-center rounded-full bg-indigo-500/10 px-2.5 py-0.5 text-xs font-semibold text-indigo-400 border border-indigo-500/20">
160-
{selectedProject ? t("sessions.showing_info_filtered", { filtered: filteredCount, total: sessions.length }) : t("sessions.showing_info", { count: sessions.length })}
180+
{selectedProject ? t("sessions.showing_info_filtered", { filtered: filteredCount, total: displaySessions.length }) : t("sessions.showing_info", { count: displaySessions.length })}
161181
</span>
162182
</h2>
163183
<p className="text-sm text-muted-foreground">
@@ -331,12 +351,12 @@ export function SessionUsageTable({
331351
key={session.path}
332352
tabIndex={onSessionClick ? 0 : undefined}
333353
role={onSessionClick ? "button" : undefined}
334-
onClick={() => onSessionClick?.(session)}
354+
onClick={() => onSessionClick?.(session.originalSession)}
335355
onKeyDown={(event) => {
336356
if (!onSessionClick) return;
337357
if (event.key === "Enter" || event.key === " ") {
338358
event.preventDefault();
339-
onSessionClick(session);
359+
onSessionClick(session.originalSession);
340360
}
341361
}}
342362
className="align-top hover:bg-white/[0.01] transition-colors duration-150 cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary/70"

0 commit comments

Comments
 (0)