Skip to content

Commit c933640

Browse files
committed
ui(analytics): improve layout and remove nested scroll
1 parent 297837f commit c933640

File tree

3 files changed

+201
-40
lines changed

3 files changed

+201
-40
lines changed

build/runElectronBuilder.cjs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
const { spawn } = require('node:child_process')
2+
const path = require('node:path')
3+
4+
function getElectronBuilderBin() {
5+
const bin = process.platform === 'win32' ? 'electron-builder.cmd' : 'electron-builder'
6+
return path.join(process.cwd(), 'node_modules', '.bin', bin)
7+
}
8+
9+
function run(bin, args) {
10+
return new Promise((resolve) => {
11+
const isWin = process.platform === 'win32'
12+
const quoteArg = (a) => (/[ \t"]/g.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)
13+
14+
// On Windows, electron-builder is a .cmd shim and this repo path contains spaces.
15+
// Use cmd.exe /c with explicit quoting so it runs reliably.
16+
const child = isWin
17+
? spawn(
18+
'cmd.exe',
19+
// Use an outer pair of quotes for /c, and an inner pair to quote the .cmd path.
20+
// This avoids issues with spaces in the repo path ("typing-trainer react-ts").
21+
['/d', '/s', '/c', `""${bin}" ${args.map(quoteArg).join(' ')}"`],
22+
{ stdio: ['ignore', 'pipe', 'pipe'], windowsVerbatimArguments: true },
23+
)
24+
: spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })
25+
let out = ''
26+
let err = ''
27+
child.stdout.on('data', (buf) => {
28+
const s = buf.toString('utf8')
29+
out += s
30+
process.stdout.write(s)
31+
})
32+
child.stderr.on('data', (buf) => {
33+
const s = buf.toString('utf8')
34+
err += s
35+
process.stderr.write(s)
36+
})
37+
child.on('close', (code) => resolve({ code: code ?? 1, out, err }))
38+
})
39+
}
40+
41+
function looksLikeWindowsFileLock(logText) {
42+
const t = logText.toLowerCase()
43+
return (
44+
(t.includes('app.asar') && t.includes('being used by another process'))
45+
|| (t.includes('app.asar') && t.includes('cannot access the file'))
46+
|| (t.includes('app.asar') && t.includes('process cannot access the file'))
47+
|| (t.includes('app.asar') && t.includes('another process'))
48+
|| (t.includes('app.asar') && t.includes('另一进程'))
49+
|| (t.includes('app.asar') && t.includes('正由另一进程使用'))
50+
)
51+
}
52+
53+
async function main() {
54+
const bin = getElectronBuilderBin()
55+
const baseArgs = ['--publish', 'never']
56+
57+
const first = await run(bin, baseArgs)
58+
if (first.code === 0) process.exit(0)
59+
60+
const combined = `${first.out}\n${first.err}`
61+
if (!looksLikeWindowsFileLock(combined)) {
62+
process.exit(first.code)
63+
}
64+
65+
// Windows sometimes keeps the previous output folder locked (Defender scanning, Explorer preview, etc.).
66+
// Fall back to a fresh output directory so packaging can proceed.
67+
const pkg = require(path.join(process.cwd(), 'package.json'))
68+
const version = pkg.version || '0.0.0'
69+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
70+
const fallbackOutDir = `release/CodeTyping-Trainer-${version}-local-${stamp}`
71+
72+
console.warn(`\n[build] Detected locked app.asar; retrying with output=${fallbackOutDir}\n`)
73+
74+
const second = await run(bin, [...baseArgs, `--config.directories.output=${fallbackOutDir}`])
75+
process.exit(second.code)
76+
}
77+
78+
// eslint-disable-next-line unicorn/prefer-top-level-await
79+
main().catch((err) => {
80+
console.error(err)
81+
process.exit(1)
82+
})

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"type": "module",
1010
"scripts": {
1111
"dev": "vite",
12-
"build": "tsc && vite build && electron-builder --publish never",
12+
"build": "tsc && vite build && node build/runElectronBuilder.cjs",
1313
"test": "vitest --config vitest.config.ts run",
1414
"test:watch": "vitest --config vitest.config.ts",
1515
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",

src/pages/Analytics.tsx

Lines changed: 118 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Alert, Badge, Button, Card, Container, Group, Stack, Text, TextInput, Title } from '@mantine/core'
1+
import { Alert, Badge, Button, Card, Container, Divider, Group, Stack, Text, TextInput, Title, Collapse } from '@mantine/core'
2+
import { notifications } from '@mantine/notifications'
23
import { useEffect, useMemo, useState } from 'react'
34
import {
45
CartesianGrid,
@@ -80,9 +81,19 @@ export function Analytics({ onHome }: AnalyticsProps) {
8081
}))
8182
}, [filteredAttempts])
8283

84+
const copyText = async (label: string, value: string) => {
85+
try {
86+
await navigator.clipboard.writeText(value)
87+
notifications.show({ color: 'green', message: `Copied ${label}` })
88+
} catch (error) {
89+
const message = error instanceof Error ? error.message : String(error)
90+
notifications.show({ color: 'red', title: 'Copy failed', message })
91+
}
92+
}
93+
8394
return (
84-
<Container size="lg" py="lg" className="h-full">
85-
<Stack gap="md" className="h-full">
95+
<Container size="lg" py="lg">
96+
<Stack gap="md">
8697
<Group justify="space-between" align="flex-end" wrap="wrap">
8798
<Group>
8899
<Button variant="subtle" onClick={onHome}>Home</Button>
@@ -108,13 +119,13 @@ export function Analytics({ onHome }: AnalyticsProps) {
108119
</Alert>
109120
)}
110121

111-
<div className="grid grid-cols-1 gap-3">
122+
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
112123
<Card withBorder padding="md">
113124
<Group justify="space-between" mb="xs">
114125
<Text fw={600}>WPM over time</Text>
115126
{state.status === 'loading' && <Text size="xs" c="dimmed">Loading...</Text>}
116127
</Group>
117-
<div style={{ height: 220 }}>
128+
<div style={{ height: 240 }}>
118129
<ResponsiveContainer width="100%" height="100%">
119130
<LineChart data={series} margin={{ top: 10, right: 16, bottom: 8, left: 0 }}>
120131
<CartesianGrid strokeDasharray="4 4" />
@@ -134,8 +145,11 @@ export function Analytics({ onHome }: AnalyticsProps) {
134145
</Card>
135146

136147
<Card withBorder padding="md">
137-
<Text fw={600} mb="xs">Unproductive% over time</Text>
138-
<div style={{ height: 220 }}>
148+
<Group justify="space-between" mb="xs">
149+
<Text fw={600}>Unproductive% over time</Text>
150+
{state.status === 'loading' && <Text size="xs" c="dimmed">Loading...</Text>}
151+
</Group>
152+
<div style={{ height: 240 }}>
139153
<ResponsiveContainer width="100%" height="100%">
140154
<LineChart data={series} margin={{ top: 10, right: 16, bottom: 8, left: 0 }}>
141155
<CartesianGrid strokeDasharray="4 4" />
@@ -155,9 +169,16 @@ export function Analytics({ onHome }: AnalyticsProps) {
155169
</Card>
156170
</div>
157171

158-
<div className="tt-border min-h-0 overflow-auto border-t pt-3">
159-
<Group justify="space-between" mb="xs">
160-
<Text fw={600}>Attempts</Text>
172+
<Divider />
173+
174+
<Stack gap="sm">
175+
<Group justify="space-between" align="flex-end" wrap="wrap">
176+
<div>
177+
<Text fw={700}>Attempts</Text>
178+
<Text size="sm" c="dimmed">
179+
Browse attempts below (page scroll). Use the filter above to narrow by file name.
180+
</Text>
181+
</div>
161182
{state.status === 'loading' && <Text size="xs" c="dimmed">Loading...</Text>}
162183
</Group>
163184

@@ -167,55 +188,113 @@ export function Analytics({ onHome }: AnalyticsProps) {
167188
</Text>
168189
)}
169190

170-
<Stack gap="xs">
191+
<Stack gap="md">
171192
{filteredAttempts.map((a) => {
172193
const expanded = expandedId === a.id
194+
const detailsJson = JSON.stringify(a, null, 2)
173195
return (
174-
<Card key={a.id} withBorder padding="sm">
175-
<button
176-
type="button"
177-
onClick={() => setExpandedId(expanded ? null : a.id)}
178-
className="w-full text-left"
179-
>
180-
<Group justify="space-between" align="flex-start" wrap="wrap">
181-
<div>
182-
<Text fw={600}>{a.fileName}</Text>
183-
<Text size="xs" c="dimmed">{formatDateTime(a.endAtMs)}</Text>
184-
</div>
185-
186-
<Group gap="md" wrap="wrap">
187-
<Text size="sm"><strong>WPM</strong> {a.wpm.toFixed(1)}</Text>
188-
<Text size="sm"><strong>Unprod%</strong> {a.unproductivePercent.toFixed(1)}</Text>
189-
<Text size="sm"><strong>Seg</strong> {a.segmentIndex + 1}</Text>
196+
<Card
197+
key={a.id}
198+
withBorder
199+
padding="md"
200+
style={{ cursor: 'pointer' }}
201+
onClick={() => setExpandedId(expanded ? null : a.id)}
202+
>
203+
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
204+
<div className="min-w-0">
205+
<Group gap="sm" wrap="wrap">
206+
<Text fw={700}>{a.fileName}</Text>
207+
<Badge variant="light">Seg {a.segmentIndex + 1}</Badge>
208+
<Badge variant="light">Lines {a.segmentStartLine}-{a.segmentEndLine}</Badge>
209+
<Badge variant="light">{formatDateTime(a.endAtMs)}</Badge>
190210
</Group>
211+
<Text
212+
size="xs"
213+
c="dimmed"
214+
className="truncate"
215+
title={a.filePath}
216+
mt={6}
217+
>
218+
{a.filePath}
219+
</Text>
220+
</div>
221+
222+
<Group gap="md" wrap="wrap" justify="flex-end">
223+
<Text size="sm"><strong>WPM</strong> {a.wpm.toFixed(1)}</Text>
224+
<Text size="sm"><strong>Unprod%</strong> {a.unproductivePercent.toFixed(1)}</Text>
225+
<Text size="sm"><strong>Duration</strong> {(a.durationMs / 1000).toFixed(1)}s</Text>
226+
<Button
227+
size="xs"
228+
variant="light"
229+
onClick={(e) => {
230+
e.stopPropagation()
231+
setExpandedId(expanded ? null : a.id)
232+
}}
233+
>
234+
{expanded ? 'Hide details' : 'Details'}
235+
</Button>
191236
</Group>
192-
</button>
193-
194-
{expanded && (
195-
<Stack gap={4} mt="sm">
196-
<Text size="sm"><strong>filePath</strong>: <span className="tt-muted">{a.filePath}</span></Text>
197-
<Text size="sm"><strong>lines</strong>: {a.segmentStartLine}-{a.segmentEndLine}</Text>
198-
<Text size="sm"><strong>durationMs</strong>: {a.durationMs}</Text>
199-
<Group gap="md" wrap="wrap" mt={4}>
237+
</Group>
238+
239+
<Collapse in={expanded}>
240+
<Divider my="sm" />
241+
<Stack gap="xs">
242+
<Group gap="sm" wrap="wrap">
243+
<Button
244+
size="xs"
245+
variant="default"
246+
onClick={(e) => {
247+
e.stopPropagation()
248+
void copyText('file path', a.filePath)
249+
}}
250+
>
251+
Copy path
252+
</Button>
253+
<Button
254+
size="xs"
255+
variant="default"
256+
onClick={(e) => {
257+
e.stopPropagation()
258+
void copyText('JSON', detailsJson)
259+
}}
260+
>
261+
Copy JSON
262+
</Button>
263+
</Group>
264+
265+
<Text size="sm" style={{ overflowWrap: 'anywhere' }}>
266+
<strong>filePath</strong>: <span className="tt-muted">{a.filePath}</span>
267+
</Text>
268+
269+
<Group gap="md" wrap="wrap">
200270
<Text size="sm"><strong>typeableChars</strong>: {a.typeableChars}</Text>
201271
<Text size="sm"><strong>correctChars</strong>: {a.correctChars}</Text>
202272
<Text size="sm"><strong>typedKeystrokes</strong>: {a.typedKeystrokes}</Text>
203273
</Group>
204-
<Group gap="md" wrap="wrap" mt={4}>
274+
<Group gap="md" wrap="wrap">
205275
<Text size="sm"><strong>incorrect</strong>: {a.incorrect}</Text>
206276
<Text size="sm"><strong>collateral</strong>: {a.collateral}</Text>
207277
<Text size="sm"><strong>backspaces</strong>: {a.backspaces}</Text>
208278
</Group>
209-
<Text size="sm" mt={4}>
279+
<Text size="sm">
210280
<strong>settings</strong>: linesPerSegment={a.linesPerSegment}, tabWidth={a.tabWidth}, slackN={a.slackN}
211281
</Text>
282+
283+
<Text fw={600} size="sm" mt="xs">Raw JSON</Text>
284+
<pre
285+
className="tt-panel tt-border rounded-md border px-3 py-2 text-xs"
286+
style={{ margin: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}
287+
onClick={(e) => e.stopPropagation()}
288+
>
289+
{detailsJson}
290+
</pre>
212291
</Stack>
213-
)}
292+
</Collapse>
214293
</Card>
215294
)
216295
})}
217296
</Stack>
218-
</div>
297+
</Stack>
219298
</Stack>
220299
</Container>
221300
)

0 commit comments

Comments
 (0)