chore: added digraph aggregation and profile building d61e209a
Steve · 2026-03-19 20:02 4 file(s) · +353 −6
src/App.svelte +57 −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>
src/lib/aggregation.ts (added) +83 −0
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 +
}
src/lib/components/CapturePanel.svelte +4 −0
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}
src/lib/components/ProfileSummary.svelte (added) +209 −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>