1497 lines
57 KiB
Python
Raw Normal View History

2025-11-08 15:08:49 +01:00
import sqlite3
import json
from datetime import datetime, timedelta
from collections import defaultdict, Counter
import sys
import time
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
class TikkerVisualizer:
def __init__(self, database_path='tikker.db'):
self.database_path = database_path
self.connection = sqlite3.connect(database_path)
self.cursor = self.connection.cursor()
def get_total_events(self):
start_time = time.time()
logging.info("Retrieving total events...")
self.cursor.execute("SELECT COUNT(*) FROM kevent")
result = self.cursor.fetchone()[0]
duration = time.time() - start_time
logging.info(f"Total events retrieved in {duration:.2f} seconds")
return result, duration
def get_pressed_events(self):
start_time = time.time()
logging.info("Retrieving pressed events...")
self.cursor.execute("SELECT COUNT(*) FROM kevent WHERE event='PRESSED'")
result = self.cursor.fetchone()[0]
duration = time.time() - start_time
logging.info(f"Pressed events retrieved in {duration:.2f} seconds")
return result, duration
def get_released_events(self):
start_time = time.time()
logging.info("Retrieving released events...")
self.cursor.execute("SELECT COUNT(*) FROM kevent WHERE event='RELEASED'")
result = self.cursor.fetchone()[0]
duration = time.time() - start_time
logging.info(f"Released events retrieved in {duration:.2f} seconds")
return result, duration
def get_date_range(self):
start_time = time.time()
logging.info("Retrieving date range...")
self.cursor.execute("SELECT MIN(timestamp), MAX(timestamp) FROM kevent")
result = self.cursor.fetchone()
duration = time.time() - start_time
logging.info(f"Date range retrieved in {duration:.2f} seconds")
return result[0], result[1], duration
def get_hourly_activity(self, limit=1000):
start_time = time.time()
logging.info("Retrieving hourly activity...")
self.cursor.execute("""
SELECT strftime('%H', timestamp) as hour, COUNT(*) as count
FROM kevent
WHERE event='PRESSED'
GROUP BY hour
ORDER BY hour
""")
result = self.cursor.fetchall()
duration = time.time() - start_time
logging.info(f"Hourly activity retrieved in {duration:.2f} seconds")
return result, duration
def get_daily_activity(self, limit=365):
start_time = time.time()
logging.info("Retrieving daily activity...")
self.cursor.execute(f"""
SELECT strftime('%Y-%m-%d', timestamp) as day, COUNT(*) as count
FROM kevent
WHERE event='PRESSED'
GROUP BY day
ORDER BY day DESC
LIMIT {limit}
""")
result = list(reversed(self.cursor.fetchall()))
duration = time.time() - start_time
logging.info(f"Daily activity retrieved in {duration:.2f} seconds")
return result, duration
def get_weekday_activity(self):
start_time = time.time()
logging.info("Retrieving weekday activity...")
self.cursor.execute("""
SELECT
CASE CAST(strftime('%w', timestamp) AS INTEGER)
WHEN 0 THEN 'Sunday'
WHEN 1 THEN 'Monday'
WHEN 2 THEN 'Tuesday'
WHEN 3 THEN 'Wednesday'
WHEN 4 THEN 'Thursday'
WHEN 5 THEN 'Friday'
WHEN 6 THEN 'Saturday'
END as weekday,
COUNT(*) as count
FROM kevent
WHERE event='PRESSED'
GROUP BY strftime('%w', timestamp)
ORDER BY strftime('%w', timestamp)
""")
result = self.cursor.fetchall()
duration = time.time() - start_time
logging.info(f"Weekday activity retrieved in {duration:.2f} seconds")
return result, duration
def get_top_keys(self, limit=30):
start_time = time.time()
logging.info("Retrieving top keys...")
self.cursor.execute(f"""
SELECT char, COUNT(*) as count
FROM kevent
WHERE event='PRESSED' AND char IS NOT NULL AND char != ''
GROUP BY char
ORDER BY count DESC
LIMIT {limit}
""")
result = self.cursor.fetchall()
duration = time.time() - start_time
logging.info(f"Top keys retrieved in {duration:.2f} seconds")
return result, duration
def get_keyboard_heatmap_data(self):
start_time = time.time()
logging.info("Retrieving keyboard heatmap data...")
self.cursor.execute("""
SELECT char, COUNT(*) as count
FROM kevent
WHERE event='PRESSED' AND char IS NOT NULL AND char != ''
GROUP BY char
ORDER BY count DESC
""")
data = {}
for char, count in self.cursor.fetchall():
data[char] = count
duration = time.time() - start_time
logging.info(f"Keyboard heatmap data retrieved in {duration:.2f} seconds")
return data, duration
def get_hour_day_heatmap(self, days_back=30):
start_time = time.time()
logging.info("Retrieving hour-day heatmap...")
self.cursor.execute(f"""
SELECT
strftime('%Y-%m-%d', timestamp) as day,
strftime('%H', timestamp) as hour,
COUNT(*) as count
FROM kevent
WHERE event='PRESSED'
AND timestamp >= datetime('now', '-{days_back} days')
GROUP BY day, hour
ORDER BY day, hour
""")
result = self.cursor.fetchall()
duration = time.time() - start_time
logging.info(f"Hour-day heatmap retrieved in {duration:.2f} seconds")
return result, duration
def get_typing_speed_data(self, sample_size=10000):
start_time = time.time()
logging.info("Retrieving typing speed data...")
self.cursor.execute(f"""
SELECT
strftime('%Y-%m-%d %H:00:00', timestamp) as hour_bucket,
COUNT(*) as keypresses
FROM kevent
WHERE event='PRESSED'
GROUP BY hour_bucket
ORDER BY hour_bucket DESC
LIMIT {sample_size}
""")
result = list(reversed(self.cursor.fetchall()))
duration = time.time() - start_time
logging.info(f"Typing speed data retrieved in {duration:.2f} seconds")
return result, duration
def get_character_frequency(self):
start_time = time.time()
logging.info("Retrieving character frequency...")
letters = Counter()
numbers = Counter()
special = Counter()
self.cursor.execute("""
SELECT char, COUNT(*) as count
FROM kevent
WHERE event='PRESSED' AND char IS NOT NULL AND char != ''
GROUP BY char
""")
for char, count in self.cursor.fetchall():
if len(char) == 1:
if char.isalpha():
letters[char.lower()] += count
elif char.isdigit():
numbers[char] += count
else:
special[char] += count
result = {
'letters': dict(letters.most_common(26)),
'numbers': dict(numbers.most_common(10)),
'special': dict(special.most_common(20))
}
duration = time.time() - start_time
logging.info(f"Character frequency retrieved in {duration:.2f} seconds")
return result, duration
def get_monthly_stats(self):
start_time = time.time()
logging.info("Retrieving monthly stats...")
self.cursor.execute("""
SELECT
strftime('%Y-%m', timestamp) as month,
COUNT(*) as count
FROM kevent
WHERE event='PRESSED'
GROUP BY month
ORDER BY month
""")
result = self.cursor.fetchall()
duration = time.time() - start_time
logging.info(f"Monthly stats retrieved in {duration:.2f} seconds")
return result, duration
def get_peak_activity_times(self):
start_time = time.time()
logging.info("Retrieving peak activity times...")
self.cursor.execute("""
SELECT
strftime('%Y-%m-%d %H:00:00', timestamp) as hour_block,
COUNT(*) as count
FROM kevent
WHERE event='PRESSED'
GROUP BY hour_block
ORDER BY count DESC
LIMIT 10
""")
result = self.cursor.fetchall()
duration = time.time() - start_time
logging.info(f"Peak activity times retrieved in {duration:.2f} seconds")
return result, duration
# NEW: Words Per Minute (WPM) and Performance Metrics
def get_wpm_stats(self):
start_time = time.time()
logging.info("Calculating WPM and performance metrics...")
# Get total key presses and time span
self.cursor.execute("SELECT COUNT(*) FROM kevent WHERE event='PRESSED'")
total_presses = self.cursor.fetchone()[0]
self.cursor.execute("SELECT MIN(timestamp), MAX(timestamp) FROM kevent WHERE event='PRESSED'")
min_time, max_time = self.cursor.fetchone()
if not min_time or not max_time or total_presses == 0:
return {
'wpm_average': 0,
'wpm_peak_hour': 0,
'wpm_peak_day': 0,
'words_typed': 0,
'characters_typed': 0,
'total_typing_time_minutes': 0,
'typing_efficiency': 0,
'peak_hour': None,
'peak_day': None
}, 0.0
min_dt = datetime.fromisoformat(min_time)
max_dt = datetime.fromisoformat(max_time)
total_minutes = (max_dt - min_dt).total_seconds() / 60.0
# Estimate words: 5 characters per word (standard WPM formula)
characters_typed = total_presses
words_typed = characters_typed / 5.0
wpm_average = words_typed / total_minutes if total_minutes > 0 else 0
# Peak hour WPM
self.cursor.execute("""
SELECT strftime('%Y-%m-%d %H:00:00', timestamp) as hour, COUNT(*) as presses
FROM kevent WHERE event='PRESSED'
GROUP BY hour
ORDER BY presses DESC
LIMIT 1
""")
peak_hour_row = self.cursor.fetchone()
peak_hour_wpm = (peak_hour_row[1] / 5.0) if peak_hour_row else 0
# Peak day WPM
self.cursor.execute("""
SELECT strftime('%Y-%m-%d', timestamp) as day, COUNT(*) as presses
FROM kevent WHERE event='PRESSED'
GROUP BY day
ORDER BY presses DESC
LIMIT 1
""")
peak_day_row = self.cursor.fetchone()
peak_day_wpm = (peak_day_row[1] / 5.0 / 60.0) if peak_day_row else 0 # per minute
# Typing efficiency: presses per minute during active time
# Approximate active time as sum of intervals with activity
self.cursor.execute("""
SELECT COUNT(DISTINCT strftime('%Y-%m-%d %H', timestamp)) as active_hours
FROM kevent WHERE event='PRESSED'
""")
active_hours = self.cursor.fetchone()[0]
typing_efficiency = total_presses / (active_hours * 60) if active_hours > 0 else 0
result = {
'wpm_average': round(wpm_average, 2),
'wpm_peak_hour': round(peak_hour_wpm, 2),
'wpm_peak_day': round(peak_day_wpm, 2),
'words_typed': int(words_typed),
'characters_typed': characters_typed,
'total_typing_time_minutes': round(total_minutes, 1),
'typing_efficiency': round(typing_efficiency, 2),
'peak_hour': peak_hour_row[0] if peak_hour_row else None,
'peak_day': peak_day_row[0] if peak_day_row else None
}
duration = time.time() - start_time
logging.info(f"WPM stats calculated in {duration:.2f} seconds")
return result, duration
def get_performance_insights(self):
start_time = time.time()
logging.info("Generating performance insights...")
# === 1. Key type distribution and corrections ===
self.cursor.execute("""
SELECT
SUM(CASE WHEN char IS NOT NULL AND length(char) = 1 AND char GLOB '[a-zA-Z]' THEN 1 ELSE 0 END) as letters,
SUM(CASE WHEN char IS NOT NULL AND length(char) = 1 AND char GLOB '[0-9]' THEN 1 ELSE 0 END) as numbers,
SUM(CASE WHEN char IS NOT NULL AND length(char) = 1 AND char NOT GLOB '[a-zA-Z0-9]' THEN 1 ELSE 0 END) as special,
SUM(CASE WHEN char = ' ' THEN 1 ELSE 0 END) as spaces,
SUM(CASE WHEN char = 'Backspace' THEN 1 ELSE 0 END) as backspaces,
SUM(CASE WHEN char = 'Delete' THEN 1 ELSE 0 END) as deletes
FROM kevent WHERE event='PRESSED'
""")
row = self.cursor.fetchone()
letters = row[0] or 0
numbers = row[1] or 0
special = row[2] or 0
spaces = row[3] or 0
backspaces = row[4] or 0
deletes = row[5] or 0
total_edits = backspaces + deletes
valid_keystrokes = letters + numbers + special + spaces
error_rate = (total_edits / valid_keystrokes * 100) if valid_keystrokes > 0 else 0
# === 2. Daily activity consistency (manual STDDEV) ===
self.cursor.execute("""
SELECT COUNT(*) as count
FROM kevent WHERE event='PRESSED'
GROUP BY strftime('%Y-%m-%d', timestamp)
""")
daily_counts = [row[0] for row in self.cursor.fetchall()]
if daily_counts:
avg_daily = sum(daily_counts) / len(daily_counts)
variance = sum((x - avg_daily) ** 2 for x in daily_counts) / len(daily_counts)
std_daily = variance ** 0.5
consistency_score = 100 - (std_daily / avg_daily * 100) if avg_daily > 0 else 0
consistency_score = max(0, min(100, consistency_score))
else:
avg_daily = std_daily = 0
consistency_score = 0
result = {
'error_rate_percent': round(error_rate, 2),
'backspace_count': backspaces,
'delete_count': deletes,
'total_corrections': total_edits,
'consistency_score': round(consistency_score, 1),
'letters_typed': letters,
'numbers_typed': numbers,
'special_typed': special,
'spaces_typed': spaces
}
duration = time.time() - start_time
logging.info(f"Performance insights generated in {duration:.2f} seconds")
return result, duration
def close(self):
self.connection.close()
def generate_html(self, output_file='tikker_report.html'):
logging.info("Starting HTML generation...")
overall_start = time.time()
total_events, total_events_duration = self.get_total_events()
pressed_events, pressed_events_duration = self.get_pressed_events()
released_events, released_events_duration = self.get_released_events()
date_range_min, date_range_max, date_range_duration = self.get_date_range()
date_range = (date_range_min, date_range_max)
hourly_data, hourly_duration = self.get_hourly_activity()
daily_data, daily_duration = self.get_daily_activity()
weekday_data, weekday_duration = self.get_weekday_activity()
top_keys, top_keys_duration = self.get_top_keys()
keyboard_heatmap, keyboard_heatmap_duration = self.get_keyboard_heatmap_data()
hour_day_heatmap, hour_day_heatmap_duration = self.get_hour_day_heatmap()
typing_speed, typing_speed_duration = self.get_typing_speed_data()
char_frequency, char_frequency_duration = self.get_character_frequency()
monthly_stats, monthly_stats_duration = self.get_monthly_stats()
peak_times, peak_times_duration = self.get_peak_activity_times()
wpm_stats, wpm_duration = self.get_wpm_stats()
perf_insights, perf_duration = self.get_performance_insights()
durations = {
'total_events': total_events_duration,
'pressed_events': pressed_events_duration,
'released_events': released_events_duration,
'date_range': date_range_duration,
'hourly_activity': hourly_duration,
'daily_activity': daily_duration,
'weekday_activity': weekday_duration,
'top_keys': top_keys_duration,
'keyboard_heatmap': keyboard_heatmap_duration,
'hour_day_heatmap': hour_day_heatmap_duration,
'typing_speed': typing_speed_duration,
'character_frequency': char_frequency_duration,
'monthly_stats': monthly_stats_duration,
'peak_activity_times': peak_times_duration,
'wpm_calculation': wpm_duration,
'performance_insights': perf_duration
}
logging.info("Generating HTML content...")
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tikker Keyboard Performance Report</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {{
--primary: #4f46e5;
--primary-light: #6366f1;
--primary-dark: #4338ca;
--secondary: #64748b;
--background: #f8fafc;
--surface: #ffffff;
--text-primary: #1e293b;
--text-secondary: #475569;
--border: #e2e8f0;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #0ea5e9;
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--radius-sm: 0.375rem;
--radius: 0.5rem;
--radius-lg: 0.75rem;
}}
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--background);
color: var(--text-primary);
line-height: 1.6;
padding: 2rem;
min-height: 100vh;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}}
header {{
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
padding: 3rem 2.5rem;
text-align: center;
}}
header h1 {{
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.75rem;
letter-spacing: -0.025em;
}}
header p {{
font-size: 1.125rem;
opacity: 0.9;
max-width: 700px;
margin: 0 auto;
}}
.stats-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
padding: 2.5rem;
background: #f1f5f9;
}}
.stat-card {{
background: var(--surface);
padding: 1.75rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--border);
transition: all 0.2s ease;
}}
.stat-card:hover {{
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--primary);
}}
.stat-card h3 {{
color: var(--secondary);
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}}
.stat-card .value {{
font-size: 2.25rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.25rem;
}}
.stat-card .label {{
color: var(--text-secondary);
font-size: 0.875rem;
}}
.content {{
padding: 2.5rem;
}}
.section {{
margin-bottom: 3.5rem;
}}
.section h2 {{
color: var(--text-primary);
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--primary);
display: inline-block;
}}
.section p {{
color: var(--text-secondary);
margin-bottom: 1.5rem;
max-width: 800px;
}}
.chart-container {{
background: var(--surface);
padding: 1.75rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--border);
}}
canvas {{
max-width: 100%;
height: auto;
}}
.bar-horizontal {{
height: 2.5rem;
background: linear-gradient(90deg, var(--primary) 0%, var(--primary-light) 100%);
margin-bottom: 0.75rem;
border-radius: var(--radius-sm);
position: relative;
transition: all 0.3s ease;
}}
.bar-horizontal:hover {{
transform: scaleX(1.02);
box-shadow: var(--shadow);
}}
.bar-label {{
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: white;
font-weight: 600;
font-size: 0.875rem;
}}
.bar-value {{
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
color: white;
font-weight: 600;
font-size: 0.875rem;
}}
.keyboard-grid {{
display: grid;
grid-template-columns: repeat(13, 1fr);
gap: 0.5rem;
margin-top: 1.5rem;
max-width: 900px;
}}
.key {{
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 1rem;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
background: #f1f5f9;
color: var(--text-primary);
}}
.key:hover {{
transform: scale(1.1);
z-index: 10;
box-shadow: var(--shadow);
}}
.heatmap-grid {{
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 0.125rem;
margin-top: 1.5rem;
max-width: 1200px;
}}
.heatmap-cell {{
aspect-ratio: 1;
border-radius: 0.125rem;
transition: transform 0.2s ease;
background: #e2e8f0;
}}
.heatmap-cell:hover {{
transform: scale(1.5);
z-index: 10;
box-shadow: var(--shadow);
}}
.legend {{
display: flex;
align-items: center;
margin-top: 1.5rem;
gap: 0.75rem;
font-size: 0.875rem;
color: var(--text-secondary);
}}
.legend-gradient {{
height: 1rem;
flex: 1;
max-width: 200px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg,
hsl(240, 100%, 90%) 0%,
hsl(200, 100%, 80%) 25%,
hsl(160, 100%, 70%) 50%,
hsl(80, 100%, 60%) 75%,
hsl(0, 100%, 50%) 100%);
}}
.legend-labels {{
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
}}
.grid-2col {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 2rem;
}}
table {{
width: 100%;
border-collapse: collapse;
margin-top: 1.5rem;
font-size: 0.875rem;
}}
th {{
background: var(--primary);
color: white;
font-weight: 600;
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.875rem;
}}
td {{
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}}
tr:hover {{
background: #f8fafc;
}}
.tooltip {{
position: absolute;
background: rgba(15, 23, 42, 0.95);
color: white;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1000;
box-shadow: var(--shadow);
}}
.durations {{
background: #f1f5f9;
padding: 1.75rem;
border-radius: var(--radius);
margin-bottom: 2.5rem;
border: 1px solid var(--border);
}}
.durations h2 {{
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--primary);
display: inline-block;
}}
.durations ul {{
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 0.75rem;
}}
.durations li {{
padding: 0.5rem 0;
color: var(--text-secondary);
font-size: 0.875rem;
display: flex;
justify-content: space-between;
}}
.durations li span:first-child {{
font-weight: 500;
color: var(--text-primary);
}}
.performance-metrics {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}}
.metric-card {{
background: #f8fafc;
padding: 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
}}
.metric-card h4 {{
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}}
.metric-card .metric-value {{
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}}
.metric-card .metric-sub {{
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}}
footer {{
background: #f1f5f9;
color: var(--text-secondary);
text-align: center;
padding: 2rem;
margin-top: 3rem;
border-top: 1px solid var(--border);
font-size: 0.875rem;
}}
@media (max-width: 768px) {{
body {{
padding: 1rem;
}}
header h1 {{
font-size: 2rem;
}}
.stats-grid {{
grid-template-columns: 1fr;
padding: 1.5rem;
}}
.content {{
padding: 1.5rem;
}}
.grid-2col {{
grid-template-columns: 1fr;
}}
.keyboard-grid {{
grid-template-columns: repeat(10, 1fr);
}}
.heatmap-grid {{
grid-template-columns: repeat(12, 1fr);
}}
.durations ul {{
grid-template-columns: 1fr;
}}
}}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Tikker Keyboard Performance Report</h1>
<p>Comprehensive Analysis of Typing Speed, Accuracy, and Productivity Metrics</p>
</header>
<div class="stats-grid">
<div class="stat-card" title="Total number of keyboard events recorded (presses and releases)">
<h3>Total Events</h3>
<div class="value">{total_events:,}</div>
<div class="label">Recorded Actions</div>
</div>
<div class="stat-card" title="Number of key press events, primary metric for activity analysis">
<h3>Key Presses</h3>
<div class="value">{pressed_events:,}</div>
<div class="label">Keys Pressed</div>
</div>
<div class="stat-card" title="Average words per minute across entire tracking period">
<h3>Average WPM</h3>
<div class="value">{wpm_stats['wpm_average']}</div>
<div class="label">Words Per Minute</div>
</div>
<div class="stat-card" title="Peak words per minute in a single hour">
<h3>Peak Hour WPM</h3>
<div class="value">{wpm_stats['wpm_peak_hour']}</div>
<div class="label">Max Speed</div>
</div>
<div class="stat-card" title="Duration between first and last recorded event">
<h3>Tracking Period</h3>
<div class="value">{(datetime.fromisoformat(date_range[1]) - datetime.fromisoformat(date_range[0])).days if date_range[0] and date_range[1] else 0}</div>
<div class="label">Days of Data</div>
</div>
<div class="stat-card" title="Estimated total words typed (5 characters per word)">
<h3>Words Typed</h3>
<div class="value">{wpm_stats['words_typed']:,}</div>
<div class="label">Total Output</div>
</div>
</div>
<div class="content">
<div class="durations">
<h2>Data Retrieval Performance</h2>
<ul>
{''.join(f'<li><span>{key.replace("_", " ").title()}:</span> <span>{value:.3f}s</span></li>' for key, value in durations.items())}
</ul>
</div>
<!-- WPM & Performance Section -->
<div class="section">
<h2>Typing Speed & Performance</h2>
<p>Detailed breakdown of words per minute, efficiency, and accuracy metrics.</p>
<div class="performance-metrics">
<div class="metric-card">
<h4>Average WPM</h4>
<div class="metric-value">{wpm_stats['wpm_average']}</div>
<div class="metric-sub">Over {wpm_stats['total_typing_time_minutes']} minutes</div>
</div>
<div class="metric-card">
<h4>Peak Hour WPM</h4>
<div class="metric-value">{wpm_stats['wpm_peak_hour']}</div>
<div class="metric-sub">{wpm_stats['peak_hour'] or 'N/A'}</div>
</div>
<div class="metric-card">
<h4>Typing Efficiency</h4>
<div class="metric-value">{wpm_stats['typing_efficiency']}</div>
<div class="metric-sub">Presses per active minute</div>
</div>
<div class="metric-card">
<h4>Consistency Score</h4>
<div class="metric-value">{perf_insights['consistency_score']}%</div>
<div class="metric-sub">Daily activity stability</div>
</div>
<div class="metric-card">
<h4>Error Rate</h4>
<div class="metric-value">{perf_insights['error_rate_percent']}%</div>
<div class="metric-sub">{perf_insights['total_corrections']:,} corrections</div>
</div>
<div class="metric-card">
<h4>Characters Typed</h4>
<div class="metric-value">{wpm_stats['characters_typed']:,}</div>
<div class="metric-sub">{perf_insights['letters_typed']:,} letters • {perf_insights['spaces_typed']:,} spaces</div>
</div>
</div>
</div>
<div class="section">
<h2>Hourly Activity Distribution</h2>
<p>Average key press volume by hour of day (00:00–23:00) across the complete dataset.</p>
<div class="chart-container">
<canvas id="hourlyChart"></canvas>
</div>
</div>
<div class="section">
<h2>Daily Activity Timeline (Last 365 Days)</h2>
<p>Daily key press totals over the past year, illustrating long-term usage patterns.</p>
<div class="chart-container">
<canvas id="dailyChart"></canvas>
</div>
</div>
<div class="grid-2col">
<div class="section">
<h2>Activity by Day of Week</h2>
<p>Distribution of typing activity across weekdays, highlighting weekly patterns.</p>
<div class="chart-container">
<canvas id="weekdayChart"></canvas>
</div>
</div>
<div class="section">
<h2>Monthly Activity Trends</h2>
<p>Monthly key press totals showing seasonal and long-term productivity trends.</p>
<div class="chart-container">
<canvas id="monthlyChart"></canvas>
</div>
</div>
</div>
<div class="section">
<h2>Top 30 Most Frequently Used Keys</h2>
<p>Ranking of the most pressed keys by total count, excluding system keys.</p>
<div class="chart-container">
<div id="topKeysChart"></div>
</div>
</div>
<div class="section">
<h2>Keyboard Usage Heatmap</h2>
<p>Visual intensity map of key usage. Color intensity corresponds to press frequency.</p>
<div class="chart-container">
<div class="keyboard-grid" id="keyboardHeatmap"></div>
<div class="legend">
<span>Low Activity</span>
<div class="legend-gradient"></div>
<span>High Activity</span>
</div>
</div>
</div>
<div class="section">
<h2>Hour × Day Activity Matrix (Last 30 Days)</h2>
<p>Detailed view of typing patterns by hour and day. Each cell represents one hour of activity.</p>
<div class="chart-container">
<div class="heatmap-grid" id="hourDayHeatmap"></div>
<div class="legend-labels">
<span>00:00</span>
<span>06:00</span>
<span>12:00</span>
<span>18:00</span>
<span>23:00</span>
</div>
</div>
</div>
<div class="section">
<h2>Keypresses Per Hour Timeline</h2>
<p>Hourly key press volume over time, highlighting peak productivity periods.</p>
<div class="chart-container">
<canvas id="typingSpeedChart"></canvas>
</div>
</div>
<div class="grid-2col">
<div class="section">
<h2>Letter Frequency Analysis</h2>
<p>Relative frequency of letter usage (A-Z), case-insensitive.</p>
<div class="chart-container">
<canvas id="letterChart"></canvas>
</div>
</div>
<div class="section">
<h2>Number Key Usage</h2>
<p>Frequency distribution of numeric key usage (0-9).</p>
<div class="chart-container">
<canvas id="numberChart"></canvas>
</div>
</div>
</div>
<div class="section">
<h2>Peak Activity Periods</h2>
<p>Top 10 one-hour periods with highest key press volume.</p>
<div class="chart-container">
<table>
<thead>
<tr>
<th>Rank</th>
<th>Time Period</th>
<th>Key Presses</th>
</tr>
</thead>
<tbody>
{''.join(f'<tr><td>{i+1}</td><td>{time}</td><td>{count:,}</td></tr>' for i, (time, count) in enumerate(peak_times))}
</tbody>
</table>
</div>
</div>
</div>
<footer>
<p>Report generated on {datetime.now().strftime('%Y-%m-%d at %H:%M:%S')}</p>
<p>Tikker Keyboard Performance Analytics © {datetime.now().year} • Enterprise Edition</p>
</footer>
</div>
<div class="tooltip" id="tooltip"></div>
<script>
const hourlyData = {json.dumps([[h, c] for h, c in hourly_data])};
const dailyData = {json.dumps([[d, c] for d, c in daily_data])};
const weekdayData = {json.dumps([[w, c] for w, c in weekday_data])};
const topKeysData = {json.dumps([[k, c] for k, c in top_keys])};
const keyboardHeatmapData = {json.dumps(keyboard_heatmap)};
const hourDayHeatmapData = {json.dumps([[d, h, c] for d, h, c in hour_day_heatmap])};
const typingSpeedData = {json.dumps([[t, c] for t, c in typing_speed])};
const letterFrequency = {json.dumps(char_frequency['letters'])};
const numberFrequency = {json.dumps(char_frequency['numbers'])};
const monthlyData = {json.dumps([[m, c] for m, c in monthly_stats])};
const tooltip = document.getElementById('tooltip');
function createBarChart(canvasId, labels, data, label, color) {{
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const width = canvas.parentElement.clientWidth;
const height = 400;
canvas.width = width;
canvas.height = height;
const maxValue = Math.max(...data);
const padding = 60;
const barWidth = (width - padding * 2) / data.length;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {{
const y = padding + (height - padding * 2) * i / 5;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(width - padding, y);
ctx.stroke();
ctx.fillStyle = '#64748b';
ctx.font = '12px Inter';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxValue * (1 - i / 5)).toLocaleString(), padding - 10, y + 4);
}}
const chartRects = [];
data.forEach((value, index) => {{
const barHeight = (value / maxValue) * (height - padding * 2);
const x = padding + index * barWidth;
const y = height - padding - barHeight;
const gradient = ctx.createLinearGradient(x, y, x, height - padding);
gradient.addColorStop(0, color);
gradient.addColorStop(1, color + 'cc');
ctx.fillStyle = gradient;
ctx.fillRect(x + barWidth * 0.1, y, barWidth * 0.8, barHeight);
chartRects.push({{
x: x + barWidth * 0.1,
y: y,
width: barWidth * 0.8,
height: barHeight,
label: labels[index],
value: value.toLocaleString()
}});
if (data.length <= 24) {{
ctx.save();
ctx.translate(x + barWidth * 0.5, height - padding + 20);
ctx.rotate(-Math.PI / 3);
ctx.fillStyle = '#64748b';
ctx.font = '11px Inter';
ctx.textAlign = 'right';
ctx.fillText(labels[index], 0, 0);
ctx.restore();
}}
}});
canvas.addEventListener('mousemove', (e) => {{
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let found = false;
for (const bar of chartRects) {{
if (x >= bar.x && x <= bar.x + bar.width && y >= bar.y && y <= bar.y + bar.height) {{
tooltip.innerHTML = `<strong>${{bar.label}}</strong><br>${{bar.value}} key presses`;
tooltip.style.opacity = '1';
tooltip.style.left = `${{e.pageX + 12}}px`;
tooltip.style.top = `${{e.pageY + 12}}px`;
found = true;
break;
}}
}}
if (!found) {{ tooltip.style.opacity = '0'; }}
}});
canvas.addEventListener('mouseout', () => {{ tooltip.style.opacity = '0'; }});
}}
function createLineChart(canvasId, labels, data, label, color) {{
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const width = canvas.parentElement.clientWidth;
const height = 400;
canvas.width = width;
canvas.height = height;
const maxValue = Math.max(...data);
const padding = 60;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {{
const y = padding + (height - padding * 2) * i / 5;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(width - padding, y);
ctx.stroke();
ctx.fillStyle = '#64748b';
ctx.font = '12px Inter';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxValue * (1 - i / 5)).toLocaleString(), padding - 10, y + 4);
}}
ctx.strokeStyle = color;
ctx.lineWidth = 2.5;
ctx.beginPath();
const chartPoints = [];
data.forEach((value, index) => {{
const x = padding + (index / (data.length - 1)) * (width - padding * 2);
const y = height - padding - (value / maxValue) * (height - padding * 2);
if (index === 0) {{
ctx.moveTo(x, y);
}} else {{
ctx.lineTo(x, y);
}}
chartPoints.push({{
x: x,
y: y,
radius: 8,
label: labels[index],
value: value.toLocaleString()
}});
}});
ctx.stroke();
ctx.fillStyle = color + '15';
ctx.lineTo(width - padding, height - padding);
ctx.lineTo(padding, height - padding);
ctx.closePath();
ctx.fill();
ctx.fillStyle = color;
chartPoints.forEach(point => {{
ctx.beginPath();
ctx.arc(point.x, point.y, 4, 0, 2 * Math.PI);
ctx.fill();
}});
canvas.addEventListener('mousemove', (e) => {{
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let found = false;
for (const point of chartPoints) {{
const distance = Math.sqrt(Math.pow(x - point.x, 2) + Math.pow(y - point.y, 2));
if (distance <= point.radius) {{
tooltip.innerHTML = `<strong>${{point.label}}</strong><br>${{point.value}} key presses`;
tooltip.style.opacity = '1';
tooltip.style.left = `${{e.pageX + 12}}px`;
tooltip.style.top = `${{e.pageY + 12}}px`;
found = true;
break;
}}
}}
if (!found) {{ tooltip.style.opacity = '0'; }}
}});
canvas.addEventListener('mouseout', () => {{ tooltip.style.opacity = '0'; }});
}}
function createPieChart(canvasId, labels, data, colors) {{
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const width = canvas.parentElement.clientWidth;
const height = 400;
canvas.width = width;
canvas.height = height;
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 2 - 60;
const total = data.reduce((sum, val) => sum + val, 0);
let currentAngle = -Math.PI / 2;
const chartSlices = [];
data.forEach((value, index) => {{
const sliceAngle = (value / total) * 2 * Math.PI;
ctx.fillStyle = colors[index % colors.length];
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + sliceAngle);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
chartSlices.push({{
startAngle: currentAngle,
endAngle: currentAngle + sliceAngle,
label: labels[index],
value: value.toLocaleString(),
percent: (value / total * 100).toFixed(1)
}});
const labelAngle = currentAngle + sliceAngle / 2;
const labelX = centerX + Math.cos(labelAngle) * (radius * 0.65);
const labelY = centerY + Math.sin(labelAngle) * (radius * 0.65);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 13px Inter';
ctx.textAlign = 'center';
ctx.fillText(labels[index].substring(0, 3), labelX, labelY);
currentAngle += sliceAngle;
}});
canvas.addEventListener('mousemove', (e) => {{
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left - centerX;
const y = e.clientY - rect.top - centerY;
const distance = Math.sqrt(x*x + y*y);
let angle = Math.atan2(y, x);
if (angle < -Math.PI / 2) {{
angle += 2 * Math.PI;
}}
let found = false;
if (distance <= radius) {{
for (const slice of chartSlices) {{
if (angle >= slice.startAngle && angle <= slice.endAngle) {{
tooltip.innerHTML = `<strong>${{slice.label}}</strong><br>${{slice.value}} presses (${{slice.percent}}%)`;
tooltip.style.opacity = '1';
tooltip.style.left = `${{e.pageX + 12}}px`;
tooltip.style.top = `${{e.pageY + 12}}px`;
found = true;
break;
}}
}}
}}
if (!found) {{ tooltip.style.opacity = '0'; }}
}});
canvas.addEventListener('mouseout', () => {{ tooltip.style.opacity = '0'; }});
}}
createBarChart('hourlyChart',
hourlyData.map(d => d[0].padStart(2, '0') + ':00'),
hourlyData.map(d => d[1]),
'Key Presses',
'#4f46e5');
createLineChart('dailyChart',
dailyData.map(d => d[0]),
dailyData.map(d => d[1]),
'Daily Activity',
'#10b981');
const weekdayColors = ['#ef4444', '#f97316', '#fbbf24', '#a3e635', '#22d3ee', '#818cf8', '#c084fc'];
createPieChart('weekdayChart',
weekdayData.map(d => d[0]),
weekdayData.map(d => d[1]),
weekdayColors);
createBarChart('monthlyChart',
monthlyData.map(d => d[0]),
monthlyData.map(d => d[1]),
'Monthly Key Presses',
'#0ea5e9');
const topKeysContainer = document.getElementById('topKeysChart');
const maxKeyValue = Math.max(...topKeysData.map(d => d[1]));
topKeysData.forEach(([key, count]) => {{
const bar = document.createElement('div');
bar.className = 'bar-horizontal';
bar.style.width = (count / maxKeyValue * 100) + '%';
const label = document.createElement('span');
label.className = 'bar-label';
label.textContent = key.length === 1 ? key : key.substring(0, 12);
const value = document.createElement('span');
value.className = 'bar-value';
value.textContent = count.toLocaleString();
bar.appendChild(label);
bar.appendChild(value);
bar.title = `${{key}}: ${{count.toLocaleString()}} presses`;
topKeysContainer.appendChild(bar);
}});
const keyboardLayout = [
['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\\\'],
['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"],
['z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/']
];
const keyboardContainer = document.getElementById('keyboardHeatmap');
const allCounts = Object.values(keyboardHeatmapData).sort((a, b) => a - b);
const percentileMax = allCounts[Math.floor(allCounts.length * 0.98)];
function getHeatColor(value, max) {{
if (value === 0) return '#e2e8f0';
const logValue = Math.log(value + 1);
const logMax = Math.log(max + 1);
const ratio = logValue / logMax;
const hue = (1 - ratio) * 240;
return `hsl(${{hue}}, 100%, 50%)`;
}}
keyboardLayout.forEach(row => {{
row.forEach(key => {{
const keyEl = document.createElement('div');
keyEl.className = 'key';
keyEl.textContent = key;
const count = keyboardHeatmapData[key] || keyboardHeatmapData[key.toUpperCase()] || 0;
keyEl.style.background = count > 0 ? getHeatColor(count, percentileMax) : '#f1f5f9';
keyEl.style.color = count > 5000 ? 'white' : '#1e293b';
keyEl.title = `${{key}}: ${{count.toLocaleString()}} presses`;
keyboardContainer.appendChild(keyEl);
}});
}});
const heatmapContainer = document.getElementById('hourDayHeatmap');
const heatmapMax = Math.max(...hourDayHeatmapData.map(d => d[2]));
const uniqueDays = [...new Set(hourDayHeatmapData.map(d => d[0]))].sort();
const heatmapMap = {{}};
hourDayHeatmapData.forEach(([day, hour, count]) => {{
heatmapMap[`${{day}}-${{hour}}`] = count;
}});
uniqueDays.forEach(day => {{
for (let hour = 0; hour < 24; hour++) {{
const key = `${{day}}-${{hour.toString().padStart(2, '0')}}`;
const count = heatmapMap[key] || 0;
const cell = document.createElement('div');
cell.className = 'heatmap-cell';
cell.style.background = count > 0 ? getHeatColor(count, heatmapMax) : '#e2e8f0';
cell.title = `${{day}} ${{hour}}:00 — ${{count.toLocaleString()}} presses`;
heatmapContainer.appendChild(cell);
}}
}});
createLineChart('typingSpeedChart',
typingSpeedData.map(d => d[0]),
typingSpeedData.map(d => d[1]),
'Key Presses per Hour',
'#f59e0b');
createBarChart('letterChart',
Object.keys(letterFrequency),
Object.values(letterFrequency),
'Letter Frequency',
'#10b981');
createBarChart('numberChart',
Object.keys(numberFrequency),
Object.values(numberFrequency),
'Number Usage',
'#f97316');
window.addEventListener('resize', () => {{
['hourlyChart', 'dailyChart', 'weekdayChart', 'monthlyChart', 'typingSpeedChart', 'letterChart', 'numberChart'].forEach(id => {{
const canvas = document.getElementById(id);
if (canvas) canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
}});
createBarChart('hourlyChart', hourlyData.map(d => d[0].padStart(2, '0') + ':00'), hourlyData.map(d => d[1]), 'Key Presses', '#4f46e5');
createLineChart('dailyChart', dailyData.map(d => d[0]), dailyData.map(d => d[1]), 'Daily Activity', '#10b981');
createPieChart('weekdayChart', weekdayData.map(d => d[0]), weekdayData.map(d => d[1]), weekdayColors);
createBarChart('monthlyChart', monthlyData.map(d => d[0]), monthlyData.map(d => d[1]), 'Monthly Key Presses', '#0ea5e9');
createLineChart('typingSpeedChart', typingSpeedData.map(d => d[0]), typingSpeedData.map(d => d[1]), 'Key Presses per Hour', '#f59e0b');
createBarChart('letterChart', Object.keys(letterFrequency), Object.values(letterFrequency), 'Letter Frequency', '#10b981');
createBarChart('numberChart', Object.keys(numberFrequency), Object.values(numberFrequency), 'Number Usage', '#f97316');
}});
</script>
</body>
</html>"""
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html)
overall_duration = time.time() - overall_start
logging.info(f"Enterprise HTML report generated in {overall_duration:.2f} seconds: {output_file}")
logging.info(f"Total events analyzed: {total_events:,}")
print(f"Enterprise performance report generated: {output_file}")
if __name__ == '__main__':
db_path = sys.argv[1] if len(sys.argv) > 1 else 'tikker.db'
output_path = sys.argv[2] if len(sys.argv) > 2 else 'tikker_report.html'
visualizer = TikkerVisualizer(db_path)
visualizer.generate_html(output_path)
visualizer.close()