Projects / tikker / tikker_viz.py

git clone https://molodetz.nl/retoor/tikker.git

Raw source file available here .

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"""
en">

UTF-8">
viewport" content="width=device-width, initial-scale=1.0">
Tikker Keyboard Performance Report
https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

: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;
}}
}}



container">

Tikker Keyboard Performance Report


Comprehensive Analysis of Typing Speed, Accuracy, and Productivity Metrics




stats-grid">
stat-card" title="Total number of keyboard events recorded (presses and releases)">

Total Events


value">{total_events:,}
label">Recorded Actions

stat-card" title="Number of key press events, primary metric for activity analysis">

Key Presses


value">{pressed_events:,}
label">Keys Pressed

stat-card" title="Average words per minute across entire tracking period">

Average WPM


value">{wpm_stats['wpm_average']}
label">Words Per Minute

stat-card" title="Peak words per minute in a single hour">

Peak Hour WPM


value">{wpm_stats['wpm_peak_hour']}
label">Max Speed

stat-card" title="Duration between first and last recorded event">

Tracking Period


value">{(datetime.fromisoformat(date_range[1]) - datetime.fromisoformat(date_range[0])).days if date_range[0] and date_range[1] else 0}
label">Days of Data

stat-card" title="Estimated total words typed (5 characters per word)">

Words Typed


value">{wpm_stats['words_typed']:,}
label">Total Output



content">
durations">

Data Retrieval Performance



    {''.join(f'
  • {key.replace("
  • _", " ").title()}: {value:.3f}s' for key, value in durations.items())}




    section">

    Typing Speed & Performance


    Detailed breakdown of words per minute, efficiency, and accuracy metrics.


    performance-metrics">
    metric-card">

    Average WPM


    metric-value">{wpm_stats['wpm_average']}
    metric-sub">Over {wpm_stats['total_typing_time_minutes']} minutes

    metric-card">

    Peak Hour WPM


    metric-value">{wpm_stats['wpm_peak_hour']}
    metric-sub">{wpm_stats['peak_hour'] or 'N/A'}

    metric-card">

    Typing Efficiency


    metric-value">{wpm_stats['typing_efficiency']}
    metric-sub">Presses per active minute

    metric-card">

    Consistency Score


    metric-value">{perf_insights['consistency_score']}%
    metric-sub">Daily activity stability

    metric-card">

    Error Rate


    metric-value">{perf_insights['error_rate_percent']}%
    metric-sub">{perf_insights['total_corrections']:,} corrections

    metric-card">

    Characters Typed


    metric-value">{wpm_stats['characters_typed']:,}
    metric-sub">{perf_insights['letters_typed']:,} letters • {perf_insights['spaces_typed']:,} spaces




    section">

    Hourly Activity Distribution


    Average key press volume by hour of day (00:00–23:00) across the complete dataset.


    chart-container">
    hourlyChart">



    section">

    Daily Activity Timeline (Last 365 Days)


    Daily key press totals over the past year, illustrating long-term usage patterns.


    chart-container">
    dailyChart">



    grid-2col">
    section">

    Activity by Day of Week


    Distribution of typing activity across weekdays, highlighting weekly patterns.


    chart-container">
    weekdayChart">



    section">

    Monthly Activity Trends


    Monthly key press totals showing seasonal and long-term productivity trends.


    chart-container">
    monthlyChart">




    section">

    Top 30 Most Frequently Used Keys


    Ranking of the most pressed keys by total count, excluding system keys.


    chart-container">
    topKeysChart">



    section">

    Keyboard Usage Heatmap


    Visual intensity map of key usage. Color intensity corresponds to press frequency.


    chart-container">
    keyboard-grid" id="keyboardHeatmap">
    legend">
    Low Activity
    legend-gradient">
    High Activity




    section">

    Hour × Day Activity Matrix (Last 30 Days)


    Detailed view of typing patterns by hour and day. Each cell represents one hour of activity.


    chart-container">
    heatmap-grid" id="hourDayHeatmap">
    legend-labels">
    00:00
    06:00
    12:00
    18:00
    23:00




    section">

    Keypresses Per Hour Timeline


    Hourly key press volume over time, highlighting peak productivity periods.


    chart-container">
    typingSpeedChart">



    grid-2col">
    section">

    Letter Frequency Analysis


    Relative frequency of letter usage (A-Z), case-insensitive.


    chart-container">
    letterChart">



    section">

    Number Key Usage


    Frequency distribution of numeric key usage (0-9).


    chart-container">
    numberChart">




    section">

    Peak Activity Periods


    Top 10 one-hour periods with highest key press volume.


    chart-container">



    Rank
    Time Period
    Key Presses



    {''.join(f'{i+1}{time}{count:,}' for i, (time, count) in enumerate(peak_times))}







    Report generated on {datetime.now().strftime('%Y-%m-%d at %H:%M:%S')}


    Tikker Keyboard Performance Analytics © {datetime.now().year} • Enterprise Edition





    tooltip" id="tooltip">


    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 = `${{bar.label}}
    ${{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 = `${{point.label}}
    ${{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 = `${{slice.label}}
    ${{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');
    }});


    """

    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()