diff --git a/ui/backend/src/db.rs b/ui/backend/src/db.rs index 7e29302a245..1015ebf175f 100644 --- a/ui/backend/src/db.rs +++ b/ui/backend/src/db.rs @@ -174,6 +174,24 @@ impl HistoryDB { Ok(history) } + pub async fn calendar(&self) -> Result, String> { + let query = "select count(1) as count, strftime('%F', datetime(timestamp / 1000000000, 'unixepoch')) as day from history where timestamp > ((unixepoch() - 31536000) * 1000000000) group by day;"; + + let calendar: Vec<(String, u64)> = sqlx::query(query) + // safe to cast, count(x) is never < 0 + .map(|row: SqliteRow| { + ( + row.get::("day"), + row.get::("count") as u64, + ) + }) + .fetch_all(&self.0.pool) + .await + .map_err(|e| e.to_string())?; + + Ok(calendar) + } + pub async fn global_stats(&self) -> Result { let day_ago = time::OffsetDateTime::now_utc() - time::Duration::days(1); let day_ago = day_ago.unix_timestamp_nanos(); diff --git a/ui/backend/src/main.rs b/ui/backend/src/main.rs index f03bccda51a..2ba67e50015 100644 --- a/ui/backend/src/main.rs +++ b/ui/backend/src/main.rs @@ -167,6 +167,54 @@ async fn home_info() -> Result { Ok(info) } +// Match the format that the frontend library we use expects +// All the processing in Rust, not JS. +// Faaaassssssst af ⚡️🦀 +#[derive(Debug, serde::Serialize)] +pub struct HistoryCalendarDay { + pub date: String, + pub count: u64, + pub level: u8, +} + +#[tauri::command] +async fn history_calendar() -> Result, String> { + let settings = Settings::new().map_err(|e| e.to_string())?; + let db_path = PathBuf::from(settings.db_path.as_str()); + let db = HistoryDB::new(db_path, settings.local_timeout).await?; + + let calendar = db.calendar().await?; + + // probs don't want to iterate _this_ many times, but it's only the last year. so 365 + // iterations at max. should be quick. + + let max = calendar + .iter() + .max_by_key(|d| d.1) + .expect("Can't find max count"); + + let ret = calendar + .iter() + .map(|d| { + // calculate the "level". we have 5, so figure out which 5th it fits into + let percent: f64 = d.1 as f64 / max.1 as f64; + let level = if d.1 == 0 { + 0.0 + } else { + (percent / 0.2).round() + 1.0 + }; + + HistoryCalendarDay { + date: d.0.clone(), + count: d.1, + level: std::cmp::min(4, level as u8), + } + }) + .collect(); + + Ok(ret) +} + fn show_window(app: &AppHandle) { let windows = app.webview_windows(); @@ -190,6 +238,7 @@ fn main() { session, login, register, + history_calendar, install::install_cli, install::is_cli_installed, install::setup_cli, diff --git a/ui/package.json b/ui/package.json index 11726aa4937..bd0b17b99b2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,8 +32,10 @@ "prism-react-renderer": "^2.3.1", "prismjs": "^1.29.0", "react": "^18.3.1", + "react-activity-calendar": "^2.2.10", "react-dom": "^18.3.1", "react-spinners": "^0.13.8", + "react-tooltip": "^5.27.0", "react-window": "^1.8.10", "react-window-infinite-loader": "^1.0.9", "recharts": "^2.12.7", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index db5e044f34f..1ba940aed76 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -71,12 +71,18 @@ dependencies: react: specifier: ^18.3.1 version: 18.3.1 + react-activity-calendar: + specifier: ^2.2.10 + version: 2.2.10(react-dom@18.3.1)(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) react-spinners: specifier: ^0.13.8 version: 0.13.8(react-dom@18.3.1)(react@18.3.1) + react-tooltip: + specifier: ^5.27.0 + version: 5.27.0(react-dom@18.3.1)(react@18.3.1) react-window: specifier: ^1.8.10 version: 1.8.10(react-dom@18.3.1)(react@18.3.1) @@ -1669,6 +1675,10 @@ packages: '@babel/types': 7.24.7 dev: true + /@types/chroma-js@2.4.4: + resolution: {integrity: sha512-/DTccpHTaKomqussrn+ciEvfW4k6NAHzNzs/sts1TCqg333qNxOhy8TNIoQCmbGG3Tl8KdEhkGAssb1n3mTXiQ==} + dev: false + /@types/d3-array@3.2.1: resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} dev: false @@ -1870,12 +1880,20 @@ packages: optionalDependencies: fsevents: 2.3.3 + /chroma-js@2.4.2: + resolution: {integrity: sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==} + dev: false + /class-variance-authority@0.7.0: resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} dependencies: clsx: 2.0.0 dev: false + /classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + dev: false + /clsx@2.0.0: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} @@ -2500,6 +2518,19 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + /react-activity-calendar@2.2.10(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-6UsPmw6jD5TM5DHAVCIKkOhqdcJ1reOrFsMd3pDnQ5Yo8WfkFoDLjYQRYUkH6BWhishVpZ3JTn39Tf+3JyVY6w==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + dependencies: + '@types/chroma-js': 2.4.4 + chroma-js: 2.4.2 + date-fns: 3.6.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /react-dom@18.3.1(react@18.3.1): resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -2594,6 +2625,18 @@ packages: tslib: 2.6.3 dev: false + /react-tooltip@5.27.0(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-JXROcdfCEbCqkAkh8LyTSP3guQ0dG53iY2E2o4fw3D8clKzziMpE6QG6CclDaHELEKTzpMSeAOsdtg0ahoQosw==} + peerDependencies: + react: '>=16.14.0' + react-dom: '>=16.14.0' + dependencies: + '@floating-ui/dom': 1.6.5 + classnames: 2.5.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /react-transition-group@4.4.5(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: diff --git a/ui/src/pages/Home.tsx b/ui/src/pages/Home.tsx index 0075232632e..51c1e934acd 100644 --- a/ui/src/pages/Home.tsx +++ b/ui/src/pages/Home.tsx @@ -1,10 +1,13 @@ -import { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { formatRelative } from "date-fns"; +import { Tooltip as ReactTooltip } from "react-tooltip"; import { useStore } from "@/state/store"; import { useToast } from "@/components/ui/use-toast"; import { invoke } from "@tauri-apps/api/core"; +import ActivityCalendar from "react-activity-calendar"; + function Stats({ stats }: any) { return (
@@ -44,16 +47,32 @@ function Header({ name }: any) { ); } +const explicitTheme: ThemeInput = { + light: ["#f0f0f0", "#c4edde", "#7ac7c4", "#f73859", "#384259"], + dark: ["#383838", "#4D455D", "#7DB9B6", "#F5E9CF", "#E96479"], +}; + export default function Home() { + const [weekStart, setWeekStart] = useState(0); + const homeInfo = useStore((state) => state.homeInfo); const user = useStore((state) => state.user); + const calendar = useStore((state) => state.calendar); + const refreshHomeInfo = useStore((state) => state.refreshHomeInfo); const refreshUser = useStore((state) => state.refreshUser); + const refreshCalendar = useStore((state) => state.refreshCalendar); + const { toast } = useToast(); useEffect(() => { + let locale = new Intl.Locale(navigator.language); + let weekinfo = locale.getWeekInfo(); + setWeekStart(weekinfo.firstDay); + refreshHomeInfo(); refreshUser(); + refreshCalendar(); let setup = async () => { let installed = await invoke("is_cli_installed"); @@ -112,6 +131,24 @@ export default function Home() { ]} />
+ +
+ + React.cloneElement(block, { + "data-tooltip-id": "react-tooltip", + "data-tooltip-html": `${activity.count} commands on ${activity.date}`, + }) + } + labels={{ + totalCount: "{{count}} history records in the last year", + }} + /> + +
); diff --git a/ui/src/state/store.ts b/ui/src/state/store.ts index 1ad5dc323e4..822abc263db 100644 --- a/ui/src/state/store.ts +++ b/ui/src/state/store.ts @@ -25,8 +25,10 @@ interface AtuinState { aliases: Alias[]; vars: Var[]; shellHistory: ShellHistory[]; + calendar: any[]; refreshHomeInfo: () => void; + refreshCalendar: () => void; refreshAliases: () => void; refreshVars: () => void; refreshUser: () => void; @@ -40,6 +42,7 @@ export const useStore = create()((set, get) => ({ aliases: [], vars: [], shellHistory: [], + calendar: [], refreshAliases: () => { invoke("aliases").then((aliases: any) => { @@ -47,6 +50,12 @@ export const useStore = create()((set, get) => ({ }); }, + refreshCalendar: () => { + invoke("history_calendar").then((calendar: any) => { + set({ calendar: calendar }); + }); + }, + refreshVars: () => { invoke("vars").then((vars: any) => { set({ vars: vars });