Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src-tauri/src/actions.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
use crate::apple_intelligence;
use crate::audio_feedback::{play_feedback_sound, play_feedback_sound_blocking, SoundType};
use crate::audio_toolkit::constants;
use crate::managers::audio::AudioRecordingManager;
use crate::managers::history::HistoryManager;
use crate::managers::transcription::TranscriptionManager;
Expand Down Expand Up @@ -395,13 +396,22 @@ impl ShortcutAction for TranscribeAction {
// Save to history with post-processed text and prompt
let hm_clone = Arc::clone(&hm);
let transcription_for_history = transcription.clone();

// Calculate analytics data
let duration_seconds =
samples_clone.len() as f32 / constants::WHISPER_SAMPLE_RATE as f32;
let word_count =
transcription_for_history.split_whitespace().count() as i32;

tauri::async_runtime::spawn(async move {
if let Err(e) = hm_clone
.save_transcription(
samples_clone,
transcription_for_history,
post_processed_text,
post_process_prompt,
duration_seconds,
word_count,
)
.await
{
Expand Down
14 changes: 13 additions & 1 deletion src-tauri/src/commands/history.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::managers::history::{HistoryEntry, HistoryManager};
use crate::managers::history::{AnalyticsStats, HistoryEntry, HistoryManager};
use std::sync::Arc;
use tauri::{AppHandle, State};

Expand Down Expand Up @@ -99,3 +99,15 @@ pub async fn update_recording_retention_period(

Ok(())
}

#[tauri::command]
#[specta::specta]
pub fn get_analytics(
_app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,
period: String,
) -> Result<AnalyticsStats, String> {
history_manager
.get_analytics(&period)
.map_err(|e| e.to_string())
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ pub fn run() {
commands::history::delete_history_entry,
commands::history::update_history_limit,
commands::history::update_recording_retention_period,
commands::history::get_analytics,
helpers::clamshell::is_laptop,
]);

Expand Down
142 changes: 137 additions & 5 deletions src-tauri/src/managers/history.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use anyhow::Result;
use chrono::{DateTime, Local, Utc};
use chrono::{DateTime, Datelike, Local, NaiveDate, Utc};
use log::{debug, error, info};
use rusqlite::{params, Connection, OptionalExtension};
use rusqlite_migration::{Migrations, M};
Expand Down Expand Up @@ -31,6 +31,8 @@ static MIGRATIONS: &[M] = &[
),
M::up("ALTER TABLE transcription_history ADD COLUMN post_processed_text TEXT;"),
M::up("ALTER TABLE transcription_history ADD COLUMN post_process_prompt TEXT;"),
M::up("ALTER TABLE transcription_history ADD COLUMN duration_seconds REAL;"),
M::up("ALTER TABLE transcription_history ADD COLUMN word_count INTEGER;"),
];

#[derive(Clone, Debug, Serialize, Deserialize, Type)]
Expand All @@ -43,6 +45,17 @@ pub struct HistoryEntry {
pub transcription_text: String,
pub post_processed_text: Option<String>,
pub post_process_prompt: Option<String>,
pub duration_seconds: Option<f32>,
pub word_count: Option<i32>,
}

#[derive(Clone, Debug, Serialize, Deserialize, Type)]
pub struct AnalyticsStats {
pub total_words: i32,
pub total_duration_seconds: f32,
pub transcription_count: i32,
pub average_wpm: Option<f32>,
pub current_streak_days: i32,
}

pub struct HistoryManager {
Expand Down Expand Up @@ -183,6 +196,8 @@ impl HistoryManager {
transcription_text: String,
post_processed_text: Option<String>,
post_process_prompt: Option<String>,
duration_seconds: f32,
word_count: i32,
) -> Result<()> {
let timestamp = Utc::now().timestamp();
let file_name = format!("handy-{}.wav", timestamp);
Expand All @@ -200,6 +215,8 @@ impl HistoryManager {
transcription_text,
post_processed_text,
post_process_prompt,
duration_seconds,
word_count,
)?;

// Clean up old entries
Expand All @@ -221,11 +238,13 @@ impl HistoryManager {
transcription_text: String,
post_processed_text: Option<String>,
post_process_prompt: Option<String>,
duration_seconds: f32,
word_count: i32,
) -> Result<()> {
let conn = self.get_connection()?;
conn.execute(
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt],
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, duration_seconds, word_count) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt, duration_seconds, word_count],
)?;

debug!("Saved transcription to database");
Expand Down Expand Up @@ -355,7 +374,7 @@ impl HistoryManager {
pub async fn get_history_entries(&self) -> Result<Vec<HistoryEntry>> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt FROM transcription_history ORDER BY timestamp DESC"
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, duration_seconds, word_count FROM transcription_history ORDER BY timestamp DESC"
)?;

let rows = stmt.query_map([], |row| {
Expand All @@ -368,6 +387,8 @@ impl HistoryManager {
transcription_text: row.get("transcription_text")?,
post_processed_text: row.get("post_processed_text")?,
post_process_prompt: row.get("post_process_prompt")?,
duration_seconds: row.get("duration_seconds")?,
word_count: row.get("word_count")?,
})
})?;

Expand Down Expand Up @@ -413,7 +434,7 @@ impl HistoryManager {
pub async fn get_entry_by_id(&self, id: i64) -> Result<Option<HistoryEntry>> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, duration_seconds, word_count
FROM transcription_history WHERE id = ?1",
)?;

Expand All @@ -428,6 +449,8 @@ impl HistoryManager {
transcription_text: row.get("transcription_text")?,
post_processed_text: row.get("post_processed_text")?,
post_process_prompt: row.get("post_process_prompt")?,
duration_seconds: row.get("duration_seconds")?,
word_count: row.get("word_count")?,
})
})
.optional()?;
Expand Down Expand Up @@ -475,4 +498,113 @@ impl HistoryManager {
format!("Recording {}", timestamp)
}
}

/// Get analytics statistics for a given time period
pub fn get_analytics(&self, period: &str) -> Result<AnalyticsStats> {
let conn = self.get_connection()?;

// Calculate timestamp boundary based on period
let now = Utc::now();
let start_timestamp: i64 = match period {
"today" => {
// Start of today in local timezone, converted to UTC timestamp
let local_now = now.with_timezone(&Local);
local_now
.date_naive()
.and_hms_opt(0, 0, 0)
.map(|dt| dt.and_local_timezone(Local).unwrap().timestamp())
.unwrap_or(0)
}
"this_week" => {
// Start of this week (Monday) in local timezone
let local_now = now.with_timezone(&Local);
let days_since_monday = local_now.weekday().num_days_from_monday() as i64;
(local_now - chrono::Duration::days(days_since_monday))
.date_naive()
.and_hms_opt(0, 0, 0)
.map(|dt| dt.and_local_timezone(Local).unwrap().timestamp())
.unwrap_or(0)
}
_ => 0, // all_time
};

// Aggregate stats - only count entries with analytics data
let (total_words, total_duration_seconds, transcription_count): (i32, f64, i32) = conn
.query_row(
"SELECT
COALESCE(SUM(word_count), 0),
COALESCE(SUM(duration_seconds), 0.0),
COUNT(*)
FROM transcription_history
WHERE timestamp >= ?1
AND word_count IS NOT NULL
AND duration_seconds IS NOT NULL",
params![start_timestamp],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;

// Calculate average WPM (avoid division by zero)
let average_wpm = if total_duration_seconds > 0.0 {
Some((total_words as f32) / (total_duration_seconds as f32 / 60.0))
} else {
None
};

// Calculate current streak
let current_streak_days = self.calculate_streak(&conn)?;

Ok(AnalyticsStats {
total_words,
total_duration_seconds: total_duration_seconds as f32,
transcription_count,
average_wpm,
current_streak_days,
})
}

/// Calculate the current streak of consecutive days with transcriptions
fn calculate_streak(&self, conn: &Connection) -> Result<i32> {
// Get distinct days with transcriptions that have analytics data
let mut stmt = conn.prepare(
"SELECT DISTINCT date(timestamp, 'unixepoch', 'localtime') as day
FROM transcription_history
WHERE word_count IS NOT NULL AND duration_seconds IS NOT NULL
ORDER BY day DESC",
)?;

let days: Vec<String> = stmt
.query_map([], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();

if days.is_empty() {
return Ok(0);
}

let today = Local::now().format("%Y-%m-%d").to_string();
let yesterday = (Local::now() - chrono::Duration::days(1))
.format("%Y-%m-%d")
.to_string();

// Current streak must include today or yesterday
if days.first() != Some(&today) && days.first() != Some(&yesterday) {
return Ok(0);
}

let mut current_streak = 1;
for i in 1..days.len() {
if let (Ok(prev), Ok(curr)) = (
NaiveDate::parse_from_str(&days[i - 1], "%Y-%m-%d"),
NaiveDate::parse_from_str(&days[i], "%Y-%m-%d"),
) {
if prev.signed_duration_since(curr).num_days() == 1 {
current_streak += 1;
} else {
break;
}
}
}

Ok(current_streak)
}
}
11 changes: 10 additions & 1 deletion src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,14 @@ async updateRecordingRetentionPeriod(period: string) : Promise<Result<null, stri
else return { status: "error", error: e as any };
}
},
async getAnalytics(period: string) : Promise<Result<AnalyticsStats, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_analytics", { period }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
/**
* Checks if the Mac is a laptop by detecting battery presence
*
Expand All @@ -610,13 +618,14 @@ async isLaptop() : Promise<Result<boolean, string>> {

/** user-defined types **/

export type AnalyticsStats = { total_words: number; total_duration_seconds: number; transcription_count: number; average_wpm: number | null; current_streak_days: number }
export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: string; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean }
export type AudioDevice = { index: string; name: string; is_default: boolean }
export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null }
export type ClipboardHandling = "dont_modify" | "copy_to_clipboard"
export type CustomSounds = { start: boolean; stop: boolean }
export type EngineType = "Whisper" | "Parakeet"
export type HistoryEntry = { id: string; file_name: string; timestamp: string; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null }
export type HistoryEntry = { id: string; file_name: string; timestamp: string; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null; duration_seconds: number | null; word_count: number | null }
export type LLMPrompt = { id: string; name: string; prompt: string }
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error"
export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: string; is_downloaded: boolean; is_downloading: boolean; partial_size: string; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number }
Expand Down
16 changes: 15 additions & 1 deletion src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Cog, FlaskConical, History, Info, Sparkles } from "lucide-react";
import {
BarChart3,
Cog,
FlaskConical,
History,
Info,
Sparkles,
} from "lucide-react";
import HandyTextLogo from "./icons/HandyTextLogo";
import HandyHand from "./icons/HandyHand";
import { useSettings } from "../hooks/useSettings";
import {
GeneralSettings,
AdvancedSettings,
HistorySettings,
AnalyticsSettings,
DebugSettings,
AboutSettings,
PostProcessingSettings,
Expand Down Expand Up @@ -55,6 +63,12 @@ export const SECTIONS_CONFIG = {
component: HistorySettings,
enabled: () => true,
},
analytics: {
labelKey: "sidebar.analytics",
icon: BarChart3,
component: AnalyticsSettings,
enabled: () => true,
},
debug: {
labelKey: "sidebar.debug",
icon: FlaskConical,
Expand Down
Loading