1497 lines
57 KiB
Python
1497 lines
57 KiB
Python
|
|
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()
|