fix: migrate api to new version

This commit is contained in:
Leafd 2025-10-03 20:36:39 -04:00
parent d69129d43e
commit 4183bff4f6
3 changed files with 288 additions and 198 deletions

View file

@ -12,6 +12,7 @@ use sha2::{Sha256, Digest};
use base64::{Engine as _, engine::general_purpose};
use rand::{Rng, thread_rng};
use rand::distributions::Alphanumeric;
use chrono::Datelike;
mod database;
mod discord_rpc;
@ -217,6 +218,12 @@ async fn get_api_key(
return Err("Not authenticated".to_string());
}
let base_url = if api_config.base_url.is_empty() {
"https://hackatime.hackclub.com"
} else {
&api_config.base_url
};
let access_token = auth_state
.access_token
.as_ref()
@ -224,7 +231,7 @@ async fn get_api_key(
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/api/v1/authenticated/api_key", api_config.base_url))
.get(&format!("{}/api/v1/authenticated/api_keys", base_url))
.bearer_auth(access_token)
.send()
.await
@ -243,9 +250,9 @@ async fn get_api_key(
.await
.map_err(|e| format!("Failed to parse API key response: {}", e))?;
let api_key = api_key_response["api_key"]
let api_key = api_key_response["token"]
.as_str()
.ok_or("No API key in response")?;
.ok_or("No token in response")?;
Ok(api_key.to_string())
}
@ -793,40 +800,6 @@ struct SessionData {
heartbeat_count: u32,
}
#[tauri::command]
async fn register_presence_connection(
api_config: ApiConfig,
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
) -> Result<(), String> {
let auth_state = state.lock().await;
if !auth_state.is_authenticated {
return Err("Not authenticated".to_string());
}
let access_token = auth_state
.access_token
.as_ref()
.ok_or("No access token available")?;
let client = reqwest::Client::new();
let response = client
.post(&format!("{}/api/v1/presence/register", api_config.base_url))
.bearer_auth(access_token)
.send()
.await
.map_err(|e| format!("Failed to register presence connection: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Presence registration failed: {}", error_text));
}
Ok(())
}
#[tauri::command]
async fn get_latest_heartbeat(
@ -841,6 +814,12 @@ async fn get_latest_heartbeat(
return Err("Not authenticated".to_string());
}
let base_url = if api_config.base_url.is_empty() {
"https://hackatime.hackclub.com"
} else {
&api_config.base_url
};
let access_token = auth_state
.access_token
.as_ref()
@ -849,8 +828,8 @@ async fn get_latest_heartbeat(
let client = reqwest::Client::new();
let response = client
.get(&format!(
"{}/api/v1/presence/latest_heartbeat",
api_config.base_url
"{}/api/v1/authenticated/heartbeats/latest",
base_url
))
.bearer_auth(access_token)
.send()
@ -993,40 +972,6 @@ async fn get_latest_heartbeat(
Ok(heartbeat_response)
}
#[tauri::command]
async fn ping_presence_connection(
api_config: ApiConfig,
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
) -> Result<(), String> {
let auth_state = state.lock().await;
if !auth_state.is_authenticated {
return Err("Not authenticated".to_string());
}
let access_token = auth_state
.access_token
.as_ref()
.ok_or("No access token available")?;
let client = reqwest::Client::new();
let response = client
.post(&format!("{}/api/v1/presence/ping", api_config.base_url))
.bearer_auth(access_token)
.send()
.await
.map_err(|e| format!("Failed to ping presence connection: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Presence ping failed: {}", error_text));
}
Ok(())
}
#[allow(dead_code)]
fn get_config_dir() -> Result<std::path::PathBuf, String> {
@ -1190,6 +1135,12 @@ async fn get_projects(
return Err("Not authenticated".to_string());
}
let base_url = if api_config.base_url.is_empty() {
"https://hackatime.hackclub.com"
} else {
&api_config.base_url
};
let access_token = auth_state
.access_token
.as_ref()
@ -1199,7 +1150,7 @@ async fn get_projects(
let response = client
.get(&format!(
"{}/api/v1/authenticated/projects",
api_config.base_url
base_url
))
.bearer_auth(access_token)
.send()
@ -1234,6 +1185,12 @@ async fn get_project_details(
return Err("Not authenticated".to_string());
}
let base_url = if api_config.base_url.is_empty() {
"https://hackatime.hackclub.com"
} else {
&api_config.base_url
};
let access_token = auth_state
.access_token
.as_ref()
@ -1243,7 +1200,7 @@ async fn get_project_details(
let response = client
.get(&format!(
"{}/api/v1/authenticated/projects/{}",
api_config.base_url,
base_url,
urlencoding::encode(&project_name)
))
.bearer_auth(access_token)
@ -1351,6 +1308,12 @@ async fn get_statistics_data(
return Err("Not authenticated".to_string());
}
let base_url = if api_config.base_url.is_empty() {
"https://hackatime.hackclub.com"
} else {
&api_config.base_url
};
let access_token = auth_state
.access_token
.as_ref()
@ -1358,37 +1321,140 @@ async fn get_statistics_data(
let client = reqwest::Client::new();
// Get dashboard stats from Ruby API
let response = client
let end_date = chrono::Utc::now().date_naive();
let mut daily_hours = serde_json::Map::new();
let mut total_seconds = 0u64;
for days_ago in 0..7 {
let date = end_date - chrono::Duration::days(days_ago);
let date_str = date.format("%Y-%m-%d").to_string();
let day_response = client
.get(&format!(
"{}/api/v1/authenticated/hours?start_date={}&end_date={}",
base_url,
date_str,
date_str
))
.bearer_auth(access_token)
.send()
.await;
match day_response {
Ok(response) if response.status().is_success() => {
if let Ok(day_data) = response.json::<serde_json::Value>().await {
let seconds = day_data["total_seconds"].as_u64().unwrap_or(0);
total_seconds += seconds;
let day_name = match date.weekday() {
chrono::Weekday::Mon => "Mon",
chrono::Weekday::Tue => "Tue",
chrono::Weekday::Wed => "Wed",
chrono::Weekday::Thu => "Thu",
chrono::Weekday::Fri => "Fri",
chrono::Weekday::Sat => "Sat",
chrono::Weekday::Sun => "Sun",
};
daily_hours.insert(date_str.clone(), serde_json::json!({
"date": date_str,
"day_name": day_name,
"hours": seconds as f64 / 3600.0,
"seconds": seconds
}));
}
}
_ => {
let day_name = match date.weekday() {
chrono::Weekday::Mon => "Mon",
chrono::Weekday::Tue => "Tue",
chrono::Weekday::Wed => "Wed",
chrono::Weekday::Thu => "Thu",
chrono::Weekday::Fri => "Fri",
chrono::Weekday::Sat => "Sat",
chrono::Weekday::Sun => "Sun",
};
daily_hours.insert(date_str.clone(), serde_json::json!({
"date": date_str,
"day_name": day_name,
"hours": 0.0,
"seconds": 0
}));
}
}
}
let all_time_start = end_date - chrono::Duration::days(365);
let all_time_response = client
.get(&format!(
"{}/api/v1/authenticated/dashboard_stats",
api_config.base_url
"{}/api/v1/authenticated/hours?start_date={}&end_date={}",
base_url,
all_time_start.format("%Y-%m-%d"),
end_date.format("%Y-%m-%d")
))
.bearer_auth(access_token)
.send()
.await;
let all_time_seconds = match all_time_response {
Ok(response) if response.status().is_success() => {
if let Ok(data) = response.json::<serde_json::Value>().await {
data["total_seconds"].as_u64().unwrap_or(0)
} else {
0
}
}
_ => 0
};
let hours_data = serde_json::json!({
"weekly_stats": {
"time_coded_seconds": total_seconds,
"daily_hours": daily_hours
},
"all_time_stats": {
"time_coded_seconds": all_time_seconds
}
});
let streak_response = client
.get(&format!(
"{}/api/v1/authenticated/streak",
base_url
))
.bearer_auth(access_token)
.send()
.await
.map_err(|e| format!("Failed to fetch dashboard stats: {}", e))?;
.map_err(|e| format!("Failed to fetch streak: {}", e))?;
if !response.status().is_success() {
let error_text = response
if !streak_response.status().is_success() {
let error_text = streak_response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Dashboard stats request failed: {}", error_text));
return Err(format!("Streak request failed: {}", error_text));
}
let dashboard_stats: serde_json::Value = response
let streak_data: serde_json::Value = streak_response
.json()
.await
.map_err(|e| format!("Failed to parse dashboard stats: {}", e))?;
.map_err(|e| format!("Failed to parse streak data: {}", e))?;
let mut dashboard_stats = hours_data;
if let Some(streak) = streak_data.get("current_streak") {
dashboard_stats["current_streak"] = streak.clone();
}
if let Some(longest) = streak_data.get("longest_streak") {
dashboard_stats["longest_streak"] = longest.clone();
}
// Process the data in Rust for heavy computations
let statistics = process_statistics_data(dashboard_stats).await?;
Ok(statistics)
}
// Tray-related commands
#[tauri::command]
async fn show_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
@ -1620,36 +1686,43 @@ async fn generate_chart_data(
) -> Result<Vec<ChartData>, String> {
let mut charts = Vec::new();
// Daily hours chart
let mut chart_data = Vec::new();
let mut labels = Vec::new();
if let Some(daily_hours) = dashboard_stats["weekly_stats"]["daily_hours"].as_object() {
let mut chart_data = Vec::new();
let mut labels = Vec::new();
for (_date, day_data) in daily_hours {
if let Some(hours) = day_data["hours"].as_f64() {
labels.push(day_data["day_name"].as_str().unwrap_or("").to_string());
chart_data.push(hours);
}
}
charts.push(ChartData {
id: "daily_hours".to_string(),
title: "Daily Coding Hours".to_string(),
chart_type: "bar".to_string(),
data: serde_json::json!({
"labels": labels,
"datasets": [{
"label": "Hours",
"data": chart_data,
"backgroundColor": "#FB4B20",
"borderColor": "#FB4B20",
"borderWidth": 1
}]
}),
period: "Last 7 days".to_string(),
color_scheme: "orange".to_string(),
});
}
if chart_data.is_empty() {
let day_names = vec!["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
for day in day_names {
labels.push(day.to_string());
chart_data.push(0.0);
}
}
charts.push(ChartData {
id: "daily_hours".to_string(),
title: "Daily Coding Hours".to_string(),
chart_type: "bar".to_string(),
data: serde_json::json!({
"labels": labels,
"datasets": [{
"label": "Hours",
"data": chart_data,
"backgroundColor": "#FB4B20",
"borderColor": "#FB4B20",
"borderWidth": 1
}]
}),
period: "Last 7 days".to_string(),
color_scheme: "orange".to_string(),
});
// Language distribution pie chart
if let Some(top_language) = dashboard_stats["weekly_stats"]["top_language"].as_object() {
@ -1677,24 +1750,22 @@ async fn generate_chart_data(
});
}
// Weekly trend line chart
let mut trend_data = Vec::new();
let mut trend_labels = Vec::new();
let current_week_seconds = dashboard_stats["weekly_stats"]["time_coded_seconds"]
.as_u64()
.unwrap_or(0);
// Simulate 4 weeks of data
for week in 0..4 {
let week_hours = if week == 3 {
dashboard_stats["weekly_stats"]["time_coded_seconds"]
.as_u64()
.unwrap_or(0) as f64
/ 3600.0
current_week_seconds as f64 / 3600.0
} else if current_week_seconds == 0 {
0.0
} else {
// Simulate previous weeks
(dashboard_stats["weekly_stats"]["time_coded_seconds"]
.as_u64()
.unwrap_or(0) as f64
/ 3600.0)
* (0.8 + (week as f64 * 0.1))
(current_week_seconds as f64 / 3600.0) * (0.8 + (week as f64 * 0.1))
};
trend_data.push(week_hours);
@ -2077,8 +2148,8 @@ pub fn run() {
handle_deep_link_callback,
logout,
test_auth_callback,
get_api_key,
authenticate_with_direct_oauth,
get_api_key,
setup_hackatime_macos_linux,
setup_hackatime_windows,
test_hackatime_heartbeat,
@ -2086,9 +2157,7 @@ pub fn run() {
save_auth_state,
load_auth_state,
clear_auth_state,
register_presence_connection,
get_latest_heartbeat,
ping_presence_connection,
get_hackatime_directories,
cleanup_old_sessions,
get_session_stats,

View file

@ -56,7 +56,20 @@ const { currentTheme, toggleTheme } = useTheme();
// Computed property for weekly chart data
const weeklyChartData = computed(() => {
if (!userStats.value?.weekly_stats?.daily_hours) return [];
if (!userStats.value?.weekly_stats?.daily_hours) {
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const today = new Date();
return dayNames.map((dayName, index) => {
const date = new Date(today);
date.setDate(today.getDate() - (6 - index));
return {
date: date.toISOString().split('T')[0],
day_name: dayName,
hours: 0,
percentage: 0
};
});
}
const dailyHours = userStats.value.weekly_stats.daily_hours;
const maxHours = Math.max(...Object.values(dailyHours).map((day: any) => day.hours), 1);
@ -173,10 +186,7 @@ async function loadAuthState() {
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");
@ -216,6 +226,8 @@ async function loadUserData() {
console.error("Failed to load user dashboard stats:", error);
}
await loadApiKey();
// Load presence data and start refresh
await loadPresenceData();
startPresenceRefresh();
@ -232,16 +244,6 @@ async function loadApiKey() {
}
}
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 {
const config = await invoke("get_api_config") as ApiConfig;
@ -565,7 +567,7 @@ function getPageTitle(): string {
</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" />
<Settings v-if="currentPage === 'settings'" :currentTheme="currentTheme" :toggleTheme="toggleTheme" :apiKey="apiKey" v-model:showApiKey="showApiKey" @copyApiKey="copyApiKey" />
</div>
</div>
</div>

View file

@ -18,12 +18,17 @@ export class KubeTimeApi {
async initialize() {
try {
const config: ApiConfig = await invoke("get_api_config");
this.baseUrl = config.base_url;
if (config.base_url && config.base_url.trim()) {
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);
if (!this.baseUrl || !this.baseUrl.trim()) {
this.baseUrl = "https://hackatime.hackclub.com";
}
}
}
@ -51,78 +56,92 @@ export class KubeTimeApi {
}
}
async getHours(startDate?: string, endDate?: string) {
if (!this.accessToken) {
throw new Error("Not authenticated");
}
try {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const url = `${this.baseUrl}/api/v1/authenticated/hours${params.toString() ? `?${params.toString()}` : ''}`;
const response = await fetch(url, {
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 hours:", error);
throw error;
}
}
async getWeeklyHours() {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 7);
const formatDate = (date: Date) => date.toISOString().split('T')[0];
return this.getHours(formatDate(startDate), formatDate(endDate));
}
async getStreak() {
if (!this.accessToken) {
throw new Error("Not authenticated");
}
try {
const response = await fetch(`${this.baseUrl}/api/v1/authenticated/streak`, {
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 streak:", 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',
},
});
const [hoursData, streakData] = await Promise.all([
this.getWeeklyHours(),
this.getStreak()
]);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
return {
...hoursData,
current_streak: streakData.current_streak,
longest_streak: streakData.longest_streak
};
} 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) {
@ -136,7 +155,7 @@ export class KubeTimeApi {
return this.latestPresenceCache.data;
}
const response = await fetch(`${this.baseUrl}/api/v1/presence/latest_heartbeat`, {
const response = await fetch(`${this.baseUrl}/api/v1/authenticated/heartbeats/latest`, {
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',