chore: added digraph aggregation and profile building
d61e209a
4 file(s) · +353 −6
| 2 | 2 | import { round } from './lib/utils'; |
|
| 3 | 3 | import TabBar from './lib/components/TabBar.svelte'; |
|
| 4 | 4 | import CapturePanel from './lib/components/CapturePanel.svelte'; |
|
| 5 | + | import ProfileSummary from './lib/components/ProfileSummary.svelte'; |
|
| 5 | 6 | ||
| 6 | 7 | let pendingKeys = new Map(); |
|
| 7 | 8 | let lastCompleted = null; |
|
| 9 | 10 | let typedText = ''; |
|
| 10 | 11 | let activeTab = 'capture'; |
|
| 11 | 12 | ||
| 13 | + | // Session metadata |
|
| 14 | + | let totalKeystrokes = 0; |
|
| 15 | + | let backspaceCount = 0; |
|
| 16 | + | let pauseCount = 0; |
|
| 17 | + | let firstKeydownTime = null; |
|
| 18 | + | let lastKeyupTime = null; |
|
| 19 | + | let lastKeydownTime = null; |
|
| 20 | + | let pasteCount = 0; |
|
| 21 | + | let pastedCharCount = 0; |
|
| 22 | + | ||
| 23 | + | $: sessionDurationMs = (firstKeydownTime && lastKeyupTime) |
|
| 24 | + | ? Math.round(lastKeyupTime - firstKeydownTime) |
|
| 25 | + | : 0; |
|
| 26 | + | ||
| 27 | + | $: avgTypingSpeed = (sessionDurationMs > 0) |
|
| 28 | + | ? Math.round(totalKeystrokes / (sessionDurationMs / 60000)) |
|
| 29 | + | : 0; |
|
| 30 | + | ||
| 31 | + | $: sessionMetadata = { |
|
| 32 | + | totalKeystrokes, |
|
| 33 | + | backspaceCount, |
|
| 34 | + | pauseCount, |
|
| 35 | + | avgTypingSpeed, |
|
| 36 | + | sessionDurationMs, |
|
| 37 | + | pasteCount, |
|
| 38 | + | pastedCharCount, |
|
| 39 | + | }; |
|
| 40 | + | ||
| 12 | 41 | const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock']); |
|
| 13 | 42 | ||
| 14 | 43 | function handleKeyDown(e) { |
|
| 15 | 44 | if (MODIFIER_KEYS.has(e.key)) return; |
|
| 16 | 45 | if (pendingKeys.has(e.key)) return; |
|
| 17 | - | pendingKeys.set(e.key, { pressTime: performance.now() }); |
|
| 46 | + | ||
| 47 | + | const now = performance.now(); |
|
| 48 | + | pendingKeys.set(e.key, { pressTime: now }); |
|
| 49 | + | ||
| 50 | + | // Session metadata tracking |
|
| 51 | + | totalKeystrokes++; |
|
| 52 | + | if (e.key === 'Backspace') backspaceCount++; |
|
| 53 | + | if (!firstKeydownTime) firstKeydownTime = now; |
|
| 54 | + | if (lastKeydownTime && (now - lastKeydownTime) > 500) pauseCount++; |
|
| 55 | + | lastKeydownTime = now; |
|
| 18 | 56 | } |
|
| 19 | 57 | ||
| 20 | 58 | function handleKeyUp(e) { |
|
| 24 | 62 | pendingKeys.delete(e.key); |
|
| 25 | 63 | ||
| 26 | 64 | const releaseTime = performance.now(); |
|
| 65 | + | lastKeyupTime = releaseTime; |
|
| 27 | 66 | const currentEvent = { key: e.key, pressTime: pending.pressTime, releaseTime }; |
|
| 28 | 67 | ||
| 29 | 68 | if (lastCompleted) { |
|
| 55 | 94 | } |
|
| 56 | 95 | ||
| 57 | 96 | lastCompleted = currentEvent; |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | function handlePaste(e) { |
|
| 100 | + | const nativeEvent = e.detail; |
|
| 101 | + | pasteCount++; |
|
| 102 | + | const text = nativeEvent.clipboardData?.getData('text') || ''; |
|
| 103 | + | pastedCharCount += text.length; |
|
| 58 | 104 | } |
|
| 59 | 105 | ||
| 60 | 106 | function clear() { |
|
| 62 | 108 | lastCompleted = null; |
|
| 63 | 109 | pendingKeys = new Map(); |
|
| 64 | 110 | typedText = ''; |
|
| 111 | + | totalKeystrokes = 0; |
|
| 112 | + | backspaceCount = 0; |
|
| 113 | + | pauseCount = 0; |
|
| 114 | + | firstKeydownTime = null; |
|
| 115 | + | lastKeyupTime = null; |
|
| 116 | + | lastKeydownTime = null; |
|
| 117 | + | pasteCount = 0; |
|
| 118 | + | pastedCharCount = 0; |
|
| 65 | 119 | } |
|
| 66 | 120 | </script> |
|
| 67 | 121 | ||
| 76 | 130 | <TabBar bind:activeTab /> |
|
| 77 | 131 | ||
| 78 | 132 | {#if activeTab === 'capture'} |
|
| 79 | - | <CapturePanel bind:digraphs bind:typedText /> |
|
| 133 | + | <CapturePanel bind:digraphs bind:typedText on:paste={handlePaste} /> |
|
| 80 | 134 | {#if digraphs.length > 0} |
|
| 81 | 135 | <button class="clear-btn" on:click={clear}>clear session</button> |
|
| 82 | 136 | {/if} |
|
| 83 | 137 | {:else if activeTab === 'profile'} |
|
| 84 | - | <div class="placeholder"> |
|
| 85 | - | <p>profile builder</p> |
|
| 86 | - | <span class="placeholder-sub">coming in step 1</span> |
|
| 87 | - | </div> |
|
| 138 | + | <ProfileSummary {digraphs} metadata={sessionMetadata} /> |
|
| 88 | 139 | {:else if activeTab === 'compare'} |
|
| 89 | 140 | <div class="placeholder"> |
|
| 90 | 141 | <p>session comparison</p> |
|
| 1 | + | import type { RawDigraph, MetricStats, DigraphAggregation } from './types'; |
|
| 2 | + | import { std } from './utils'; |
|
| 3 | + | ||
| 4 | + | function normalizeKey(key: string): string { |
|
| 5 | + | if (key === ' ') return '␣'; |
|
| 6 | + | if (key === 'Backspace') return '⌫'; |
|
| 7 | + | if (key === 'Enter') return '⏎'; |
|
| 8 | + | return key.toLowerCase(); |
|
| 9 | + | } |
|
| 10 | + | ||
| 11 | + | export function normalizeDigraphKey(key1: string, key2: string): string { |
|
| 12 | + | return `${normalizeKey(key1)} → ${normalizeKey(key2)}`; |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | export function computeMetricStats(values: number[]): MetricStats { |
|
| 16 | + | if (values.length === 0) { |
|
| 17 | + | return { mean: 0, std: 0, min: 0, max: 0, count: 0 }; |
|
| 18 | + | } |
|
| 19 | + | const count = values.length; |
|
| 20 | + | const mean = values.reduce((s, v) => s + v, 0) / count; |
|
| 21 | + | return { |
|
| 22 | + | mean: Math.round(mean * 10) / 10, |
|
| 23 | + | std: Math.round(std(values) * 10) / 10, |
|
| 24 | + | min: Math.round(Math.min(...values) * 10) / 10, |
|
| 25 | + | max: Math.round(Math.max(...values) * 10) / 10, |
|
| 26 | + | count, |
|
| 27 | + | }; |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | const EXCLUDED_KEYS = new Set([ |
|
| 31 | + | 'ArrowUp', |
|
| 32 | + | 'ArrowDown', |
|
| 33 | + | 'ArrowLeft', |
|
| 34 | + | 'ArrowRight', |
|
| 35 | + | 'Home', |
|
| 36 | + | 'End', |
|
| 37 | + | 'PageUp', |
|
| 38 | + | 'PageDown', |
|
| 39 | + | 'Insert', |
|
| 40 | + | 'Delete', |
|
| 41 | + | 'Escape', |
|
| 42 | + | 'Tab', |
|
| 43 | + | 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', |
|
| 44 | + | 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', |
|
| 45 | + | ]); |
|
| 46 | + | ||
| 47 | + | function shouldExclude(key: string): boolean { |
|
| 48 | + | return EXCLUDED_KEYS.has(key); |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | export function aggregateDigraphs(digraphs: RawDigraph[]): DigraphAggregation[] { |
|
| 52 | + | const groups = new Map<string, RawDigraph[]>(); |
|
| 53 | + | ||
| 54 | + | for (const d of digraphs) { |
|
| 55 | + | if (shouldExclude(d.key1) || shouldExclude(d.key2)) continue; |
|
| 56 | + | const nk = normalizeDigraphKey(d.key1, d.key2); |
|
| 57 | + | const group = groups.get(nk); |
|
| 58 | + | if (group) { |
|
| 59 | + | group.push(d); |
|
| 60 | + | } else { |
|
| 61 | + | groups.set(nk, [d]); |
|
| 62 | + | } |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | const aggregations: DigraphAggregation[] = []; |
|
| 66 | + | ||
| 67 | + | for (const [normalizedKeys, group] of groups) { |
|
| 68 | + | const agg: DigraphAggregation = { |
|
| 69 | + | normalizedKeys, |
|
| 70 | + | count: group.length, |
|
| 71 | + | holdTime1: computeMetricStats(group.map((d) => d.holdTime1)), |
|
| 72 | + | holdTime2: computeMetricStats(group.map((d) => d.holdTime2)), |
|
| 73 | + | pressPress: computeMetricStats(group.map((d) => d.pressPress)), |
|
| 74 | + | releaseRelease: computeMetricStats(group.map((d) => d.releaseRelease)), |
|
| 75 | + | pressRelease: computeMetricStats(group.map((d) => d.pressRelease)), |
|
| 76 | + | releasePress: computeMetricStats(group.map((d) => d.releasePress)), |
|
| 77 | + | }; |
|
| 78 | + | aggregations.push(agg); |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | aggregations.sort((a, b) => b.count - a.count); |
|
| 82 | + | return aggregations; |
|
| 83 | + | } |
| 1 | 1 | <script> |
|
| 2 | + | import { createEventDispatcher } from 'svelte'; |
|
| 2 | 3 | import StatsBar from './StatsBar.svelte'; |
|
| 3 | 4 | ||
| 4 | 5 | export let digraphs = []; |
|
| 5 | 6 | export let typedText = ''; |
|
| 7 | + | ||
| 8 | + | const dispatch = createEventDispatcher(); |
|
| 6 | 9 | ||
| 7 | 10 | let copied = false; |
|
| 8 | 11 | ||
| 33 | 36 | bind:value={typedText} |
|
| 34 | 37 | placeholder="start typing..." |
|
| 35 | 38 | rows="4" |
|
| 39 | + | on:paste={(e) => dispatch('paste', e)} |
|
| 36 | 40 | ></textarea> |
|
| 37 | 41 | ||
| 38 | 42 | {#if digraphs.length > 0} |
|
| 1 | + | <script> |
|
| 2 | + | import { aggregateDigraphs } from '../aggregation'; |
|
| 3 | + | ||
| 4 | + | export let digraphs = []; |
|
| 5 | + | export let metadata = null; |
|
| 6 | + | ||
| 7 | + | let showAll = false; |
|
| 8 | + | ||
| 9 | + | $: aggregations = aggregateDigraphs(digraphs); |
|
| 10 | + | $: displayAggs = showAll ? aggregations : aggregations.slice(0, 15); |
|
| 11 | + | $: hasMore = aggregations.length > 15; |
|
| 12 | + | ||
| 13 | + | function fmt(stats) { |
|
| 14 | + | return `${stats.mean} ±${stats.std}`; |
|
| 15 | + | } |
|
| 16 | + | </script> |
|
| 17 | + | ||
| 18 | + | {#if digraphs.length === 0} |
|
| 19 | + | <div class="empty"> |
|
| 20 | + | <p>no digraphs captured yet</p> |
|
| 21 | + | <span class="empty-sub">type in the textarea on the capture tab to build your profile</span> |
|
| 22 | + | </div> |
|
| 23 | + | {:else} |
|
| 24 | + | {#if metadata} |
|
| 25 | + | <div class="metadata-bar"> |
|
| 26 | + | <div class="meta-item"> |
|
| 27 | + | <span class="meta-val">{metadata.totalKeystrokes}</span> |
|
| 28 | + | <span class="meta-label">keystrokes</span> |
|
| 29 | + | </div> |
|
| 30 | + | <div class="meta-item"> |
|
| 31 | + | <span class="meta-val">{metadata.backspaceCount}</span> |
|
| 32 | + | <span class="meta-label">backspaces</span> |
|
| 33 | + | </div> |
|
| 34 | + | <div class="meta-item"> |
|
| 35 | + | <span class="meta-val">{metadata.pauseCount}</span> |
|
| 36 | + | <span class="meta-label">pauses</span> |
|
| 37 | + | </div> |
|
| 38 | + | <div class="meta-item"> |
|
| 39 | + | <span class="meta-val">{metadata.avgTypingSpeed}<span class="unit-sm"> cpm</span></span> |
|
| 40 | + | <span class="meta-label">speed</span> |
|
| 41 | + | </div> |
|
| 42 | + | <div class="meta-item"> |
|
| 43 | + | <span class="meta-val">{(metadata.sessionDurationMs / 1000).toFixed(1)}<span class="unit-sm">s</span></span> |
|
| 44 | + | <span class="meta-label">duration</span> |
|
| 45 | + | </div> |
|
| 46 | + | <div class="meta-item"> |
|
| 47 | + | <span class="meta-val">{aggregations.length}</span> |
|
| 48 | + | <span class="meta-label">unique pairs</span> |
|
| 49 | + | </div> |
|
| 50 | + | </div> |
|
| 51 | + | {/if} |
|
| 52 | + | ||
| 53 | + | <div class="agg-table-wrap"> |
|
| 54 | + | <table class="agg-table"> |
|
| 55 | + | <thead> |
|
| 56 | + | <tr> |
|
| 57 | + | <th>keys</th> |
|
| 58 | + | <th>n</th> |
|
| 59 | + | <th>HT1</th> |
|
| 60 | + | <th>HT2</th> |
|
| 61 | + | <th>PP</th> |
|
| 62 | + | <th>RR</th> |
|
| 63 | + | <th>PR</th> |
|
| 64 | + | <th>RP</th> |
|
| 65 | + | </tr> |
|
| 66 | + | </thead> |
|
| 67 | + | <tbody> |
|
| 68 | + | {#each displayAggs as a} |
|
| 69 | + | <tr> |
|
| 70 | + | <td class="keys-cell">{a.normalizedKeys}</td> |
|
| 71 | + | <td class="count-cell">{a.count}</td> |
|
| 72 | + | <td>{fmt(a.holdTime1)}</td> |
|
| 73 | + | <td>{fmt(a.holdTime2)}</td> |
|
| 74 | + | <td>{fmt(a.pressPress)}</td> |
|
| 75 | + | <td>{fmt(a.releaseRelease)}</td> |
|
| 76 | + | <td>{fmt(a.pressRelease)}</td> |
|
| 77 | + | <td>{fmt(a.releasePress)}</td> |
|
| 78 | + | </tr> |
|
| 79 | + | {/each} |
|
| 80 | + | </tbody> |
|
| 81 | + | </table> |
|
| 82 | + | </div> |
|
| 83 | + | ||
| 84 | + | {#if hasMore} |
|
| 85 | + | <button class="toggle-btn" on:click={() => (showAll = !showAll)}> |
|
| 86 | + | {showAll ? `show top 15` : `show all ${aggregations.length}`} |
|
| 87 | + | </button> |
|
| 88 | + | {/if} |
|
| 89 | + | {/if} |
|
| 90 | + | ||
| 91 | + | <style> |
|
| 92 | + | .empty { |
|
| 93 | + | display: flex; |
|
| 94 | + | flex-direction: column; |
|
| 95 | + | align-items: center; |
|
| 96 | + | justify-content: center; |
|
| 97 | + | padding: 4rem 2rem; |
|
| 98 | + | border: 1px dashed #333; |
|
| 99 | + | gap: 0.5rem; |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | .empty p { |
|
| 103 | + | font-size: 14px; |
|
| 104 | + | color: #888; |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | .empty-sub { |
|
| 108 | + | font-size: 11px; |
|
| 109 | + | color: #555; |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | .metadata-bar { |
|
| 113 | + | display: grid; |
|
| 114 | + | grid-template-columns: repeat(6, 1fr); |
|
| 115 | + | border: 1px solid #333; |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | .meta-item { |
|
| 119 | + | display: flex; |
|
| 120 | + | flex-direction: column; |
|
| 121 | + | align-items: center; |
|
| 122 | + | padding: 0.75rem 0.5rem; |
|
| 123 | + | border-right: 1px solid #333; |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | .meta-item:last-child { |
|
| 127 | + | border-right: none; |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | .meta-val { |
|
| 131 | + | font-size: 14px; |
|
| 132 | + | font-weight: 700; |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | .meta-label { |
|
| 136 | + | font-size: 10px; |
|
| 137 | + | color: #888; |
|
| 138 | + | margin-top: 0.15rem; |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | .unit-sm { |
|
| 142 | + | font-size: 10px; |
|
| 143 | + | color: #888; |
|
| 144 | + | font-weight: 400; |
|
| 145 | + | } |
|
| 146 | + | ||
| 147 | + | .agg-table-wrap { |
|
| 148 | + | border: 1px solid #333; |
|
| 149 | + | max-height: 400px; |
|
| 150 | + | overflow-y: auto; |
|
| 151 | + | overflow-x: auto; |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | .agg-table { |
|
| 155 | + | width: 100%; |
|
| 156 | + | border-collapse: collapse; |
|
| 157 | + | font-size: 12px; |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | .agg-table th, |
|
| 161 | + | .agg-table td { |
|
| 162 | + | padding: 0.4rem 0.5rem; |
|
| 163 | + | text-align: center; |
|
| 164 | + | border-bottom: 1px solid #333; |
|
| 165 | + | white-space: nowrap; |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | .agg-table th { |
|
| 169 | + | position: sticky; |
|
| 170 | + | top: 0; |
|
| 171 | + | background: #121113; |
|
| 172 | + | color: #888; |
|
| 173 | + | font-weight: 400; |
|
| 174 | + | font-size: 10px; |
|
| 175 | + | text-transform: uppercase; |
|
| 176 | + | letter-spacing: 0.05em; |
|
| 177 | + | } |
|
| 178 | + | ||
| 179 | + | .agg-table .keys-cell { |
|
| 180 | + | text-align: left; |
|
| 181 | + | font-weight: 700; |
|
| 182 | + | } |
|
| 183 | + | ||
| 184 | + | .agg-table .count-cell { |
|
| 185 | + | color: #888; |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | .toggle-btn { |
|
| 189 | + | align-self: flex-end; |
|
| 190 | + | font-size: 12px; |
|
| 191 | + | padding: 4px 8px; |
|
| 192 | + | } |
|
| 193 | + | ||
| 194 | + | @media (max-width: 480px) { |
|
| 195 | + | .metadata-bar { |
|
| 196 | + | grid-template-columns: repeat(3, 1fr); |
|
| 197 | + | } |
|
| 198 | + | ||
| 199 | + | .meta-item:nth-child(3) { |
|
| 200 | + | border-right: none; |
|
| 201 | + | } |
|
| 202 | + | ||
| 203 | + | .meta-item:nth-child(1), |
|
| 204 | + | .meta-item:nth-child(2), |
|
| 205 | + | .meta-item:nth-child(3) { |
|
| 206 | + | border-bottom: 1px solid #333; |
|
| 207 | + | } |
|
| 208 | + | } |
|
| 209 | + | </style> |