chore: added session comparison 0b58dce8
Steve · 2026-03-20 07:21 3 file(s) · +445 −4
src/App.svelte +2 −4
4 4
  import CapturePanel from './lib/components/CapturePanel.svelte';
5 5
  import ProfileSummary from './lib/components/ProfileSummary.svelte';
6 6
  import ProfileManager from './lib/components/ProfileManager.svelte';
7 +
  import ComparisonView from './lib/components/ComparisonView.svelte';
7 8
8 9
  let pendingKeys = new Map();
9 10
  let lastCompleted = null;
139 140
    <ProfileSummary {digraphs} metadata={sessionMetadata} />
140 141
    <ProfileManager {digraphs} metadata={sessionMetadata} />
141 142
  {:else if activeTab === 'compare'}
142 -
    <div class="placeholder">
143 -
      <p>session comparison</p>
144 -
      <span class="placeholder-sub">coming in step 3</span>
145 -
    </div>
143 +
    <ComparisonView {digraphs} />
146 144
  {:else if activeTab === 'humanness'}
147 145
    <div class="placeholder">
148 146
      <p>human-ness analysis</p>
src/lib/comparison.ts (added) +86 −0
1 +
import type { DigraphAggregation, ComparisonResult, PerDigraphComparison } from './types';
2 +
3 +
const METRIC_KEYS = [
4 +
  'holdTime1',
5 +
  'holdTime2',
6 +
  'pressPress',
7 +
  'releaseRelease',
8 +
  'pressRelease',
9 +
  'releasePress',
10 +
] as const;
11 +
12 +
const EPSILON = 0.001;
13 +
14 +
export function compareSession(
15 +
  sessionAggs: DigraphAggregation[],
16 +
  profileAggs: DigraphAggregation[],
17 +
): ComparisonResult {
18 +
  const profileMap = new Map<string, DigraphAggregation>();
19 +
  for (const a of profileAggs) {
20 +
    profileMap.set(a.normalizedKeys, a);
21 +
  }
22 +
23 +
  const perDigraph: PerDigraphComparison[] = [];
24 +
  let totalDistance = 0;
25 +
  let metricCount = 0;
26 +
27 +
  for (const sessionAgg of sessionAggs) {
28 +
    const profileAgg = profileMap.get(sessionAgg.normalizedKeys);
29 +
    if (!profileAgg) continue;
30 +
31 +
    let digraphDistance = 0;
32 +
    let digraphMetrics = 0;
33 +
34 +
    for (const key of METRIC_KEYS) {
35 +
      const sessionMean = sessionAgg[key].mean;
36 +
      const profileMean = profileAgg[key].mean;
37 +
      const profileStd = profileAgg[key].std;
38 +
39 +
      let distance: number;
40 +
      if (profileStd < EPSILON) {
41 +
        const fallback = Math.max(Math.abs(profileMean) * 0.1, 1.0);
42 +
        distance = Math.abs(sessionMean - profileMean) / fallback;
43 +
      } else {
44 +
        distance = Math.abs(sessionMean - profileMean) / profileStd;
45 +
      }
46 +
47 +
      digraphDistance += distance;
48 +
      digraphMetrics++;
49 +
    }
50 +
51 +
    const avgDistance = digraphMetrics > 0 ? digraphDistance / digraphMetrics : 0;
52 +
    const matchPercent = Math.max(0, Math.round(100 * (1 - avgDistance / 3.0)));
53 +
54 +
    perDigraph.push({
55 +
      normalizedKeys: sessionAgg.normalizedKeys,
56 +
      distance: Math.round(avgDistance * 100) / 100,
57 +
      matchPercent,
58 +
    });
59 +
60 +
    totalDistance += digraphDistance;
61 +
    metricCount += digraphMetrics;
62 +
  }
63 +
64 +
  const sharedCount = perDigraph.length;
65 +
  const overallDistance = metricCount > 0
66 +
    ? Math.round((totalDistance / metricCount) * 100) / 100
67 +
    : 0;
68 +
  const similarityPercent = Math.max(0, Math.round(100 * (1 - overallDistance / 3.0)));
69 +
70 +
  let confidence: ComparisonResult['confidence'];
71 +
  if (sharedCount >= 15) confidence = 'high';
72 +
  else if (sharedCount >= 10) confidence = 'medium';
73 +
  else if (sharedCount >= 5) confidence = 'low';
74 +
  else confidence = 'insufficient';
75 +
76 +
  // Sort by distance descending (worst matches first)
77 +
  perDigraph.sort((a, b) => b.distance - a.distance);
78 +
79 +
  return {
80 +
    overallDistance,
81 +
    similarityPercent,
82 +
    confidence,
83 +
    sharedCount,
84 +
    perDigraph,
85 +
  };
86 +
}
src/lib/components/ComparisonView.svelte (added) +357 −0
1 +
<script>
2 +
  import { aggregateDigraphs } from '../aggregation';
3 +
  import { compareSession } from '../comparison';
4 +
  import { loadProfiles } from '../storage';
5 +
6 +
  export let digraphs = [];
7 +
8 +
  let profiles = loadProfiles();
9 +
  let selectedProfileId = '';
10 +
  let result = null;
11 +
12 +
  $: sessionAggs = aggregateDigraphs(digraphs);
13 +
  $: selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
14 +
  $: canCompare = selectedProfile && sessionAggs.length >= 5;
15 +
16 +
  function refreshProfiles() {
17 +
    profiles = loadProfiles();
18 +
    if (selectedProfileId && !profiles.find((p) => p.id === selectedProfileId)) {
19 +
      selectedProfileId = '';
20 +
      result = null;
21 +
    }
22 +
  }
23 +
24 +
  function handleCompare() {
25 +
    if (!selectedProfile) return;
26 +
    result = compareSession(sessionAggs, selectedProfile.aggregations);
27 +
  }
28 +
29 +
  function confidenceColor(confidence) {
30 +
    if (confidence === 'high') return '#4ade80';
31 +
    if (confidence === 'medium') return '#facc15';
32 +
    if (confidence === 'low') return '#fb923c';
33 +
    return '#888';
34 +
  }
35 +
36 +
  function matchColor(percent) {
37 +
    if (percent >= 70) return '#4ade80';
38 +
    if (percent >= 40) return '#facc15';
39 +
    return '#ff6b6b';
40 +
  }
41 +
42 +
  // Refresh profiles when the component becomes visible
43 +
  $: if (digraphs) refreshProfiles();
44 +
</script>
45 +
46 +
{#if digraphs.length === 0}
47 +
  <div class="empty">
48 +
    <p>no digraphs captured</p>
49 +
    <span class="empty-sub">type in the textarea on the capture tab first</span>
50 +
  </div>
51 +
{:else}
52 +
  <div class="comparison">
53 +
    <div class="select-row">
54 +
      <select bind:value={selectedProfileId} class="profile-select">
55 +
        <option value="">select a profile to compare against</option>
56 +
        {#each profiles as profile (profile.id)}
57 +
          <option value={profile.id}>{profile.name}</option>
58 +
        {/each}
59 +
      </select>
60 +
      <button
61 +
        on:click={handleCompare}
62 +
        disabled={!canCompare}
63 +
        class="compare-btn"
64 +
      >
65 +
        compare
66 +
      </button>
67 +
    </div>
68 +
69 +
    {#if selectedProfile && sessionAggs.length < 5}
70 +
      <span class="hint">need at least 5 unique digraph pairs to compare</span>
71 +
    {/if}
72 +
73 +
    {#if profiles.length === 0}
74 +
      <span class="hint">save a profile first on the profile tab</span>
75 +
    {/if}
76 +
77 +
    {#if result}
78 +
      <div class="results">
79 +
        <div class="score-card">
80 +
          <div class="similarity">
81 +
            <span class="similarity-value" style="color: {matchColor(result.similarityPercent)}">{result.similarityPercent}%</span>
82 +
            <span class="similarity-label">similarity</span>
83 +
          </div>
84 +
          <div class="score-details">
85 +
            <div class="detail-row">
86 +
              <span class="detail-label">distance</span>
87 +
              <span class="detail-value">{result.overallDistance}</span>
88 +
            </div>
89 +
            <div class="detail-row">
90 +
              <span class="detail-label">shared pairs</span>
91 +
              <span class="detail-value">{result.sharedCount}</span>
92 +
            </div>
93 +
            <div class="detail-row">
94 +
              <span class="detail-label">confidence</span>
95 +
              <span class="detail-value confidence-badge" style="color: {confidenceColor(result.confidence)}">{result.confidence}</span>
96 +
            </div>
97 +
          </div>
98 +
        </div>
99 +
100 +
        {#if result.confidence === 'insufficient'}
101 +
          <span class="hint">not enough shared digraph pairs for a reliable comparison — type more or use similar content</span>
102 +
        {/if}
103 +
104 +
        <span class="note">note: comparing prose vs code sessions for the same user will produce poor matches</span>
105 +
106 +
        {#if result.perDigraph.length > 0}
107 +
          <div class="breakdown-table-wrap">
108 +
            <table class="breakdown-table">
109 +
              <thead>
110 +
                <tr>
111 +
                  <th>keys</th>
112 +
                  <th>distance</th>
113 +
                  <th>match</th>
114 +
                  <th class="bar-col"></th>
115 +
                </tr>
116 +
              </thead>
117 +
              <tbody>
118 +
                {#each result.perDigraph as d}
119 +
                  <tr>
120 +
                    <td class="keys-cell">{d.normalizedKeys}</td>
121 +
                    <td class="distance-cell">{d.distance}</td>
122 +
                    <td class="match-cell" style="color: {matchColor(d.matchPercent)}">{d.matchPercent}%</td>
123 +
                    <td class="bar-cell">
124 +
                      <div class="bar-track">
125 +
                        <div
126 +
                          class="bar-fill"
127 +
                          style="width: {d.matchPercent}%; background: {matchColor(d.matchPercent)}"
128 +
                        ></div>
129 +
                      </div>
130 +
                    </td>
131 +
                  </tr>
132 +
                {/each}
133 +
              </tbody>
134 +
            </table>
135 +
          </div>
136 +
        {/if}
137 +
      </div>
138 +
    {/if}
139 +
  </div>
140 +
{/if}
141 +
142 +
<style>
143 +
  .empty {
144 +
    display: flex;
145 +
    flex-direction: column;
146 +
    align-items: center;
147 +
    justify-content: center;
148 +
    padding: 4rem 2rem;
149 +
    border: 1px dashed #333;
150 +
    gap: 0.5rem;
151 +
  }
152 +
153 +
  .empty p {
154 +
    font-size: 14px;
155 +
    color: #888;
156 +
  }
157 +
158 +
  .empty-sub {
159 +
    font-size: 11px;
160 +
    color: #555;
161 +
  }
162 +
163 +
  .comparison {
164 +
    display: flex;
165 +
    flex-direction: column;
166 +
    gap: 1rem;
167 +
  }
168 +
169 +
  .select-row {
170 +
    display: flex;
171 +
    gap: 0.5rem;
172 +
  }
173 +
174 +
  .profile-select {
175 +
    flex: 1;
176 +
    font-size: 12px;
177 +
    padding: 6px 8px;
178 +
    background: #121113;
179 +
    color: #ffffff;
180 +
    border: 1px solid white;
181 +
    font-family: "Commit Mono", monospace, sans-serif;
182 +
    cursor: pointer;
183 +
  }
184 +
185 +
  .profile-select option {
186 +
    background: #121113;
187 +
    color: #ffffff;
188 +
  }
189 +
190 +
  .compare-btn {
191 +
    font-size: 12px;
192 +
    padding: 6px 12px;
193 +
    white-space: nowrap;
194 +
  }
195 +
196 +
  .compare-btn:disabled {
197 +
    opacity: 0.3;
198 +
    cursor: not-allowed;
199 +
  }
200 +
201 +
  .hint {
202 +
    font-size: 11px;
203 +
    color: #555;
204 +
  }
205 +
206 +
  .note {
207 +
    font-size: 10px;
208 +
    color: #555;
209 +
    font-style: italic;
210 +
  }
211 +
212 +
  .results {
213 +
    display: flex;
214 +
    flex-direction: column;
215 +
    gap: 1rem;
216 +
  }
217 +
218 +
  .score-card {
219 +
    display: flex;
220 +
    border: 1px solid #333;
221 +
  }
222 +
223 +
  .similarity {
224 +
    display: flex;
225 +
    flex-direction: column;
226 +
    align-items: center;
227 +
    justify-content: center;
228 +
    padding: 1.5rem 2rem;
229 +
    border-right: 1px solid #333;
230 +
    min-width: 140px;
231 +
  }
232 +
233 +
  .similarity-value {
234 +
    font-size: 36px;
235 +
    font-weight: 700;
236 +
    line-height: 1;
237 +
  }
238 +
239 +
  .similarity-label {
240 +
    font-size: 10px;
241 +
    color: #888;
242 +
    margin-top: 0.35rem;
243 +
    text-transform: uppercase;
244 +
    letter-spacing: 0.05em;
245 +
  }
246 +
247 +
  .score-details {
248 +
    display: flex;
249 +
    flex-direction: column;
250 +
    flex: 1;
251 +
    padding: 0.75rem 1rem;
252 +
    gap: 0.35rem;
253 +
    justify-content: center;
254 +
  }
255 +
256 +
  .detail-row {
257 +
    display: flex;
258 +
    justify-content: space-between;
259 +
    align-items: center;
260 +
  }
261 +
262 +
  .detail-label {
263 +
    font-size: 11px;
264 +
    color: #888;
265 +
  }
266 +
267 +
  .detail-value {
268 +
    font-size: 13px;
269 +
    font-weight: 700;
270 +
  }
271 +
272 +
  .confidence-badge {
273 +
    text-transform: uppercase;
274 +
    font-size: 11px;
275 +
    letter-spacing: 0.05em;
276 +
  }
277 +
278 +
  .breakdown-table-wrap {
279 +
    border: 1px solid #333;
280 +
    max-height: 400px;
281 +
    overflow-y: auto;
282 +
    overflow-x: auto;
283 +
  }
284 +
285 +
  .breakdown-table {
286 +
    width: 100%;
287 +
    border-collapse: collapse;
288 +
    font-size: 12px;
289 +
  }
290 +
291 +
  .breakdown-table th,
292 +
  .breakdown-table td {
293 +
    padding: 0.4rem 0.5rem;
294 +
    text-align: center;
295 +
    border-bottom: 1px solid #333;
296 +
    white-space: nowrap;
297 +
  }
298 +
299 +
  .breakdown-table th {
300 +
    position: sticky;
301 +
    top: 0;
302 +
    background: #121113;
303 +
    color: #888;
304 +
    font-weight: 400;
305 +
    font-size: 10px;
306 +
    text-transform: uppercase;
307 +
    letter-spacing: 0.05em;
308 +
  }
309 +
310 +
  .breakdown-table .keys-cell {
311 +
    text-align: left;
312 +
    font-weight: 700;
313 +
  }
314 +
315 +
  .breakdown-table .distance-cell {
316 +
    color: #888;
317 +
  }
318 +
319 +
  .bar-col {
320 +
    width: 40%;
321 +
  }
322 +
323 +
  .bar-cell {
324 +
    padding: 0.4rem 0.75rem;
325 +
  }
326 +
327 +
  .bar-track {
328 +
    width: 100%;
329 +
    height: 4px;
330 +
    background: #333;
331 +
  }
332 +
333 +
  .bar-fill {
334 +
    height: 100%;
335 +
    transition: width 0.3s ease;
336 +
  }
337 +
338 +
  @media (max-width: 480px) {
339 +
    .score-card {
340 +
      flex-direction: column;
341 +
    }
342 +
343 +
    .similarity {
344 +
      border-right: none;
345 +
      border-bottom: 1px solid #333;
346 +
      padding: 1rem;
347 +
    }
348 +
349 +
    .bar-col {
350 +
      display: none;
351 +
    }
352 +
353 +
    .bar-cell {
354 +
      display: none;
355 +
    }
356 +
  }
357 +
</style>