feat(gui): add activity calendar to the homepage (#2160)

* feat(gui): add activity calendar to the homepage

* localise week start
This commit is contained in:
Ellie Huxtable 2024-06-18 17:11:24 +01:00 committed by GitHub
parent 7984f9ef0c
commit b8be23ee99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 159 additions and 1 deletions

View File

@ -174,6 +174,24 @@ impl HistoryDB {
Ok(history) 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> { pub async fn global_stats(&self) -> Result<GlobalStats, String> {
let day_ago = time::OffsetDateTime::now_utc() - time::Duration::days(1); let day_ago = time::OffsetDateTime::now_utc() - time::Duration::days(1);
let day_ago = day_ago.unix_timestamp_nanos(); let day_ago = day_ago.unix_timestamp_nanos();

View File

@ -167,6 +167,54 @@ async fn home_info() -> Result<HomeInfo, String> {
Ok(info) 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) { fn show_window(app: &AppHandle) {
let windows = app.webview_windows(); let windows = app.webview_windows();
@ -190,6 +238,7 @@ fn main() {
session, session,
login, login,
register, register,
history_calendar,
install::install_cli, install::install_cli,
install::is_cli_installed, install::is_cli_installed,
install::setup_cli, install::setup_cli,

View File

@ -32,8 +32,10 @@
"prism-react-renderer": "^2.3.1", "prism-react-renderer": "^2.3.1",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-activity-calendar": "^2.2.10",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-spinners": "^0.13.8", "react-spinners": "^0.13.8",
"react-tooltip": "^5.27.0",
"react-window": "^1.8.10", "react-window": "^1.8.10",
"react-window-infinite-loader": "^1.0.9", "react-window-infinite-loader": "^1.0.9",
"recharts": "^2.12.7", "recharts": "^2.12.7",

View File

@ -71,12 +71,18 @@ dependencies:
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 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: react-dom:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-spinners: react-spinners:
specifier: ^0.13.8 specifier: ^0.13.8
version: 0.13.8(react-dom@18.3.1)(react@18.3.1) 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: react-window:
specifier: ^1.8.10 specifier: ^1.8.10
version: 1.8.10(react-dom@18.3.1)(react@18.3.1) version: 1.8.10(react-dom@18.3.1)(react@18.3.1)
@ -1669,6 +1675,10 @@ packages:
'@babel/types': 7.24.7 '@babel/types': 7.24.7
dev: true dev: true
/@types/chroma-js@2.4.4:
resolution: {integrity: sha512-/DTccpHTaKomqussrn+ciEvfW4k6NAHzNzs/sts1TCqg333qNxOhy8TNIoQCmbGG3Tl8KdEhkGAssb1n3mTXiQ==}
dev: false
/@types/d3-array@3.2.1: /@types/d3-array@3.2.1:
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
dev: false dev: false
@ -1870,12 +1880,20 @@ packages:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
/chroma-js@2.4.2:
resolution: {integrity: sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==}
dev: false
/class-variance-authority@0.7.0: /class-variance-authority@0.7.0:
resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==}
dependencies: dependencies:
clsx: 2.0.0 clsx: 2.0.0
dev: false dev: false
/classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
dev: false
/clsx@2.0.0: /clsx@2.0.0:
resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -2500,6 +2518,19 @@ packages:
/queue-microtask@1.2.3: /queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} 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): /react-dom@18.3.1(react@18.3.1):
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies: peerDependencies:
@ -2594,6 +2625,18 @@ packages:
tslib: 2.6.3 tslib: 2.6.3
dev: false 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): /react-transition-group@4.4.5(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies: peerDependencies:

View File

@ -1,10 +1,13 @@
import { useEffect } from "react"; import React, { useEffect, useState } from "react";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
import { Tooltip as ReactTooltip } from "react-tooltip";
import { useStore } from "@/state/store"; import { useStore } from "@/state/store";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import ActivityCalendar from "react-activity-calendar";
function Stats({ stats }: any) { function Stats({ stats }: any) {
return ( return (
<div> <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() { export default function Home() {
const [weekStart, setWeekStart] = useState(0);
const homeInfo = useStore((state) => state.homeInfo); const homeInfo = useStore((state) => state.homeInfo);
const user = useStore((state) => state.user); const user = useStore((state) => state.user);
const calendar = useStore((state) => state.calendar);
const refreshHomeInfo = useStore((state) => state.refreshHomeInfo); const refreshHomeInfo = useStore((state) => state.refreshHomeInfo);
const refreshUser = useStore((state) => state.refreshUser); const refreshUser = useStore((state) => state.refreshUser);
const refreshCalendar = useStore((state) => state.refreshCalendar);
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => { useEffect(() => {
let locale = new Intl.Locale(navigator.language);
let weekinfo = locale.getWeekInfo();
setWeekStart(weekinfo.firstDay);
refreshHomeInfo(); refreshHomeInfo();
refreshUser(); refreshUser();
refreshCalendar();
let setup = async () => { let setup = async () => {
let installed = await invoke("is_cli_installed"); let installed = await invoke("is_cli_installed");
@ -112,6 +131,24 @@ export default function Home() {
]} ]}
/> />
</div> </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>
</div> </div>
); );

View File

@ -25,8 +25,10 @@ interface AtuinState {
aliases: Alias[]; aliases: Alias[];
vars: Var[]; vars: Var[];
shellHistory: ShellHistory[]; shellHistory: ShellHistory[];
calendar: any[];
refreshHomeInfo: () => void; refreshHomeInfo: () => void;
refreshCalendar: () => void;
refreshAliases: () => void; refreshAliases: () => void;
refreshVars: () => void; refreshVars: () => void;
refreshUser: () => void; refreshUser: () => void;
@ -40,6 +42,7 @@ export const useStore = create<AtuinState>()((set, get) => ({
aliases: [], aliases: [],
vars: [], vars: [],
shellHistory: [], shellHistory: [],
calendar: [],
refreshAliases: () => { refreshAliases: () => {
invoke("aliases").then((aliases: any) => { 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: () => { refreshVars: () => {
invoke("vars").then((vars: any) => { invoke("vars").then((vars: any) => {
set({ vars: vars }); set({ vars: vars });