summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@elliehuxtable.com>2024-06-18 17:11:24 +0100
committerGitHub <noreply@github.com>2024-06-18 17:11:24 +0100
commitb8be23ee99f47c89d9c9f4ce508b940efc88b1ca (patch)
treead5ba50590f0cdb11b2ea4540795ced931ee7c30
parent7984f9ef0c1824d4f2cffcb6e93cc951d3913be8 (diff)
feat(gui): add activity calendar to the homepage (#2160)
* feat(gui): add activity calendar to the homepage * localise week start
-rw-r--r--ui/backend/src/db.rs18
-rw-r--r--ui/backend/src/main.rs49
-rw-r--r--ui/package.json2
-rw-r--r--ui/pnpm-lock.yaml43
-rw-r--r--ui/src/pages/Home.tsx39
-rw-r--r--ui/src/state/store.ts9
6 files changed, 159 insertions, 1 deletions
diff --git a/ui/backend/src/db.rs b/ui/backend/src/db.rs
index 7e29302a2..1015ebf17 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<Vec<(String, u64)>, 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::<String, _>("day"),
+ row.get::<i64, _>("count") as u64,
+ )
+ })
+ .fetch_all(&self.0.pool)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ Ok(calendar)
+ }
+
pub async fn global_stats(&self) -> Result<GlobalStats, String> {
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 f03bccda5..2ba67e500 100644
--- a/ui/backend/src/main.rs
+++ b/ui/backend/src/main.rs
@@ -167,6 +167,54 @@ async fn home_info() -> Result<HomeInfo, String> {
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<Vec<HistoryCalendarDay>, 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 11726aa49..bd0b17b99 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 db5e044f3..1ba940aed 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 007523263..51c1e934a 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 (
<div>
@@ -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() {
]}
/>
</div>
+
+ <div className="pt-10">
+ <ActivityCalendar
+ theme={explicitTheme}
+ data={calendar}
+ weekStart={weekStart}
+ renderBlock={(block, activity) =>
+ 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",
+ }}
+ />
+ <ReactTooltip id="react-tooltip" />
+ </div>
</div>
</div>
);
diff --git a/ui/src/state/store.ts b/ui/src/state/store.ts
index 1ad5dc323..822abc263 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<AtuinState>()((set, get) => ({
aliases: [],
vars: [],
shellHistory: [],
+ calendar: [],
refreshAliases: () => {
invoke("aliases").then((aliases: any) => {
@@ -47,6 +50,12 @@ export const useStore = create<AtuinState>()((set, get) => ({
});
},
+ refreshCalendar: () => {
+ invoke("history_calendar").then((calendar: any) => {
+ set({ calendar: calendar });
+ });
+ },
+
refreshVars: () => {
invoke("vars").then((vars: any) => {
set({ vars: vars });