feat: big bang

This commit is contained in:
Leafd 2025-10-03 12:14:31 -04:00
commit ed294cb662
58 changed files with 13692 additions and 0 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
*.svg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
README.md Normal file
View file

@ -0,0 +1 @@
# hackatime-desktop

17
index.html Normal file
View file

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hackatime</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "desktop",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-deep-link": "^2.4.3",
"@tauri-apps/plugin-opener": "^2",
"chart.js": "^4.5.0",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}

1488
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

BIN
public/flame-icon.svg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
public/tauri.svg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
public/vite.svg (Stored with Git LFS) Normal file

Binary file not shown.

7
src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

6526
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

34
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,34 @@
[package]
name = "desktop"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
open = "5"
urlencoding = "2"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
discord-rich-presence = "1.0"

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"deep-link:default"
]
}

BIN
src-tauri/icons/128x128.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/128x128@2x.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/32x32.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/Square107x107Logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/Square142x142Logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/Square150x150Logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/Square284x284Logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/Square30x30Logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/Square310x310Logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/Square44x44Logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/Square71x71Logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/Square89x89Logo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/StoreLogo.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,243 @@
{
"classes": [
{
"name": "The Rustacean",
"description": "A fearless systems programmer who embraces memory safety and zero-cost abstractions.",
"level": "Legendary",
"color": "#CE422B",
"technologies": ["Rust", "Cargo", "Tokio", "Serde"],
"conditions": {
"primary_languages": ["Rust"],
"min_hours": 50,
"min_streak": 7
}
},
{
"name": "The Pythonista",
"description": "A versatile developer who believes in the beauty of clean, readable code.",
"level": "Expert",
"color": "#3776AB",
"technologies": ["Python", "Django", "Flask", "Pandas", "NumPy"],
"conditions": {
"primary_languages": ["Python"],
"min_hours": 30,
"min_streak": 5
}
},
{
"name": "The JavaScript Ninja",
"description": "A master of the web who can make anything dance in the browser.",
"level": "Advanced",
"color": "#F7DF1E",
"technologies": ["JavaScript", "Node.js", "React", "Vue.js", "TypeScript"],
"conditions": {
"primary_languages": ["JavaScript", "TypeScript"],
"min_hours": 20,
"min_streak": 3
}
},
{
"name": "The Go Gopher",
"description": "A pragmatic developer who values simplicity and concurrency.",
"level": "Advanced",
"color": "#00ADD8",
"technologies": ["Go", "Goroutines", "Channels", "Gin", "Echo"],
"conditions": {
"primary_languages": ["Go"],
"min_hours": 25,
"min_streak": 4
}
},
{
"name": "The C++ Wizard",
"description": "A master of performance who wields templates and memory management like a sorcerer.",
"level": "Expert",
"color": "#00599C",
"technologies": ["C++", "STL", "Boost", "CMake", "Conan"],
"conditions": {
"primary_languages": ["C++", "C"],
"min_hours": 40,
"min_streak": 6
}
},
{
"name": "The Java Architect",
"description": "A enterprise developer who builds robust, scalable systems.",
"level": "Expert",
"color": "#ED8B00",
"technologies": ["Java", "Spring", "Maven", "JUnit", "Hibernate"],
"conditions": {
"primary_languages": ["Java"],
"min_hours": 35,
"min_streak": 5
}
},
{
"name": "The Swift Enthusiast",
"description": "An iOS developer who crafts beautiful, performant mobile experiences.",
"level": "Advanced",
"color": "#FA7343",
"technologies": ["Swift", "SwiftUI", "UIKit", "Combine", "Core Data"],
"conditions": {
"primary_languages": ["Swift"],
"min_hours": 20,
"min_streak": 3
}
},
{
"name": "The Kotlin Craftsman",
"description": "A modern Android developer who embraces null safety and functional programming.",
"level": "Advanced",
"color": "#7F52FF",
"technologies": ["Kotlin", "Android", "Jetpack Compose", "Coroutines", "Ktor"],
"conditions": {
"primary_languages": ["Kotlin"],
"min_hours": 25,
"min_streak": 4
}
},
{
"name": "The Ruby Gem",
"description": "A developer who values developer happiness and elegant syntax.",
"level": "Intermediate",
"color": "#CC342D",
"technologies": ["Ruby", "Rails", "RSpec", "Bundler", "Sinatra"],
"conditions": {
"primary_languages": ["Ruby"],
"min_hours": 15,
"min_streak": 3
}
},
{
"name": "The PHP Artisan",
"description": "A web developer who powers the internet with dynamic content.",
"level": "Intermediate",
"color": "#777BB4",
"technologies": ["PHP", "Laravel", "Composer", "Symfony", "WordPress"],
"conditions": {
"primary_languages": ["PHP"],
"min_hours": 15,
"min_streak": 3
}
},
{
"name": "The C# Sharp",
"description": "A Microsoft ecosystem developer who builds enterprise solutions.",
"level": "Advanced",
"color": "#239120",
"technologies": ["C#", ".NET", "ASP.NET", "Entity Framework", "Xamarin"],
"conditions": {
"primary_languages": ["C#"],
"min_hours": 25,
"min_streak": 4
}
},
{
"name": "The Scala Scholar",
"description": "A functional programming enthusiast who combines OOP and FP paradigms.",
"level": "Expert",
"color": "#DC322F",
"technologies": ["Scala", "Akka", "Play Framework", "SBT", "Cats"],
"conditions": {
"primary_languages": ["Scala"],
"min_hours": 30,
"min_streak": 5
}
},
{
"name": "The Haskell Wizard",
"description": "A pure functional programmer who thinks in types and monads.",
"level": "Legendary",
"color": "#5D4F85",
"technologies": ["Haskell", "GHC", "Stack", "Cabal", "Lens"],
"conditions": {
"primary_languages": ["Haskell"],
"min_hours": 20,
"min_streak": 3
}
},
{
"name": "The Clojure Conjurer",
"description": "A Lisp enthusiast who embraces immutability and functional programming.",
"level": "Advanced",
"color": "#5881D8",
"technologies": ["Clojure", "ClojureScript", "Leiningen", "Ring", "Reagent"],
"conditions": {
"primary_languages": ["Clojure"],
"min_hours": 15,
"min_streak": 3
}
},
{
"name": "The Elixir Alchemist",
"description": "A concurrent programmer who builds fault-tolerant distributed systems.",
"level": "Advanced",
"color": "#4B275F",
"technologies": ["Elixir", "Phoenix", "OTP", "Mix", "Ecto"],
"conditions": {
"primary_languages": ["Elixir"],
"min_hours": 20,
"min_streak": 3
}
},
{
"name": "The R Researcher",
"description": "A data scientist who uncovers insights from complex datasets.",
"level": "Expert",
"color": "#276DC3",
"technologies": ["R", "Shiny", "ggplot2", "dplyr", "RStudio"],
"conditions": {
"primary_languages": ["R"],
"min_hours": 25,
"min_streak": 4
}
},
{
"name": "The Julia Scientist",
"description": "A numerical computing expert who combines performance with ease of use.",
"level": "Advanced",
"color": "#9558B2",
"technologies": ["Julia", "Plots.jl", "DataFrames.jl", "Flux.jl", "Genie.jl"],
"conditions": {
"primary_languages": ["Julia"],
"min_hours": 15,
"min_streak": 3
}
},
{
"name": "The Assembly Artisan",
"description": "A low-level programming master who understands the machine at its core.",
"level": "Legendary",
"color": "#6E4C13",
"technologies": ["Assembly", "x86", "ARM", "MASM", "NASM"],
"conditions": {
"primary_languages": ["Assembly"],
"min_hours": 10,
"min_streak": 2
}
},
{
"name": "The Polyglot",
"description": "A versatile developer who speaks many programming languages fluently.",
"level": "Expert",
"color": "#FF6B6B",
"technologies": ["Multiple Languages", "Cross-platform", "Microservices", "DevOps"],
"conditions": {
"language_count": 5,
"min_hours": 50,
"min_streak": 7
}
},
{
"name": "The Code Explorer",
"description": "An enthusiastic learner discovering the vast world of programming.",
"level": "Learning",
"color": "#9C27B0",
"technologies": ["HTML", "CSS", "JavaScript", "Git", "VS Code"],
"conditions": {
"min_hours": 0,
"max_hours": 20
}
}
]
}

349
src-tauri/src/database.rs Normal file
View file

@ -0,0 +1,349 @@
use serde::{Deserialize, Serialize};
use sqlx::{sqlite::SqlitePool, Row};
use std::collections::HashMap;
use std::path::Path;
use std::fs;
use std::env;
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthState {
pub is_authenticated: bool,
pub access_token: Option<String>,
pub user_info: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(dead_code)]
pub struct SessionRecord {
pub id: String,
pub is_authenticated: bool,
pub access_token: Option<String>,
pub user_info: Option<String>, // JSON string
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_accessed_at: DateTime<Utc>,
}
pub struct Database {
pool: SqlitePool,
}
impl Database {
pub async fn new() -> Result<Self, String> {
let db_path = get_hackatime_db_path()?;
// Ensure the hackatime directory exists
if let Some(parent) = db_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create hackatime directory: {}", e))?;
println!("Created directory: {}", parent.display());
}
}
// Ensure the parent directory exists and is writable
if let Some(parent) = db_path.parent() {
if !parent.exists() {
return Err(format!("Parent directory does not exist: {}", parent.display()));
}
// Test if we can write to the directory
let test_file = parent.join(".write_test");
if let Err(e) = fs::write(&test_file, "test") {
return Err(format!("Cannot write to directory {}: {}", parent.display(), e));
}
// Clean up test file
let _ = fs::remove_file(&test_file);
println!("Directory is writable: {}", parent.display());
}
// Create the database file if it doesn't exist
if !db_path.exists() {
println!("Database file doesn't exist, creating: {}", db_path.display());
// Touch the file to ensure it exists
if let Err(e) = fs::write(&db_path, "") {
return Err(format!("Cannot create database file {}: {}", db_path.display(), e));
}
} else {
println!("Database file already exists: {}", db_path.display());
}
// Check file permissions
if let Ok(metadata) = fs::metadata(&db_path) {
println!("Database file metadata: {:?}", metadata);
}
// Try using SqlitePool::connect_with instead of connect
let database_url = format!("sqlite:{}", db_path.display());
println!("Connecting to database at: {}", database_url);
// First try the standard connect method
let pool_result = SqlitePool::connect(&database_url).await;
let pool = match pool_result {
Ok(pool) => pool,
Err(e) => {
println!("Standard connect failed: {}, trying connect_with", e);
// Try with explicit options
let options = sqlx::sqlite::SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true);
SqlitePool::connect_with(options)
.await
.map_err(|e| format!("Failed to connect to database at {} with connect_with: {}", db_path.display(), e))?
}
};
let db = Database { pool };
db.migrate().await?;
println!("Database initialized successfully");
Ok(db)
}
async fn migrate(&self) -> Result<(), String> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
is_authenticated INTEGER NOT NULL DEFAULT 0,
access_token TEXT,
user_info TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_accessed_at TEXT NOT NULL
)
"#,
)
.execute(&self.pool)
.await
.map_err(|e| format!("Failed to create sessions table: {}", e))?;
Ok(())
}
pub async fn save_session(&self, auth_state: &AuthState) -> Result<String, String> {
let session_id = Uuid::new_v4().to_string();
let now = Utc::now();
let user_info_json = match &auth_state.user_info {
Some(info) => Some(serde_json::to_string(info)
.map_err(|e| format!("Failed to serialize user info: {}", e))?),
None => None,
};
sqlx::query(
r#"
INSERT INTO sessions (id, is_authenticated, access_token, user_info, created_at, updated_at, last_accessed_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&session_id)
.bind(auth_state.is_authenticated as i32)
.bind(&auth_state.access_token)
.bind(&user_info_json)
.bind(now.to_rfc3339())
.bind(now.to_rfc3339())
.bind(now.to_rfc3339())
.execute(&self.pool)
.await
.map_err(|e| format!("Failed to save session: {}", e))?;
Ok(session_id)
}
#[allow(dead_code)]
pub async fn update_session(&self, session_id: &str, auth_state: &AuthState) -> Result<(), String> {
let now = Utc::now();
let user_info_json = match &auth_state.user_info {
Some(info) => Some(serde_json::to_string(info)
.map_err(|e| format!("Failed to serialize user info: {}", e))?),
None => None,
};
sqlx::query(
r#"
UPDATE sessions
SET is_authenticated = ?, access_token = ?, user_info = ?, updated_at = ?, last_accessed_at = ?
WHERE id = ?
"#,
)
.bind(auth_state.is_authenticated as i32)
.bind(&auth_state.access_token)
.bind(&user_info_json)
.bind(now.to_rfc3339())
.bind(now.to_rfc3339())
.bind(session_id)
.execute(&self.pool)
.await
.map_err(|e| format!("Failed to update session: {}", e))?;
Ok(())
}
pub async fn load_latest_session(&self) -> Result<Option<AuthState>, String> {
let row = sqlx::query(
r#"
SELECT id, is_authenticated, access_token, user_info, last_accessed_at
FROM sessions
ORDER BY last_accessed_at DESC
LIMIT 1
"#,
)
.fetch_optional(&self.pool)
.await
.map_err(|e| format!("Failed to load latest session: {}", e))?;
match row {
Some(row) => {
let session_id: String = row.get("id");
let is_authenticated: i32 = row.get("is_authenticated");
let access_token: Option<String> = row.get("access_token");
let user_info_json: Option<String> = row.get("user_info");
let user_info = match user_info_json {
Some(json) => {
match serde_json::from_str::<HashMap<String, serde_json::Value>>(&json) {
Ok(info) => Some(info),
Err(_) => None, // Skip invalid JSON
}
}
None => None,
};
// Update last_accessed_at
self.update_last_accessed(&session_id).await?;
Ok(Some(AuthState {
is_authenticated: is_authenticated != 0,
access_token,
user_info,
}))
}
None => Ok(None),
}
}
async fn update_last_accessed(&self, session_id: &str) -> Result<(), String> {
let now = Utc::now();
sqlx::query(
"UPDATE sessions SET last_accessed_at = ? WHERE id = ?"
)
.bind(now.to_rfc3339())
.bind(session_id)
.execute(&self.pool)
.await
.map_err(|e| format!("Failed to update last accessed time: {}", e))?;
Ok(())
}
pub async fn clear_sessions(&self) -> Result<(), String> {
sqlx::query("DELETE FROM sessions")
.execute(&self.pool)
.await
.map_err(|e| format!("Failed to clear sessions: {}", e))?;
Ok(())
}
pub async fn cleanup_old_sessions(&self, days_old: i64) -> Result<(), String> {
let cutoff = Utc::now() - chrono::Duration::days(days_old);
sqlx::query("DELETE FROM sessions WHERE last_accessed_at < ?")
.bind(cutoff.to_rfc3339())
.execute(&self.pool)
.await
.map_err(|e| format!("Failed to cleanup old sessions: {}", e))?;
Ok(())
}
}
fn get_hackatime_db_path() -> Result<std::path::PathBuf, String> {
let app_data_dir = get_app_data_dir()?;
let db_path = app_data_dir.join("sessions.db");
println!("Database path: {}", db_path.display());
println!("Parent directory exists: {}", db_path.parent().map_or(false, |p| p.exists()));
Ok(db_path)
}
fn get_app_data_dir() -> Result<std::path::PathBuf, String> {
if cfg!(target_os = "windows") {
// Windows: %APPDATA%\.hackatime\
let appdata = env::var("APPDATA").map_err(|_| "Failed to get APPDATA directory")?;
Ok(Path::new(&appdata).join(".hackatime"))
} else if cfg!(target_os = "macos") {
// macOS: ~/Library/Application Support/.hackatime/
let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?;
Ok(Path::new(&home).join("Library").join("Application Support").join(".hackatime"))
} else {
// Linux: ~/.local/share/.hackatime/
let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?;
Ok(Path::new(&home).join(".local").join("share").join(".hackatime"))
}
}
pub fn get_hackatime_config_dir() -> Result<std::path::PathBuf, String> {
let app_data_dir = get_app_data_dir()?;
// Create the directory if it doesn't exist
if !app_data_dir.exists() {
fs::create_dir_all(&app_data_dir)
.map_err(|e| format!("Failed to create hackatime directory: {}", e))?;
}
Ok(app_data_dir)
}
pub fn get_hackatime_logs_dir() -> Result<std::path::PathBuf, String> {
let config_dir = get_hackatime_config_dir()?;
let logs_dir = config_dir.join("logs");
// Create the logs directory if it doesn't exist
if !logs_dir.exists() {
fs::create_dir_all(&logs_dir)
.map_err(|e| format!("Failed to create logs directory: {}", e))?;
}
Ok(logs_dir)
}
pub fn get_hackatime_data_dir() -> Result<std::path::PathBuf, String> {
let config_dir = get_hackatime_config_dir()?;
let data_dir = config_dir.join("data");
// Create the data directory if it doesn't exist
if !data_dir.exists() {
fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
}
Ok(data_dir)
}
pub fn get_platform_info() -> Result<serde_json::Value, String> {
let app_data_dir = get_app_data_dir()?;
let platform_info = serde_json::json!({
"platform": std::env::consts::OS,
"app_data_dir": app_data_dir.to_string_lossy(),
"description": if cfg!(target_os = "windows") {
"Windows AppData directory"
} else if cfg!(target_os = "macos") {
"macOS Application Support directory"
} else {
"Linux XDG data directory"
}
});
Ok(platform_info)
}

View file

@ -0,0 +1,194 @@
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::Mutex;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DiscordRpcState {
pub is_connected: bool,
pub client_id: Option<String>,
pub current_activity: Option<DiscordActivity>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DiscordActivity {
pub project_name: String,
pub language: Option<String>,
pub editor: Option<String>,
pub entity: Option<String>,
pub start_time: Option<i64>,
}
pub struct DiscordRpcService {
client: Option<DiscordIpcClient>,
state: Arc<Mutex<DiscordRpcState>>,
}
impl DiscordRpcService {
pub fn new() -> Self {
Self {
client: None,
state: Arc::new(Mutex::new(DiscordRpcState {
is_connected: false,
client_id: None,
current_activity: None,
})),
}
}
pub fn connect(&mut self, client_id: &str) -> Result<(), String> {
// Close existing connection if any
if self.client.is_some() {
let _ = self.disconnect();
}
let mut client = DiscordIpcClient::new(client_id);
client.connect()
.map_err(|e| format!("Failed to connect to Discord: {}", e))?;
self.client = Some(client);
// Update state
let mut state = self.state.lock().unwrap();
state.is_connected = true;
state.client_id = Some(client_id.to_string());
Ok(())
}
pub fn disconnect(&mut self) -> Result<(), String> {
if let Some(mut client) = self.client.take() {
client.close()
.map_err(|e| format!("Failed to disconnect from Discord: {}", e))?;
}
// Update state
let mut state = self.state.lock().unwrap();
state.is_connected = false;
state.client_id = None;
state.current_activity = None;
Ok(())
}
pub fn set_activity(&mut self, activity: DiscordActivity) -> Result<(), String> {
self.set_activity_internal(activity.clone())?;
// Update state
let mut state = self.state.lock().unwrap();
state.current_activity = Some(activity);
Ok(())
}
fn set_activity_internal(&mut self, activity: DiscordActivity) -> Result<(), String> {
let client = self.client.as_mut()
.ok_or("Discord client not connected")?;
// Build details string
let mut details_parts = Vec::new();
if let Some(language) = &activity.language {
details_parts.push(format!("Language: {}", language));
}
if let Some(editor) = &activity.editor {
details_parts.push(format!("Editor: {}", editor));
}
if let Some(entity) = &activity.entity {
let filename = entity.split('/').last().unwrap_or(entity);
details_parts.push(format!("File: {}", filename));
}
let details_string = if !details_parts.is_empty() {
Some(details_parts.join(""))
} else {
None
};
// Create activity with all components
let mut discord_activity = activity::Activity::new()
.state(&activity.project_name);
if let Some(details) = &details_string {
discord_activity = discord_activity.details(details);
}
// Set start time if provided
if let Some(start_time) = activity.start_time {
discord_activity = discord_activity.timestamps(activity::Timestamps::new().start(start_time));
}
// Add assets
discord_activity = discord_activity
.assets(activity::Assets::new()
.large_image("kubetime")
.large_text("KubeTime - Time Tracking")
.small_image("coding")
.small_text("Coding"));
client.set_activity(discord_activity)
.map_err(|e| format!("Failed to set Discord activity: {}", e))?;
Ok(())
}
pub fn clear_activity(&mut self) -> Result<(), String> {
let client = self.client.as_mut()
.ok_or("Discord client not connected")?;
client.clear_activity()
.map_err(|e| format!("Failed to clear Discord activity: {}", e))?;
// Update state
let mut state = self.state.lock().unwrap();
state.current_activity = None;
Ok(())
}
pub fn get_state(&self) -> DiscordRpcState {
self.state.lock().unwrap().clone()
}
pub fn is_connected(&self) -> bool {
self.state.lock().unwrap().is_connected
}
pub fn update_activity_from_heartbeat(&mut self, heartbeat_data: &crate::HeartbeatData) -> Result<(), String> {
let activity = DiscordActivity {
project_name: heartbeat_data.project.clone().unwrap_or_else(|| "Unknown Project".to_string()),
language: heartbeat_data.language.clone(),
editor: heartbeat_data.editor.clone(),
entity: heartbeat_data.entity.clone(),
start_time: Some(heartbeat_data.timestamp as i64),
};
self.set_activity(activity)
}
pub fn update_activity_from_session(&mut self, heartbeat_data: &crate::HeartbeatData, session_start_time: i64) -> Result<(), String> {
let activity = DiscordActivity {
project_name: heartbeat_data.project.clone().unwrap_or_else(|| "Unknown Project".to_string()),
language: heartbeat_data.language.clone(),
editor: heartbeat_data.editor.clone(),
entity: heartbeat_data.entity.clone(),
start_time: Some(session_start_time),
};
self.set_activity(activity)
}
pub fn auto_connect(&mut self) -> Result<(), String> {
const DEFAULT_CLIENT_ID: &str = "1423077619183779872";
self.connect(DEFAULT_CLIENT_ID)
}
}
impl Drop for DiscordRpcService {
fn drop(&mut self) {
let _ = self.disconnect();
}
}

2176
src-tauri/src/lib.rs Normal file

File diff suppressed because it is too large Load diff

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
desktop_lib::run()
}

51
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,51 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hackatime Desktop",
"version": "0.1.0",
"identifier": "com.hackclub.hackatime",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Hackatime Desktop",
"width": 800,
"height": 600,
"visible": true,
"skipTaskbar": false,
"decorations": true,
"alwaysOnTop": false,
"resizable": true,
"minimizable": true,
"maximizable": true,
"closable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"shell": {
"open": true
},
"deepLink": {
"schemes": ["kubetime"]
}
}
}

577
src/App.vue Normal file
View file

@ -0,0 +1,577 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { api } from "./api";
import { useTheme } from "./composables/useTheme";
import Home from "./views/Home.vue";
import Projects from "./views/Projects.vue";
import Settings from "./views/Settings.vue";
import Statistics from "./views/Statistics.vue";
import PresenceCard from "./components/PresenceCard.vue";
import TrendCard from "./components/TrendCard.vue";
import WeeklyChart from "./components/WeeklyChart.vue";
interface AuthState {
is_authenticated: boolean;
access_token: string | null;
user_info: Record<string, any> | null;
}
interface ApiConfig {
base_url: string;
}
const authState = ref<AuthState>({
is_authenticated: false,
access_token: null,
user_info: null,
});
const apiConfig = ref<ApiConfig>({
base_url: "http://localhost:3000",
});
const isConfigOpen = ref(false);
const isLoading = ref(false);
const userData = ref<any>(null);
const userStats = ref<any>(null);
const isDevMode = ref(false);
const directOAuthToken = ref("");
const apiKey = ref<string | null>(null);
const showApiKey = ref(false);
const hackatimeDirectories = ref<any>(null);
const sessionStats = ref<any>(null);
const presenceData = ref<any>(null);
const presenceRefreshInterval = ref<number | null>(null);
const presenceFetchInProgress = ref(false);
const nextPresenceFetchAllowedAt = ref<number>(0);
const lastPresenceFetchAt = ref<number>(0);
// Navigation state
const currentPage = ref<'home' | 'projects' | 'statistics' | 'settings'>('home');
// Theme management
const { currentTheme, toggleTheme } = useTheme();
// Computed property for weekly chart data
const weeklyChartData = computed(() => {
if (!userStats.value?.weekly_stats?.daily_hours) return [];
const dailyHours = userStats.value.weekly_stats.daily_hours;
const maxHours = Math.max(...Object.values(dailyHours).map((day: any) => day.hours), 1);
// Convert object to array and sort by date
return Object.values(dailyHours)
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((day: any) => ({
...day,
percentage: (day.hours / maxHours) * 100
}));
});
// Computed property for trend data
const weeklyTrend = computed(() => {
if (!userStats.value?.weekly_stats) return null;
const currentWeekHours = (userStats.value.weekly_stats.time_coded_seconds || 0) / 3600;
const lastWeekHours = currentWeekHours * 0.85; // Simulate 15% increase
const change = ((currentWeekHours - lastWeekHours) / lastWeekHours * 100);
return {
title: change > 0 ? "You coded more than last week" : change < 0 ? "You coded less than last week" : "Same as last week",
change: change > 0 ? `+${Math.round(change)}%` : `${Math.round(change)}%`,
changeType: change > 0 ? 'increase' : change < 0 ? 'decrease' : 'neutral',
period: "vs last week",
icon: change > 0 ? "📈" : change < 0 ? "📉" : "➡️"
};
});
onMounted(async () => {
await loadAuthState();
await loadApiConfig();
await loadHackatimeInfo();
// Detect if we're in development mode
// Check if we're running on localhost (development) or have debug features
isDevMode.value = apiConfig.value.base_url.includes('localhost') ||
apiConfig.value.base_url.includes('127.0.0.1') ||
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
// Check if app was started via deep link
try {
const startUrls = await getCurrent();
if (startUrls && startUrls.length > 0) {
console.log("App started with deep link:", startUrls);
// The Rust backend will handle the deep link processing
// We just need to refresh the auth state after processing
setTimeout(async () => {
await loadAuthState();
}, 1000); // Give the backend time to process the deep link
}
} catch (error) {
console.error("Failed to get current deep link:", error);
}
// Listen for deep link events when app is already running
try {
await onOpenUrl((urls) => {
console.log("Deep link received in frontend:", urls);
// The Rust backend handles the actual processing
// We just need to refresh the auth state
setTimeout(async () => {
await loadAuthState();
}, 1000); // Give the backend time to process the deep link
});
} catch (error) {
console.error("Failed to set up deep link listener:", error);
}
// Listen for window focus events to refresh auth state after popup closes
window.addEventListener('focus', async () => {
await loadAuthState();
});
// Also listen for visibility change (when tab becomes active)
document.addEventListener('visibilitychange', async () => {
if (!document.hidden) {
await loadAuthState();
}
});
});
// Cleanup on unmount
onUnmounted(() => {
stopPresenceRefresh();
});
async function loadAuthState() {
try {
console.log("Loading authentication state...");
const savedAuthState = await invoke("load_auth_state");
console.log("Saved auth state result:", savedAuthState);
if (savedAuthState && (savedAuthState as AuthState).is_authenticated) {
authState.value = savedAuthState as AuthState;
console.log("Loaded saved authentication state:", authState.value);
// If authenticated, load user data, stats, and API keys
await loadUserData();
await loadApiKey();
await registerPresenceConnection();
} else {
// No saved state or not authenticated, get current state
console.log("No saved auth state found, getting current state");
authState.value = await invoke("get_auth_state");
console.log("Current auth state:", authState.value);
// If we have an authenticated state, save it to disk
if (authState.value.is_authenticated) {
try {
await invoke("save_auth_state", { authState: authState.value });
console.log("Current auth state saved to disk");
} catch (error) {
console.error("Failed to save current auth state:", error);
}
}
}
} catch (error) {
console.error("Failed to load auth state:", error);
// Fallback to current state on error
try {
authState.value = await invoke("get_auth_state");
} catch (fallbackError) {
console.error("Failed to get current auth state:", fallbackError);
}
}
}
async function loadUserData() {
try {
await api.initialize();
userData.value = await api.getCurrentUser();
// Load user dashboard stats (getStats now returns dashboard stats)
try {
userStats.value = await api.getStats();
} catch (error) {
console.error("Failed to load user dashboard stats:", error);
}
// Load presence data and start refresh
await loadPresenceData();
startPresenceRefresh();
} catch (error) {
console.error("Failed to load user data:", error);
}
}
async function loadApiKey() {
try {
apiKey.value = await invoke("get_api_key", { apiConfig: apiConfig.value });
} catch (error) {
console.error("Failed to load API key:", error);
}
}
async function registerPresenceConnection() {
try {
await invoke("register_presence_connection", { apiConfig: apiConfig.value });
console.log("Presence connection registered successfully");
} catch (error) {
console.error("Failed to register presence connection:", error);
}
}
async function loadApiConfig() {
try {
apiConfig.value = await invoke("get_api_config");
} catch (error) {
console.error("Failed to load API config:", error);
}
}
async function loadHackatimeInfo() {
try {
hackatimeDirectories.value = await invoke("get_hackatime_directories");
sessionStats.value = await invoke("get_session_stats");
} catch (error) {
console.error("Failed to load hackatime info:", error);
}
}
async function loadPresenceData() {
const now = Date.now();
if (presenceFetchInProgress.value) {
return; // Skip if a fetch is already in flight
}
if (now < nextPresenceFetchAllowedAt.value) {
return; // Respect backoff window
}
// Enforce hard minimum interval of 60s between network calls
if (now - lastPresenceFetchAt.value < 60_000) {
return;
}
presenceFetchInProgress.value = true;
try {
await api.initialize();
// Use the Rust backend's get_latest_heartbeat which includes session logic
presenceData.value = await invoke("get_latest_heartbeat", {
apiConfig: apiConfig.value
});
lastPresenceFetchAt.value = Date.now();
} catch (error: any) {
console.error("Failed to load presence data:", error);
// If we hit rate limit, back off for 60s
const message = error?.message || "";
if (typeof message === "string" && message.includes("429")) {
nextPresenceFetchAllowedAt.value = Date.now() + 60_000;
}
presenceData.value = null;
} finally {
presenceFetchInProgress.value = false;
}
}
function startPresenceRefresh() {
// Ensure only one interval is active
if (presenceRefreshInterval.value) {
clearInterval(presenceRefreshInterval.value);
presenceRefreshInterval.value = null;
}
// Refresh presence data every 60 seconds (1 minute)
presenceRefreshInterval.value = setInterval(loadPresenceData, 60000);
}
function stopPresenceRefresh() {
if (presenceRefreshInterval.value) {
clearInterval(presenceRefreshInterval.value);
presenceRefreshInterval.value = null;
}
}
async function authenticate() {
isLoading.value = true;
try {
await invoke("authenticate_with_rails", { apiConfig: apiConfig.value });
// Show instructions for manual token entry
alert(`Authentication opened in browser!\n\nAfter completing OAuth in the browser:\n1. Copy the temporary token from the URL\n2. Use the "Test Authentication" button below\n3. Enter the token when prompted\n\nFor development, you can also use the test button to simulate authentication.`);
} catch (error) {
console.error("Authentication failed:", error);
alert("Authentication failed: " + (error instanceof Error ? error.message : String(error)));
} finally {
isLoading.value = false;
}
}
async function logout() {
try {
stopPresenceRefresh();
await invoke("logout");
await loadAuthState();
} catch (error) {
console.error("Logout failed:", error);
}
}
async function saveApiConfig() {
try {
await invoke("set_api_config", { newConfig: apiConfig.value });
isConfigOpen.value = false;
} catch (error) {
console.error("Failed to save API config:", error);
alert("Failed to save API config: " + error);
}
}
async function copyApiKey() {
if (!apiKey.value) return;
try {
await navigator.clipboard.writeText(apiKey.value);
alert("API key copied to clipboard!");
} catch (error) {
console.error("Failed to copy API key:", error);
alert("Failed to copy API key to clipboard");
}
}
async function handleDirectOAuthAuth() {
if (!directOAuthToken.value.trim()) {
alert("Please enter an OAuth token");
return;
}
try {
isLoading.value = true;
console.log("Attempting direct OAuth auth with token:", directOAuthToken.value);
console.log("Token length:", directOAuthToken.value.length);
console.log("API config:", apiConfig.value);
// Authenticate with the direct OAuth token
await invoke("authenticate_with_direct_oauth", {
oauthToken: directOAuthToken.value,
apiConfig: apiConfig.value
});
console.log("Direct OAuth auth successful!");
await loadAuthState();
// Ensure the auth state is saved after successful authentication
if (authState.value.is_authenticated) {
try {
await invoke("save_auth_state", { authState: authState.value });
console.log("Auth state saved after direct OAuth authentication");
} catch (error) {
console.error("Failed to save auth state after direct OAuth:", error);
}
}
directOAuthToken.value = ""; // Clear the input
} catch (error) {
console.error("Direct OAuth auth failed:", error);
alert("Direct OAuth auth failed: " + error);
} finally {
isLoading.value = false;
}
}
function getPageTitle(): string {
switch (currentPage.value) {
case 'home':
return 'Home';
case 'projects':
return 'Projects';
case 'statistics':
return 'Statistics';
case 'settings':
return 'Settings';
default:
return 'Home';
}
}
</script>
<template>
<div class="flex h-screen text-text-primary font-sans outfit" style="background-color: #0A0101;">
<!-- Left Sidebar -->
<aside class="w-64 flex flex-col p-0 shadow-xl rounded-r-2xl" style="background-color: #191415;">
<div class="p-6" style="background-color: #191415;">
<h1 class="text-2xl font-bold text-accent-primary m-0 text-center">Hackatime</h1>
</div>
<nav class="flex-1 py-4">
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'home' }" @click.prevent="currentPage = 'home'" style="color: #B0BAC4;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
<span class="font-medium">Home</span>
</a>
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'projects' }" @click.prevent="currentPage = 'projects'" style="color: #B0BAC4;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
<span class="font-medium">Projects</span>
</a>
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'statistics' }" @click.prevent="currentPage = 'statistics'" style="color: #B0BAC4;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<span class="font-medium">Statistics</span>
</a>
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'settings' }" @click.prevent="currentPage = 'settings'" style="color: #B0BAC4;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="font-medium">Settings</span>
</a>
</nav>
<div class="p-6" style="background-color: #191415;">
<button v-if="authState.is_authenticated" @click="logout" class="flex items-center gap-3 w-full px-3 py-3 bg-transparent border border-accent-danger rounded-xl text-accent-danger cursor-pointer transition-all duration-200 text-sm hover:bg-accent-danger hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
</svg>
<span class="font-medium">Logout</span>
</button>
</div>
</aside>
<!-- Main Content Area -->
<main class="flex-1 p-6 overflow-y-auto">
<!-- Home Page Layout -->
<div v-if="currentPage === 'home'" class="flex flex-col h-full gap-6">
<!-- This Week Card -->
<div v-if="authState.is_authenticated && userStats" class="rounded-2xl shadow-card mb-6 p-6 flex flex-col" style="background-color: #191415;">
<!-- This Week Title -->
<h3 class="text-text-primary font-semibold text-lg mb-4">This Week</h3>
<div class="flex gap-8 flex-1 items-center mb-4">
<!-- Left Section - Streak & Hours Display (2/3 width) -->
<div class="flex justify-center items-center space-x-20" style="flex: 2;">
<!-- Streak Section -->
<div class="flex flex-col items-center">
<div class="relative">
<img src="/flame-icon.svg" alt="Streak" class="w-20 h-20" />
<div class="absolute inset-0 flex items-end justify-center pb-2">
<div class="text-white drop-shadow-lg font-bold" :class="{
'text-4xl': (userStats.current_streak || 0) < 10,
'text-3xl': (userStats.current_streak || 0) >= 10 && (userStats.current_streak || 0) < 100,
'text-2xl': (userStats.current_streak || 0) >= 100 && (userStats.current_streak || 0) < 1000,
'text-xl': (userStats.current_streak || 0) >= 1000
}">
{{ userStats.current_streak || 0 }}
</div>
</div>
</div>
<div class="text-center mt-2">
<div class="text-text-secondary text-xl font-semibold">day streak</div>
</div>
</div>
<!-- Hours Section -->
<div class="flex flex-col items-center">
<div class="text-4xl font-bold text-accent-primary">
{{ Math.round((userStats.weekly_stats?.time_coded_seconds || 0) / 3600 * 10) / 10 }}
</div>
<div class="text-center mt-3">
<div class="text-text-secondary text-xl font-semibold">hours this week</div>
</div>
</div>
</div>
<!-- Right Section - Weekly Chart.js Chart (1/3 width) -->
<div class="flex flex-col justify-center pl-6" style="flex: 1;">
<WeeklyChart :data="weeklyChartData" />
</div>
</div>
<!-- Trend Card -->
<div v-if="weeklyTrend" class="mt-4">
<TrendCard
:title="weeklyTrend.title"
:change="weeklyTrend.change"
:change-type="weeklyTrend.changeType"
:period="weeklyTrend.period"
:icon="weeklyTrend.icon"
/>
</div>
</div>
<!-- Current Session Card -->
<div v-if="authState.is_authenticated" class="mb-6">
<PresenceCard :authState="authState" :presenceData="presenceData" :apiConfig="apiConfig" />
</div>
<!-- Home Component -->
<Home
:authState="authState"
:apiConfig="apiConfig"
:userData="userData"
:userStats="userStats"
:isLoading="isLoading"
:isDevMode="isDevMode"
v-model:directOAuthToken="directOAuthToken"
@authenticate="authenticate"
@handleDirectOAuthAuth="handleDirectOAuthAuth"
/>
</div>
<!-- Statistics Page Layout (full page) -->
<div v-else-if="currentPage === 'statistics'" class="flex flex-col h-full">
<Statistics :apiConfig="apiConfig" />
</div>
<!-- Other Pages Layout (single card) -->
<div v-else class="flex flex-col h-full">
<div class="bg-bg-card border border-border-primary rounded-2xl overflow-hidden shadow-card flex flex-col min-h-96">
<div class="flex justify-between items-center px-6 py-5 border-b border-border-primary bg-bg-card-tertiary">
<h2 class="m-0 text-xl font-semibold text-text-primary">{{ getPageTitle() }}</h2>
</div>
<div class="p-6 flex-1 overflow-y-auto">
<Projects v-if="currentPage === 'projects'" :currentTheme="currentTheme" :toggleTheme="toggleTheme" :apiConfig="apiConfig" />
<Settings v-if="currentPage === 'settings'" :currentTheme="currentTheme" :toggleTheme="toggleTheme" :apiKey="apiKey" :showApiKey="showApiKey" @copyApiKey="copyApiKey" />
</div>
</div>
</div>
</main>
<!-- Configuration Modal -->
<div v-if="isConfigOpen" class="fixed inset-0 bg-black/70 flex justify-center items-center z-50" @click="isConfigOpen = false">
<div class="bg-bg-card border border-border-primary p-8 rounded-2xl shadow-secondary max-w-md w-11/12" @click.stop>
<h3 class="mt-0 text-text-primary mb-6 text-lg font-semibold">API Configuration</h3>
<div class="my-4">
<label for="api-url" class="block mb-2 font-medium text-text-primary">API Base URL:</label>
<input
id="api-url"
v-model="apiConfig.base_url"
type="url"
placeholder="http://localhost:3000"
class="w-full p-3 bg-bg-secondary border border-border-secondary rounded-xl text-text-primary text-base box-border focus:outline-none focus:border-accent-primary focus:shadow-[0_0_0_2px_rgba(200,57,79,0.2)]"
/>
</div>
<div class="flex gap-4 justify-end mt-6">
<button @click="isConfigOpen = false" class="px-6 py-3 rounded-xl cursor-pointer text-base font-medium transition-all duration-200 bg-transparent text-text-secondary border border-border-secondary hover:bg-bg-secondary hover:text-text-primary hover:border-border-primary">Cancel</button>
<button @click="saveApiConfig" class="px-6 py-3 rounded-xl cursor-pointer text-base font-medium transition-all duration-200 bg-accent-primary text-white border-0 hover:bg-accent-secondary hover:shadow-card-hover">Save</button>
</div>
</div>
</div>
</div>
</template>
<!-- All styles now handled by Tailwind CSS -->

160
src/api.ts Normal file
View file

@ -0,0 +1,160 @@
import { invoke } from "@tauri-apps/api/core";
interface ApiConfig {
base_url: string;
}
interface AuthState {
is_authenticated: boolean;
access_token: string | null;
user_info: Record<string, any> | null;
}
export class KubeTimeApi {
private baseUrl: string = "http://localhost:3000";
private accessToken: string | null = null;
private latestPresenceCache: { data: any | null; fetchedAt: number } = { data: null, fetchedAt: 0 };
async initialize() {
try {
const config: ApiConfig = await invoke("get_api_config");
this.baseUrl = config.base_url;
const authState: AuthState = await invoke("get_auth_state");
this.accessToken = authState.access_token;
} catch (error) {
console.error("Failed to initialize API:", error);
}
}
async getCurrentUser() {
if (!this.accessToken) {
throw new Error("Not authenticated");
}
try {
const response = await fetch(`${this.baseUrl}/api/v1/authenticated/me`, {
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch current user:", error);
throw error;
}
}
async getStats() {
if (!this.accessToken) {
throw new Error("Not authenticated");
}
try {
// Call the current user's dashboard stats endpoint
const response = await fetch(`${this.baseUrl}/api/v1/authenticated/dashboard_stats`, {
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch stats:", error);
throw error;
}
}
async getMyHeartbeats() {
if (!this.accessToken) {
throw new Error("Not authenticated");
}
try {
const response = await fetch(`${this.baseUrl}/api/v1/my/heartbeats`, {
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch heartbeats:", error);
throw error;
}
}
async getUserDashboardStats(username: string) {
if (!this.accessToken) {
throw new Error("Not authenticated");
}
try {
const response = await fetch(`${this.baseUrl}/api/v1/users/${username}/dashboard_stats`, {
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch user dashboard stats:", error);
throw error;
}
}
async getCurrentPresence() {
if (!this.accessToken) {
throw new Error("Not authenticated");
}
try {
// Return cached result if fetched within last 60s
const now = Date.now();
if (now - this.latestPresenceCache.fetchedAt < 60_000 && this.latestPresenceCache.data !== null) {
return this.latestPresenceCache.data;
}
const response = await fetch(`${this.baseUrl}/api/v1/presence/latest_heartbeat`, {
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
this.latestPresenceCache = { data: json, fetchedAt: Date.now() };
return json;
} catch (error) {
console.error("Failed to fetch current presence:", error);
throw error;
}
}
}
export const api = new KubeTimeApi();

BIN
src/assets/vue.svg (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,147 @@
<template>
<div class="bg-bg-card border border-border-primary rounded-2xl p-6 shadow-card">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
<div class="text-sm text-text-secondary">{{ period }}</div>
</div>
<div class="h-64">
<canvas ref="chartCanvas"></canvas>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler,
BarController,
LineController,
DoughnutController,
PieController
} from 'chart.js';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler,
BarController,
LineController,
DoughnutController,
PieController
);
interface Props {
title: string;
chartType: string;
data: any;
period: string;
colorScheme: string;
}
const props = defineProps<Props>();
const chartCanvas = ref<HTMLCanvasElement | null>(null);
let chartInstance: ChartJS | null = null;
const createChart = () => {
if (!chartCanvas.value) return;
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy();
}
const ctx = chartCanvas.value.getContext('2d');
if (!ctx) return;
const config = {
type: props.chartType,
data: props.data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: props.chartType === 'doughnut' || props.chartType === 'pie',
position: 'bottom' as const,
labels: {
color: '#B0BAC4',
font: {
family: 'Inter, system-ui, sans-serif'
}
}
},
tooltip: {
backgroundColor: '#191415',
titleColor: '#FFFFFF',
bodyColor: '#B0BAC4',
borderColor: '#FB4B20',
borderWidth: 1,
cornerRadius: 8,
displayColors: true
}
},
scales: props.chartType !== 'doughnut' && props.chartType !== 'pie' ? {
x: {
grid: {
color: '#2A2A2A',
drawBorder: false
},
ticks: {
color: '#B0BAC4',
font: {
family: 'Inter, system-ui, sans-serif'
}
}
},
y: {
grid: {
color: '#2A2A2A',
drawBorder: false
},
ticks: {
color: '#B0BAC4',
font: {
family: 'Inter, system-ui, sans-serif'
}
}
}
} : undefined
}
};
chartInstance = new ChartJS(ctx, config);
};
onMounted(() => {
createChart();
});
onUnmounted(() => {
if (chartInstance) {
chartInstance.destroy();
}
});
watch(() => props.data, () => {
createChart();
}, { deep: true });
</script>

View file

@ -0,0 +1,28 @@
<template>
<div class="bg-bg-card border border-border-primary rounded-2xl p-6 shadow-card">
<div class="flex items-start space-x-4">
<div class="text-3xl">{{ icon }}</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-text-primary mb-2">{{ title }}</h3>
<p class="text-text-secondary mb-3">{{ description }}</p>
<div class="flex items-center justify-between">
<div class="text-2xl font-bold" :style="{ color: color }">{{ value }}</div>
<div class="text-sm text-text-secondary">{{ trend }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string;
description: string;
value: string;
trend: string;
icon: string;
color: string;
}
defineProps<Props>();
</script>

View file

@ -0,0 +1,189 @@
<template>
<div class="rounded-2xl shadow-card p-6" style="background-color: #191415;">
<!-- Presence Title -->
<h3 class="text-text-primary font-semibold text-lg mb-4">Current Session</h3>
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-8">
<div class="w-16 h-16 bg-bg-secondary rounded-full flex items-center justify-center mx-auto mb-4">
<div class="w-6 h-6 border-2 border-text-secondary border-t-transparent rounded-full animate-spin"></div>
</div>
<div class="text-text-secondary text-lg font-medium mb-2">Loading session data...</div>
</div>
<!-- Active Session Display -->
<div v-else-if="sessionState.is_active" class="space-y-4">
<!-- Project and Editor Info -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
<div>
<div class="text-text-primary font-medium text-lg">
{{ sessionState.project || 'Unknown Project' }}
</div>
<div class="text-text-secondary text-sm">
{{ sessionState.editor || 'Unknown Editor' }}
</div>
</div>
</div>
<div class="text-right">
<div class="text-text-secondary text-sm">Language</div>
<div class="text-text-primary font-medium">
{{ sessionState.language || 'Unknown' }}
</div>
</div>
</div>
<!-- File being worked on -->
<div v-if="sessionState.entity" class="bg-bg-secondary rounded-lg p-3">
<div class="text-text-secondary text-xs mb-1">Currently editing</div>
<div class="text-text-primary font-mono text-sm truncate">
{{ sessionState.entity }}
</div>
</div>
<!-- Session duration -->
<div class="flex items-center justify-between text-sm">
<div class="text-text-secondary">Session started</div>
<div class="text-text-primary font-medium">
{{ formatTime(sessionState.start_time) }}
</div>
</div>
<!-- Heartbeat count -->
<div class="flex items-center justify-between text-sm">
<div class="text-text-secondary">Heartbeats</div>
<div class="text-text-primary font-medium">
{{ sessionState.heartbeat_count }}
</div>
</div>
</div>
<!-- No active session -->
<div v-else class="text-center py-8">
<div class="w-16 h-16 bg-bg-secondary rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
</svg>
</div>
<div class="text-text-secondary text-lg font-medium mb-2">No active coding session</div>
<div class="text-text-secondary text-sm">Start coding in your editor to see your current session here</div>
<div class="text-text-secondary text-xs mt-2 opacity-75">Make sure your editor has the WakaTime plugin installed and configured</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
interface AuthState {
is_authenticated: boolean;
access_token: string | null;
user_info: Record<string, any> | null;
}
interface SessionState {
is_active: boolean;
start_time: number | null;
last_heartbeat_id: number | null;
heartbeat_count: number;
project: string | null;
editor: string | null;
language: string | null;
entity: string | null;
}
const props = defineProps<{
authState: AuthState;
presenceData: any;
apiConfig: any;
}>();
const sessionState = ref<SessionState>({
is_active: false,
start_time: null,
last_heartbeat_id: null,
heartbeat_count: 0,
project: null,
editor: null,
language: null,
entity: null,
});
const isLoading = ref(true);
let sessionRefreshInterval: number | null = null;
// Format timestamp to relative time
function formatTime(timestamp: number | null): string {
if (!timestamp) return 'Unknown';
const now = Math.floor(Date.now() / 1000);
const diff = now - timestamp;
if (diff < 60) {
return 'Just now';
} else if (diff < 3600) {
const minutes = Math.floor(diff / 60);
return `${minutes}m ago`;
} else if (diff < 86400) {
const hours = Math.floor(diff / 3600);
return `${hours}h ago`;
} else {
const days = Math.floor(diff / 86400);
return `${days}d ago`;
}
}
async function loadSessionState() {
if (!props.authState.is_authenticated) {
isLoading.value = false;
return;
}
try {
// Get the current session state
const session = await invoke("get_current_session");
console.log("Session state loaded:", session);
sessionState.value = session as SessionState;
isLoading.value = false;
} catch (error) {
console.error("Failed to load session state:", error);
isLoading.value = false;
}
}
function startSessionRefresh() {
if (sessionRefreshInterval) {
clearInterval(sessionRefreshInterval);
}
// Refresh session state every 10 seconds
sessionRefreshInterval = setInterval(loadSessionState, 10000);
}
function stopSessionRefresh() {
if (sessionRefreshInterval) {
clearInterval(sessionRefreshInterval);
sessionRefreshInterval = null;
}
}
// Watch for changes in presence data to update session state
watch(() => props.presenceData, () => {
if (props.authState.is_authenticated) {
loadSessionState();
}
}, { deep: true });
onMounted(() => {
loadSessionState();
startSessionRefresh();
});
onUnmounted(() => {
stopSessionRefresh();
});
</script>
<!-- All styles now handled by Tailwind CSS -->

View file

@ -0,0 +1,53 @@
<template>
<div class="bg-bg-card border border-border-primary rounded-2xl p-6 shadow-card">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
<div class="text-2xl">{{ icon }}</div>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<div class="text-3xl font-bold text-text-primary">{{ value }}</div>
<div class="text-sm text-text-secondary">{{ period }}</div>
</div>
<div class="text-right">
<div
class="text-sm font-medium px-3 py-1 rounded-full"
:class="changeClass"
>
{{ change }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
title: string;
value: string;
change: string;
changeType: string;
period: string;
icon: string;
color: string;
}
const props = defineProps<Props>();
const changeClass = computed(() => {
switch (props.changeType) {
case 'increase':
return 'bg-green-100 text-green-800';
case 'decrease':
return 'bg-red-100 text-red-800';
case 'neutral':
default:
return 'bg-gray-100 text-gray-800';
}
});
</script>

View file

@ -0,0 +1,195 @@
<template>
<div v-if="statisticsData" class="space-y-6">
<!-- Trends Section -->
<div>
<h2 class="text-xl font-semibold text-text-primary mb-4">Trends</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatisticsCard
v-for="trend in statisticsData.trends"
:key="trend.title"
:title="trend.title"
:value="trend.value"
:change="trend.change"
:change-type="trend.change_type"
:period="trend.period"
:icon="trend.icon"
:color="trend.color"
/>
</div>
</div>
<!-- Charts Section -->
<div>
<h2 class="text-xl font-semibold text-text-primary mb-4">Analytics</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ChartComponent
v-for="chart in statisticsData.charts"
:key="chart.id"
:title="chart.title"
:chart-type="chart.chart_type"
:data="chart.data"
:period="chart.period"
:color-scheme="chart.color_scheme"
/>
</div>
</div>
<!-- Programmer Class Section -->
<div>
<h2 class="text-xl font-semibold text-text-primary mb-4">Your Programmer Class</h2>
<div class="bg-bg-card border border-border-primary rounded-2xl p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-2xl font-bold text-text-primary">{{ statisticsData.programmer_class.class_name }}</h3>
<p class="text-text-secondary">{{ statisticsData.programmer_class.description }}</p>
</div>
<div class="text-right">
<div class="text-sm font-semibold px-3 py-1 rounded-full" :style="{ backgroundColor: statisticsData.programmer_class.color + '20', color: statisticsData.programmer_class.color }">
{{ statisticsData.programmer_class.level }}
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="tech in statisticsData.programmer_class.technologies"
:key="tech"
class="px-3 py-1 bg-bg-secondary text-text-primary rounded-lg text-sm font-medium"
>
{{ tech }}
</span>
</div>
</div>
</div>
<!-- Insights Section -->
<div>
<h2 class="text-xl font-semibold text-text-primary mb-4">Insights</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<InsightCard
v-for="insight in statisticsData.insights"
:key="insight.title"
:title="insight.title"
:description="insight.description"
:value="insight.value"
:trend="insight.trend"
:icon="insight.icon"
:color="insight.color"
/>
</div>
</div>
</div>
<!-- Loading State -->
<div v-else-if="isLoading" class="space-y-6">
<div class="animate-pulse">
<div class="h-6 bg-bg-secondary rounded w-32 mb-4"></div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i" class="bg-bg-card border border-border-primary rounded-2xl p-6">
<div class="h-4 bg-bg-secondary rounded w-24 mb-4"></div>
<div class="h-8 bg-bg-secondary rounded w-16 mb-2"></div>
<div class="h-3 bg-bg-secondary rounded w-20"></div>
</div>
</div>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-2xl p-6">
<div class="flex items-center">
<div class="text-red-500 text-xl mr-3"></div>
<div>
<h3 class="text-red-800 font-semibold">Failed to load statistics</h3>
<p class="text-red-600 text-sm">{{ error }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import StatisticsCard from './StatisticsCard.vue';
import ChartComponent from './ChartComponent.vue';
import InsightCard from './InsightCard.vue';
interface StatisticsData {
trends: Array<{
title: string;
value: string;
change: string;
change_type: string;
period: string;
icon: string;
color: string;
}>;
charts: Array<{
id: string;
title: string;
chart_type: string;
data: any;
period: string;
color_scheme: string;
}>;
insights: Array<{
title: string;
description: string;
value: string;
trend: string;
icon: string;
color: string;
}>;
programmer_class: {
class_name: string;
description: string;
technologies: string[];
level: string;
color: string;
};
}
interface Props {
apiConfig: {
base_url: string;
};
}
const props = defineProps<Props>();
const statisticsData = ref<StatisticsData | null>(null);
const isLoading = ref(false);
const error = ref<string | null>(null);
const loadStatistics = async () => {
isLoading.value = true;
error.value = null;
try {
const data = await invoke<StatisticsData>('get_statistics_data', {
apiConfig: props.apiConfig
});
statisticsData.value = data;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to load statistics:', err);
// Set some default data to prevent crashes
statisticsData.value = {
trends: [],
charts: [],
insights: [],
programmer_class: {
class_name: "Future Coder",
description: "Ready to embark on an exciting journey into the world of programming.",
technologies: ["HTML", "CSS", "JavaScript"],
level: "Beginner",
color: "#607D8B"
}
};
} finally {
isLoading.value = false;
}
};
onMounted(() => {
loadStatistics();
});
</script>

View file

@ -0,0 +1,64 @@
<template>
<div class="rounded-xl p-4 flex items-center space-x-3 relative overflow-hidden" :style="cardStyle">
<div class="text-2xl">{{ icon }}</div>
<div class="flex-1">
<div class="text-sm text-white font-medium">{{ title }}</div>
<div class="text-xs text-white/70">{{ period }}</div>
</div>
<div class="text-right">
<div
class="text-2xl font-bold px-3 py-2 rounded-lg"
:class="changeClass"
>
{{ change }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
title: string;
change: string;
changeType: string;
period: string;
icon: string;
}
const props = defineProps<Props>();
const cardStyle = computed(() => {
switch (props.changeType) {
case 'increase':
return {
background: 'linear-gradient(135deg, #FB4C21 0%, #FF6B35 100%)',
border: '1px solid #FB4C21'
};
case 'decrease':
return {
background: 'linear-gradient(135deg, #4568DC 0%, #5A7BE8 100%)',
border: '1px solid #4568DC'
};
case 'neutral':
default:
return {
background: 'linear-gradient(135deg, #6B7280 0%, #9CA3AF 100%)',
border: '1px solid #6B7280'
};
}
});
const changeClass = computed(() => {
switch (props.changeType) {
case 'increase':
return 'bg-white/20 text-white';
case 'decrease':
return 'bg-white/20 text-white';
case 'neutral':
default:
return 'bg-white/20 text-white';
}
});
</script>

View file

@ -0,0 +1,145 @@
<template>
<div class="h-32">
<canvas ref="chartCanvas"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
BarController,
Tooltip,
Legend
} from 'chart.js';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
BarController,
Tooltip,
Legend
);
interface Props {
data: Array<{
date: string;
day_name: string;
hours: number;
percentage: number;
}>;
}
const props = defineProps<Props>();
const chartCanvas = ref<HTMLCanvasElement | null>(null);
let chartInstance: ChartJS | null = null;
const createChart = () => {
if (!chartCanvas.value) return;
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy();
}
const ctx = chartCanvas.value.getContext('2d');
if (!ctx) return;
// Prepare data for Chart.js
const labels = props.data.map(day => day.day_name);
const chartData = props.data.map(day => day.hours);
// Calculate colors for each bar
const maxHours = Math.max(...chartData, 1);
const colors = chartData.map(hours => {
if (hours === 0) return '#3d2b2e';
const intensity = hours / maxHours;
const startColor = { r: 237, g: 141, b: 75 }; // #ED8D4B (lighter)
const endColor = { r: 251, g: 75, b: 32 }; // #FB4B20 (darker)
const r = Math.round(startColor.r + (endColor.r - startColor.r) * intensity);
const g = Math.round(startColor.g + (endColor.g - startColor.g) * intensity);
const b = Math.round(startColor.b + (endColor.b - startColor.b) * intensity);
return `rgb(${r}, ${g}, ${b})`;
});
const config = {
type: 'bar' as const,
data: {
labels: labels,
datasets: [{
data: chartData,
backgroundColor: colors,
borderColor: colors,
borderWidth: 0,
borderRadius: 4,
borderSkipped: false,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: '#191415',
titleColor: '#FFFFFF',
bodyColor: '#B0BAC4',
borderColor: '#FB4B20',
borderWidth: 1,
cornerRadius: 8,
displayColors: false,
callbacks: {
label: function(context: any) {
return `${context.parsed.y}h`;
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false
},
ticks: {
color: '#B0BAC4',
font: {
size: 10,
family: 'Inter, system-ui, sans-serif'
}
}
},
y: {
display: false
}
}
}
};
chartInstance = new ChartJS(ctx, config);
};
onMounted(() => {
createChart();
});
onUnmounted(() => {
if (chartInstance) {
chartInstance.destroy();
}
});
watch(() => props.data, () => {
createChart();
}, { deep: true });
</script>

View file

@ -0,0 +1,36 @@
import { ref, onMounted } from 'vue'
export type Theme = 'dark' | 'light'
const currentTheme = ref<Theme>('dark')
export function useTheme() {
const setTheme = (theme: Theme) => {
currentTheme.value = theme
document.documentElement.className = theme
localStorage.setItem('theme', theme)
}
const toggleTheme = () => {
const newTheme = currentTheme.value === 'dark' ? 'light' : 'dark'
setTheme(newTheme)
}
const initTheme = () => {
// Check localStorage first, then default to dark
const savedTheme = localStorage.getItem('theme') as Theme
const theme = savedTheme || 'dark'
setTheme(theme)
}
onMounted(() => {
initTheme()
})
return {
currentTheme,
setTheme,
toggleTheme,
initTheme
}
}

5
src/main.ts Normal file
View file

@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");

175
src/style.css Normal file
View file

@ -0,0 +1,175 @@
@import "tailwindcss";
/* CSS Variables for Theme System */
:root {
/* Dark theme (default) */
--bg-primary: #2a1f21;
--bg-secondary: #3d2b2e;
--bg-tertiary: #4a2d31;
--bg-card: #3d2b2e;
--bg-card-secondary: #4a2d31;
--bg-card-tertiary: #1f1617;
--bg-sidebar: #2a1f21;
--text-primary: #f5e6e8;
--text-secondary: #f5e6e8;
--text-muted: #a0a0a0;
--accent-primary: #ff7a8a;
--accent-secondary: #c8394f;
--accent-danger: #ec3750;
--accent-warning: #f1c40f;
--accent-info: #33d6a6;
--border-primary: #3d2b2e;
--border-secondary: #4a2d31;
}
.light {
/* Light theme */
--bg-primary: #fdf7f8;
--bg-secondary: #ffffff;
--bg-tertiary: #f8e8ea;
--bg-card: #ffffff;
--bg-card-secondary: #f8e8ea;
--bg-card-tertiary: #ffffff;
--bg-sidebar: #fdf7f8;
--text-primary: #5d3a3f;
--text-secondary: #5d3a3f;
--text-muted: #8b2635;
--accent-primary: #c8394f;
--accent-secondary: #a12d3e;
--accent-danger: #ec3750;
--accent-warning: #ff8c37;
--accent-info: #33d6a6;
--border-primary: #f8e8ea;
--border-secondary: #ffffff;
}
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-sidebar);
}
::-webkit-scrollbar-thumb {
background: var(--border-primary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-secondary);
}
/* Selection styling */
::selection {
background: rgba(200, 57, 79, 0.3);
color: var(--text-primary);
}
/* Focus styles */
*:focus {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
/* Global styles */
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
font-family: "Outfit", sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
transition: background-color 0.3s ease, color 0.3s ease;
}
#app {
height: 100vh;
width: 100vw;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
margin: 0;
font-weight: 600;
line-height: 1.2;
}
p {
margin: 0;
line-height: 1.5;
}
/* Button and input reset */
button, input, textarea, select {
font-family: inherit;
font-size: inherit;
}
/* Link styles */
a {
color: var(--accent-primary);
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: var(--accent-secondary);
}
/* Smooth transitions for theme changes */
* {
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
/* Outfit font class as per Google Fonts documentation */
.outfit {
font-family: "Outfit", sans-serif;
font-optical-sizing: auto;
font-style: normal;
}
/* Outfit font weight utilities */
.outfit-light {
font-family: "Outfit", sans-serif;
font-optical-sizing: auto;
font-weight: 300;
font-style: normal;
}
.outfit-normal {
font-family: "Outfit", sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
}
.outfit-medium {
font-family: "Outfit", sans-serif;
font-optical-sizing: auto;
font-weight: 500;
font-style: normal;
}
.outfit-semibold {
font-family: "Outfit", sans-serif;
font-optical-sizing: auto;
font-weight: 600;
font-style: normal;
}
.outfit-bold {
font-family: "Outfit", sans-serif;
font-optical-sizing: auto;
font-weight: 700;
font-style: normal;
}

104
src/views/Home.vue Normal file
View file

@ -0,0 +1,104 @@
<template>
<!-- Authentication Section -->
<div v-if="!authState.is_authenticated" class="flex items-center justify-center min-h-96">
<div class="text-center max-w-md">
<h3 class="text-2xl mb-4 text-text-primary">Welcome to Hackatime</h3>
<p class="text-text-secondary mb-8 leading-relaxed">Connect to your KubeTime account to start tracking your coding time.</p>
<!-- Production authentication (deep link) -->
<template v-if="!isDevMode">
<button
@click="authenticate"
:disabled="isLoading"
class="bg-accent-primary text-white border-0 px-8 py-4 rounded-xl text-base font-medium cursor-pointer transition-all duration-200 my-4 w-full hover:bg-accent-secondary hover:shadow-card-hover disabled:bg-text-muted disabled:cursor-not-allowed disabled:transform-none"
>
{{ isLoading ? 'Opening Login...' : 'Login with KubeTime' }}
</button>
<p class="text-text-secondary text-sm mt-2">This will open your browser for OAuth authentication.</p>
</template>
<!-- Development authentication options -->
<template v-else>
<div class="browser-auth-section">
<button
@click="authenticate"
:disabled="isLoading"
class="bg-accent-primary text-white border-0 px-8 py-4 rounded-xl text-base font-medium cursor-pointer transition-all duration-200 my-4 w-full hover:bg-accent-secondary hover:shadow-card-hover disabled:bg-text-muted disabled:cursor-not-allowed disabled:transform-none"
>
{{ isLoading ? 'Opening Login...' : 'Open Browser Login' }}
</button>
<p class="text-text-secondary text-sm mt-2">This will open your browser for OAuth authentication.</p>
</div>
<div class="mt-8 pt-8 border-t border-border-primary text-left">
<h4 class="text-text-primary mb-2 text-lg">Or paste your token directly</h4>
<p class="text-text-secondary mb-4 text-sm">Paste your token from the browser callback URL:</p>
<div class="flex gap-3 mb-4">
<input
:value="directOAuthToken"
@input="$emit('update:directOAuthToken', ($event.target as HTMLInputElement).value)"
type="text"
placeholder="Paste your token here..."
class="flex-1 p-3 bg-bg-secondary border border-border-secondary rounded-xl text-text-primary font-mono text-sm focus:outline-none focus:border-accent-primary focus:shadow-[0_0_0_2px_rgba(200,57,79,0.2)]"
@keyup.enter="handleDirectOAuthAuth"
/>
<button
@click="handleDirectOAuthAuth"
:disabled="isLoading || !directOAuthToken.trim()"
class="bg-accent-primary text-white border-0 px-6 py-3 rounded-xl text-sm font-medium cursor-pointer whitespace-nowrap transition-all duration-200 hover:bg-accent-secondary disabled:bg-text-muted disabled:cursor-not-allowed"
>
{{ isLoading ? 'Authenticating...' : 'Authenticate' }}
</button>
</div>
</div>
</template>
</div>
</div>
<!-- Fallback when authenticated but no user stats available -->
<div v-else-if="!userStats" class="mb-8">
<div class="bg-bg-secondary p-6 rounded-xl border border-border-primary text-center">
<h4 class="text-text-primary mb-2 text-lg">No Stats Available</h4>
<p class="text-text-secondary">Start coding to see your statistics here!</p>
</div>
</div>
</template>
<script setup lang="ts">
interface AuthState {
is_authenticated: boolean;
access_token: string | null;
user_info: Record<string, any> | null;
}
interface ApiConfig {
base_url: string;
}
defineProps<{
authState: AuthState;
apiConfig: ApiConfig;
userData: any;
userStats: any;
isLoading: boolean;
isDevMode: boolean;
directOAuthToken: string;
}>();
const emit = defineEmits<{
authenticate: [];
handleDirectOAuthAuth: [];
'update:directOAuthToken': [value: string];
}>();
async function authenticate() {
emit('authenticate');
}
async function handleDirectOAuthAuth() {
emit('handleDirectOAuthAuth');
}
</script>
<!-- All styles now handled by Tailwind CSS -->

206
src/views/Projects.vue Normal file
View file

@ -0,0 +1,206 @@
<template>
<div class="min-h-72">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center h-64">
<div class="flex items-center gap-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-primary"></div>
<p class="text-text-secondary">Loading projects...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="flex items-center justify-center h-64">
<div class="text-center">
<p class="text-accent-danger mb-4">{{ error }}</p>
<button
@click="loadProjects"
class="px-4 py-2 bg-accent-primary text-white rounded-lg hover:bg-accent-secondary transition-colors"
>
Retry
</button>
</div>
</div>
<!-- Projects List -->
<div v-else-if="projects && projects.length > 0" class="space-y-4">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-semibold text-text-primary">Your Projects</h3>
<div class="text-sm text-text-secondary">
{{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }}
</div>
</div>
<div class="grid gap-4">
<div
v-for="project in projects"
:key="project.name"
class="bg-bg-secondary border border-border-primary rounded-xl p-4 hover:border-accent-primary transition-colors cursor-pointer"
@click="selectProject(project)"
>
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<h4 class="text-text-primary font-medium text-lg mb-1">{{ project.name }}</h4>
<div class="flex items-center gap-4 text-sm text-text-secondary">
<span>{{ project.total_heartbeats }} heartbeats</span>
<span>{{ formatDuration(project.total_seconds) }}</span>
<span v-if="project.recent_activity_seconds > 0" class="text-accent-primary">
Active recently
</span>
</div>
</div>
<div class="text-right">
<div class="text-lg font-semibold text-accent-primary">
{{ (project.total_seconds / 3600).toFixed(1) }}h
</div>
</div>
</div>
<!-- Languages and Editors -->
<div class="flex flex-wrap gap-2 mb-3">
<span
v-for="language in project.languages.slice(0, 3)"
:key="language"
class="px-2 py-1 bg-bg-tertiary text-text-primary text-xs rounded-md"
>
{{ language }}
</span>
<span
v-if="project.languages.length > 3"
class="px-2 py-1 bg-bg-tertiary text-text-secondary text-xs rounded-md"
>
+{{ project.languages.length - 3 }} more
</span>
</div>
<!-- Time Range -->
<div class="text-xs text-text-secondary">
<span v-if="project.first_heartbeat">
First: {{ formatDate(project.first_heartbeat) }}
</span>
<span v-if="project.last_heartbeat" class="ml-4">
Last: {{ formatDate(project.last_heartbeat) }}
</span>
</div>
<!-- Repo Link -->
<div v-if="project.repo_url" class="mt-2">
<a
:href="project.repo_url"
target="_blank"
rel="noopener noreferrer"
class="text-accent-primary text-sm hover:underline"
@click.stop
>
View Repository
</a>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex items-center justify-center h-64">
<div class="text-center">
<p class="text-text-secondary mb-4">No projects found</p>
<p class="text-sm text-text-secondary">
Start coding to see your projects appear here!
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
interface Project {
name: string;
total_seconds: number;
total_heartbeats: number;
languages: string[];
editors: string[];
first_heartbeat: string | null;
last_heartbeat: string | null;
repo_url: string | null;
recent_activity_seconds: number;
recent_activity_formatted: string;
}
interface ProjectsResponse {
projects: Project[];
total_count: number;
time_range: {
since: string;
until: string;
};
}
const projects = ref<Project[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
// Props
const props = defineProps<{
currentTheme: string;
toggleTheme: () => void;
apiConfig: {
base_url: string;
};
}>();
// Load projects data
async function loadProjects() {
isLoading.value = true;
error.value = null;
try {
const response = await invoke("get_projects", {
apiConfig: props.apiConfig
}) as ProjectsResponse;
projects.value = response.projects || [];
} catch (err) {
console.error("Failed to load projects:", err);
error.value = err instanceof Error ? err.message : "Failed to load projects";
} finally {
isLoading.value = false;
}
}
// Select a project (for future detailed view)
function selectProject(project: Project) {
console.log("Selected project:", project.name);
// TODO: Implement project details view
}
// Format duration helper
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return "0m";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}
// Format date helper
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
// Load projects on mount
onMounted(() => {
loadProjects();
});
</script>
<!-- All styles now handled by Tailwind CSS -->

196
src/views/Settings.vue Normal file
View file

@ -0,0 +1,196 @@
<template>
<div class="min-h-72">
<div class="space-y-8">
<!-- Theme Settings -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-text-primary">Appearance</h3>
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Theme</h4>
<p class="text-sm text-text-secondary">Choose between dark and light mode</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-text-secondary">{{ currentTheme === 'dark' ? 'Dark' : 'Light' }}</span>
<button
@click="toggleTheme"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2"
:class="currentTheme === 'dark' ? 'bg-accent-primary' : 'bg-border-primary'"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="currentTheme === 'dark' ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</div>
</div>
<!-- App Information -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-text-primary">About</h3>
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-text-secondary">Version</span>
<span class="text-text-primary font-medium">1.0.0</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Build</span>
<span class="text-text-primary font-medium">Development</span>
</div>
</div>
</div>
</div>
<!-- API Key -->
<div v-if="apiKey" class="space-y-4">
<h3 class="text-lg font-semibold text-text-primary">API Access</h3>
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
<div class="space-y-4">
<div>
<h4 class="font-medium text-text-primary mb-2">Your API Key</h4>
<p class="text-sm text-text-secondary mb-4">Use this key to authenticate with the KubeTime API</p>
<div class="flex gap-3 items-center">
<input
:type="showApiKey ? 'text' : 'password'"
:value="apiKey"
readonly
class="flex-1 p-3 bg-bg-tertiary border border-border-secondary rounded-xl text-text-primary font-mono text-sm"
/>
<div class="flex gap-2">
<button @click="$emit('update:showApiKey', !showApiKey)" class="p-3 border border-border-secondary rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-bg-tertiary text-text-secondary hover:bg-bg-primary hover:text-text-primary hover:border-accent-primary">
<svg v-if="showApiKey" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464M9.878 9.878L12 12m-2.122-2.122l1.415 1.415M12 12l2.122 2.122m-2.122-2.122L12 12m2.122 2.122l-1.415-1.415M12 12l-2.122-2.122"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
<button @click="copyApiKey" class="p-3 border border-accent-info rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-accent-info text-white hover:bg-blue-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Preferences -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-text-primary">Preferences</h3>
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Auto-start</h4>
<p class="text-sm text-text-secondary">Start with system</p>
</div>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full bg-border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white translate-x-1 transition-transform" />
</button>
</div>
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Discord RPC</h4>
<p class="text-sm text-text-secondary">Show coding activity in Discord</p>
</div>
<button
@click="toggleDiscordRpc"
:disabled="isLoading"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
:class="discordRpcEnabled ? 'bg-accent-primary' : 'bg-border-primary'"
>
<span
v-if="isLoading"
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform animate-pulse"
:class="discordRpcEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
<span
v-else
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="discordRpcEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Notifications</h4>
<p class="text-sm text-text-secondary">Show desktop notifications</p>
</div>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full bg-accent-primary transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white translate-x-6 transition-transform" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import type { Theme } from '../composables/useTheme'
defineProps<{
currentTheme: Theme
toggleTheme: () => void
apiKey: string | null
showApiKey: boolean
}>()
const emit = defineEmits<{
copyApiKey: []
'update:showApiKey': [value: boolean]
}>()
const discordRpcEnabled = ref(false);
const isLoading = ref(false);
async function loadDiscordRpcState() {
try {
discordRpcEnabled.value = await invoke("get_discord_rpc_enabled");
} catch (error) {
console.error("Failed to load Discord RPC state:", error);
}
}
async function toggleDiscordRpc() {
if (isLoading.value) return;
isLoading.value = true;
try {
const newState = !discordRpcEnabled.value;
await invoke("set_discord_rpc_enabled", { enabled: newState });
discordRpcEnabled.value = newState;
} catch (error) {
console.error("Failed to toggle Discord RPC:", error);
// Revert the UI state on error
discordRpcEnabled.value = !discordRpcEnabled.value;
} finally {
isLoading.value = false;
}
}
function copyApiKey() {
emit('copyApiKey')
}
// Load Discord RPC state on mount
onMounted(() => {
loadDiscordRpcState();
});
</script>
<!-- All styles now handled by Tailwind CSS -->

23
src/views/Statistics.vue Normal file
View file

@ -0,0 +1,23 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-text-primary">Statistics</h1>
<div class="text-sm text-text-secondary">Your coding insights and trends</div>
</div>
<!-- Statistics Dashboard -->
<StatisticsDashboard :apiConfig="apiConfig" />
</div>
</template>
<script setup lang="ts">
import StatisticsDashboard from '../components/StatisticsDashboard.vue';
interface ApiConfig {
base_url: string;
}
defineProps<{
apiConfig: ApiConfig;
}>();
</script>

7
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

99
tailwind.config.js Normal file
View file

@ -0,0 +1,99 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Dark theme colors
'dark': {
'sidebar-bg': '#2a1f21',
'sidebar-text': '#f5e6e8',
'sidebar-unread-text': '#ffffff',
'sidebar-text-hover-bg': '#3d2b2e',
'sidebar-text-active-border': '#ff7a8a',
'sidebar-text-active-color': '#ffffff',
'sidebar-header-bg': '#1f1617',
'sidebar-header-text': '#f5e6e8',
'sidebar-team-bar-bg': '#1f1617',
'online-indicator': '#33d6a6',
'away-indicator': '#f1c40f',
'dnd-indicator': '#ec3750',
'mention-bg': '#d63c56',
'mention-color': '#ffffff',
'center-channel-bg': '#3d2b2e',
'center-channel-color': '#f5e6e8',
'new-message-separator': '#ff7a8a',
'link-color': '#ff9aaa',
'button-bg': '#c8394f',
'button-color': '#ffffff',
'error-text': '#ff6b7d',
'mention-highlight-bg': '#4a2d31',
'mention-highlight-link': '#ff9aaa',
},
// Light theme colors
'light': {
'sidebar-bg': '#fdf7f8',
'sidebar-text': '#5d3a3f',
'sidebar-unread-text': '#2c1a1d',
'sidebar-text-hover-bg': '#f8e8ea',
'sidebar-text-active-border': '#c8394f',
'sidebar-text-active-color': '#8b2635',
'sidebar-header-bg': '#ffffff',
'sidebar-header-text': '#5d3a3f',
'sidebar-team-bar-bg': '#ffffff',
'online-indicator': '#33d6a6',
'away-indicator': '#ff8c37',
'dnd-indicator': '#ec3750',
'mention-bg': '#c8394f',
'mention-color': '#ffffff',
'center-channel-bg': '#ffffff',
'center-channel-color': '#5d3a3f',
'new-message-separator': '#ff7a8a',
'link-color': '#a12d3e',
'button-bg': '#c8394f',
'button-color': '#ffffff',
'error-text': '#d63c56',
'mention-highlight-bg': '#fce4e6',
'mention-highlight-link': '#a12d3e',
},
// Semantic color mappings
'bg-primary': 'var(--bg-primary)',
'bg-secondary': 'var(--bg-secondary)',
'bg-tertiary': 'var(--bg-tertiary)',
'bg-card': 'var(--bg-card)',
'bg-card-secondary': 'var(--bg-card-secondary)',
'bg-card-tertiary': 'var(--bg-card-tertiary)',
'bg-sidebar': 'var(--bg-sidebar)',
'text-primary': 'var(--text-primary)',
'text-secondary': 'var(--text-secondary)',
'text-muted': 'var(--text-muted)',
'accent-primary': 'var(--accent-primary)',
'accent-secondary': 'var(--accent-secondary)',
'accent-danger': 'var(--accent-danger)',
'accent-warning': 'var(--accent-warning)',
'accent-info': 'var(--accent-info)',
'border-primary': 'var(--border-primary)',
'border-secondary': 'var(--border-secondary)',
},
fontFamily: {
'sans': ['"Outfit"', 'sans-serif'],
'outfit': ['"Outfit"', 'sans-serif'],
},
borderRadius: {
'xl': '12px',
'2xl': '16px',
'3xl': '20px',
},
boxShadow: {
'primary': '0 4px 6px rgba(0, 0, 0, 0.2)',
'secondary': '0 10px 25px rgba(0, 0, 0, 0.3)',
'card': '0 2px 8px rgba(0, 0, 0, 0.1)',
'card-hover': '0 4px 16px rgba(0, 0, 0, 0.15)',
}
},
},
}

25
tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

33
vite.config.ts Normal file
View file

@ -0,0 +1,33 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [vue(), tailwindcss()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));