Skip to content

Commit 6954d50

Browse files
committed
Add Activity screen with heatmaps & controls
Introduce a new Activity screen and related plumbing: add ActiveScreen::Activity, ActivityCommand and input mapping/handling to toggle metrics, refresh data, and increase/decrease visible projects. Extend AppState and persisted UI state with activity_project_limit and add DEFAULT/MIN/MAX bounds; persist/load this value. Implement UI rendering for the activity view (header, controls, usage cards, project heatmaps, help overlay) including drawing helpers, color levels, weekday/month labels and layout constants. In usage module add ProjectActivity model, collect per-project daily totals during snapshot computation, build sorted project activity lists, activity timeline constants (54 weeks), locale-aware first-weekday detection, and helper functions for epoch/day keys. Add unit tests for several activity helpers. Also bump crate version to 0.3.5.
1 parent b375f91 commit 6954d50

5 files changed

Lines changed: 1044 additions & 314 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "comon"
3-
version = "0.3.4"
3+
version = "0.3.5"
44
edition = "2021"
55
license = "Apache-2.0"
66

src/app/mod.rs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ enum AppEvent {
5454
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5555
pub(crate) enum ActiveScreen {
5656
Usage,
57+
Activity,
5758
Read,
5859
}
5960

@@ -63,12 +64,22 @@ enum InputOutcome {
6364
Quit,
6465
}
6566

67+
#[derive(Debug, Clone, Copy)]
68+
enum ActivityCommand {
69+
RefreshAll,
70+
ToggleMetric,
71+
IncreaseProjects,
72+
DecreaseProjects,
73+
ToggleHelp,
74+
}
75+
6676
#[derive(Debug)]
6777
pub(crate) struct AppState {
6878
pub(crate) active_screen: ActiveScreen,
6979
pub(crate) metric: UsageMetric,
7080
pub(crate) range: ChartRange,
7181
pub(crate) orientation: ChartOrientation,
82+
pub(crate) activity_project_limit: usize,
7283
pub(crate) show_help: bool,
7384
pub(crate) workspace_path: Option<std::path::PathBuf>,
7485
pub(crate) no_sessions_confirm_open: bool,
@@ -89,12 +100,16 @@ pub(crate) struct AppState {
89100
const STATE_STORE_SCHEMA_VERSION: u32 = 1;
90101
const STATE_STORE_FILE_NAME: &str = "state.json";
91102
const STATE_SAVE_DEBOUNCE: Duration = Duration::from_millis(400);
103+
pub(crate) const DEFAULT_ACTIVITY_PROJECT_LIMIT: usize = 5;
104+
pub(crate) const MIN_ACTIVITY_PROJECT_LIMIT: usize = 1;
105+
pub(crate) const MAX_ACTIVITY_PROJECT_LIMIT: usize = 50;
92106

93107
#[derive(Debug, Clone, PartialEq, Eq)]
94108
struct PersistedUiState {
95109
metric: UsageMetric,
96110
range: ChartRange,
97111
orientation: ChartOrientation,
112+
activity_project_limit: usize,
98113
workspace_path: Option<PathBuf>,
99114
no_sessions_confirm_dismissed: bool,
100115
}
@@ -105,6 +120,7 @@ impl PersistedUiState {
105120
metric: UsageMetric::Tokens,
106121
range: ChartRange::Week,
107122
orientation: ChartOrientation::Horizontal,
123+
activity_project_limit: DEFAULT_ACTIVITY_PROJECT_LIMIT,
108124
workspace_path,
109125
no_sessions_confirm_dismissed: false,
110126
}
@@ -115,6 +131,7 @@ impl PersistedUiState {
115131
metric: state.metric,
116132
range: state.range,
117133
orientation: state.orientation,
134+
activity_project_limit: state.activity_project_limit,
118135
workspace_path: state.workspace_path.clone(),
119136
no_sessions_confirm_dismissed: state.no_sessions_confirm_dismissed,
120137
}
@@ -145,6 +162,7 @@ struct StoredGlobalState {
145162
metric: Option<String>,
146163
range: Option<String>,
147164
orientation: Option<String>,
165+
activity_project_limit: Option<usize>,
148166
last_workspace_path: Option<String>,
149167
updated_at: i64,
150168
}
@@ -342,6 +360,7 @@ async fn run_inner(
342360
metric: restored_ui_state.metric,
343361
range: restored_ui_state.range,
344362
orientation: restored_ui_state.orientation,
363+
activity_project_limit: restored_ui_state.activity_project_limit,
345364
show_help: false,
346365
workspace_path: restored_ui_state.workspace_path.clone(),
347366
no_sessions_confirm_open: false,
@@ -476,7 +495,8 @@ fn handle_input_event(
476495
(KeyCode::Char('c'), KeyModifiers::CONTROL) => return Ok(InputOutcome::Quit),
477496
(KeyCode::Char('s'), _) | (KeyCode::Char('S'), _) | (KeyCode::F(2), _) => {
478497
state.active_screen = match state.active_screen {
479-
ActiveScreen::Usage => ActiveScreen::Read,
498+
ActiveScreen::Usage => ActiveScreen::Activity,
499+
ActiveScreen::Activity => ActiveScreen::Read,
480500
ActiveScreen::Read => ActiveScreen::Usage,
481501
};
482502
return Ok(InputOutcome::Continue(true));
@@ -506,6 +526,14 @@ fn handle_input_event(
506526
let dirty = handle_usage_command(state, command, usage_refresh_tx, limits_refresh_tx);
507527
Ok(InputOutcome::Continue(dirty))
508528
}
529+
ActiveScreen::Activity => {
530+
let Some(command) = map_event_to_activity_cmd(event) else {
531+
return Ok(InputOutcome::Continue(false));
532+
};
533+
let dirty =
534+
handle_activity_command(state, command, usage_refresh_tx, limits_refresh_tx);
535+
Ok(InputOutcome::Continue(dirty))
536+
}
509537
ActiveScreen::Read => Ok(InputOutcome::Continue(read::tui::handle_event(
510538
&mut state.read_browser,
511539
event,
@@ -536,6 +564,29 @@ fn map_event_to_usage_cmd(event: Event) -> Option<UsageCommand> {
536564
}
537565
}
538566

567+
fn map_event_to_activity_cmd(event: Event) -> Option<ActivityCommand> {
568+
match event {
569+
Event::Key(key) => {
570+
if key.kind != KeyEventKind::Press {
571+
return None;
572+
}
573+
match (key.code, key.modifiers) {
574+
(KeyCode::Char('r'), _) | (KeyCode::F(5), _) => Some(ActivityCommand::RefreshAll),
575+
(KeyCode::Tab, _) => Some(ActivityCommand::ToggleMetric),
576+
(KeyCode::Char('+'), _) | (KeyCode::Char('='), _) | (KeyCode::Char(']'), _) => {
577+
Some(ActivityCommand::IncreaseProjects)
578+
}
579+
(KeyCode::Char('-'), _) | (KeyCode::Char('['), _) => {
580+
Some(ActivityCommand::DecreaseProjects)
581+
}
582+
(KeyCode::Char('?'), _) => Some(ActivityCommand::ToggleHelp),
583+
_ => None,
584+
}
585+
}
586+
_ => None,
587+
}
588+
}
589+
539590
fn handle_usage_command(
540591
state: &mut AppState,
541592
cmd: UsageCommand,
@@ -588,6 +639,51 @@ fn handle_usage_command(
588639
}
589640
}
590641

642+
fn handle_activity_command(
643+
state: &mut AppState,
644+
cmd: ActivityCommand,
645+
usage_refresh_tx: &mpsc::Sender<()>,
646+
limits_refresh_tx: &mpsc::Sender<()>,
647+
) -> bool {
648+
match cmd {
649+
ActivityCommand::ToggleHelp => {
650+
state.show_help = !state.show_help;
651+
true
652+
}
653+
ActivityCommand::ToggleMetric => {
654+
state.metric = match state.metric {
655+
UsageMetric::Tokens => UsageMetric::Time,
656+
UsageMetric::Time => UsageMetric::Runs,
657+
UsageMetric::Runs => UsageMetric::Tokens,
658+
};
659+
true
660+
}
661+
ActivityCommand::IncreaseProjects => {
662+
let next = state
663+
.activity_project_limit
664+
.saturating_add(1)
665+
.min(MAX_ACTIVITY_PROJECT_LIMIT);
666+
let changed = next != state.activity_project_limit;
667+
state.activity_project_limit = next;
668+
changed
669+
}
670+
ActivityCommand::DecreaseProjects => {
671+
let next = state
672+
.activity_project_limit
673+
.saturating_sub(1)
674+
.max(MIN_ACTIVITY_PROJECT_LIMIT);
675+
let changed = next != state.activity_project_limit;
676+
state.activity_project_limit = next;
677+
changed
678+
}
679+
ActivityCommand::RefreshAll => {
680+
let _ = usage_refresh_tx.try_send(());
681+
let _ = limits_refresh_tx.try_send(());
682+
true
683+
}
684+
}
685+
}
686+
591687
fn handle_app_event(state: &mut AppState, evt: AppEvent) -> bool {
592688
match evt {
593689
AppEvent::UsageUpdated(res) => {
@@ -653,6 +749,10 @@ fn load_persisted_ui_state(
653749
state.orientation = orientation;
654750
}
655751
}
752+
if let Some(limit) = store.global.activity_project_limit {
753+
state.activity_project_limit =
754+
limit.clamp(MIN_ACTIVITY_PROJECT_LIMIT, MAX_ACTIVITY_PROJECT_LIMIT);
755+
}
656756

657757
if let Some(workspace_path) = state.workspace_path.as_ref() {
658758
let workspace_key = workspace_path.to_string_lossy();
@@ -675,6 +775,11 @@ fn save_persisted_ui_state(comon_home: &Path, state: &PersistedUiState) -> Resul
675775
store.global.metric = Some(usage_metric_to_store(state.metric).to_string());
676776
store.global.range = Some(chart_range_to_store(state.range).to_string());
677777
store.global.orientation = Some(chart_orientation_to_store(state.orientation).to_string());
778+
store.global.activity_project_limit = Some(
779+
state
780+
.activity_project_limit
781+
.clamp(MIN_ACTIVITY_PROJECT_LIMIT, MAX_ACTIVITY_PROJECT_LIMIT),
782+
);
678783
store.global.last_workspace_path = workspace_path_text.clone();
679784
store.global.updated_at = now;
680785

0 commit comments

Comments
 (0)