Skip to content

Commit 3fe15ce

Browse files
committed
feat: Add Logs
1 parent a243a5c commit 3fe15ce

File tree

7 files changed

+246
-43
lines changed

7 files changed

+246
-43
lines changed

src/Page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Layout } from '@/components/layout/Layout';
1313
import { Index } from '@/pages/index';
1414
import { Exercise } from '@/pages/Exercise';
1515
import { Page404 } from '@/pages/404';
16+
import { Logs } from '@/pages/Logs';
1617

1718
const route = window.location.pathname.replace(/^\/|\/$/g, '');
1819
const path = route.split('/');
@@ -25,6 +26,8 @@ if (route.length === 0) {
2526
} else if (path[0] === 'exercise') {
2627
if (path[1]) page = <Exercise template={path[1]} />;
2728
else page = <Page404 />; // TODO
29+
} else if (path[0] === 'logs' && path.length === 1) {
30+
page = <Logs />;
2831
} else page = <Page404 />;
2932

3033
const elem = document.getElementById('root')!;

src/components/molecules/TemplateRenderer.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@ import { Button } from '@/components/ui/button';
88
import { StaticEditor } from '@/components/molecules/StaticEditor';
99
import { useFetch } from '@/hooks/useFetch';
1010
import { getName, NameInput } from '@/components/molecules/NameInput';
11-
import { ClimbingBoxLoader, PacmanLoader } from 'react-spinners';
11+
import { PacmanLoader } from 'react-spinners';
1212

1313
function BaseTemplateRenderer({ templateCode, template }: { templateCode: string; template: string }): ReactElement {
14+
// I apologize to everyone who has ever taught me
15+
// This is disgusting, but I'm proud of it
16+
const [startTime] = useState(() => {
17+
const current = Date.now();
18+
localStorage.setItem(`STARTED_AT:${template}`, current.toString());
19+
return current;
20+
});
21+
1422
const [currentCode, setCurrentCode] = useState(() => templateCode);
1523

1624
const onChange = useRef<() => void | null>(null);
@@ -64,7 +72,7 @@ function BaseTemplateRenderer({ templateCode, template }: { templateCode: string
6472
fetch(`/api/submit/${template}`, {
6573
method: 'POST',
6674
headers: { 'Content-Type': 'application/json' },
67-
body: JSON.stringify({ name, code }),
75+
body: JSON.stringify({ name, code, start: startTime }),
6876
}).then(res => {
6977
if (!res.ok) {
7078
setStatus(false);
@@ -116,7 +124,7 @@ function BaseTemplateRenderer({ templateCode, template }: { templateCode: string
116124
);
117125
}
118126

119-
export function TemplateRenderer({ template }: { template: string; onSubmit: (code: string) => void }): ReactElement {
127+
export function TemplateRenderer({ template }: { template: string }): ReactElement {
120128
const { data, loading, error } = useFetch<{ template: string }>(`/api/templates/${template}`);
121129

122130
return (

src/index.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { serve } from 'bun';
1+
import { serve, type ServerWebSocket } from 'bun';
22
import z from 'zod';
33
import path from 'path';
44
import index from '@/index.html';
55
import { readdir } from 'fs/promises';
66
import { validate } from '@/checker';
7+
import { getCurrentLogs, log } from '@/logger/logs.ts';
8+
9+
const connectedSockets: Set<ServerWebSocket<any>> = new Set();
710

811
const server = serve({
912
routes: {
@@ -37,13 +40,22 @@ const server = serve({
3740
'/api/submit/:exercise': {
3841
POST: async req => {
3942
const exercise = req.params.exercise;
40-
const reqData = z.object({ code: z.string(), name: z.string() }).safeParse(await req.json());
43+
const reqData = z.object({ code: z.string(), name: z.string(), start: z.number() }).safeParse(await req.json());
4144
if (!reqData.success) return Response.json({ message: 'Invalid input' }, { status: 400 });
4245

4346
try {
4447
const success = await validate(reqData.data.code, exercise);
4548
if (success) {
46-
// TODO: Dispatch to socket and store in state
49+
log(
50+
{
51+
code: reqData.data.code,
52+
name: reqData.data.name,
53+
start: new Date(reqData.data.start),
54+
at: new Date(),
55+
template: exercise,
56+
},
57+
connectedSockets
58+
);
4759
return Response.json({ message: 'Valid input!' });
4860
} else return Response.json({ message: 'Failed.' }, { status: 400 });
4961
} catch {
@@ -59,8 +71,15 @@ const server = serve({
5971
},
6072

6173
websocket: {
62-
message(ws, message) {
63-
ws.send('Response!');
74+
open(ws) {
75+
connectedSockets.add(ws);
76+
ws.send(getCurrentLogs());
77+
},
78+
message() {
79+
// Do nothing with incoming messages for sockets
80+
},
81+
close(ws) {
82+
connectedSockets.delete(ws);
6483
},
6584
},
6685

src/logger/logs.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Logs are not persistent.
2+
// This is intentional.
3+
4+
import type { ServerWebSocket } from 'bun';
5+
6+
export type Log = { template: string; code: string; name: string; start: Date; at: Date };
7+
const cache: Log[] = [];
8+
9+
export function getCurrentLogs(): string {
10+
return JSON.stringify(cache);
11+
}
12+
13+
export function log(logData: Log, emitters: Set<ServerWebSocket<any>>) {
14+
cache.push(logData);
15+
emitters.forEach(socket => socket.send(JSON.stringify([logData])));
16+
}

src/pages/Exercise.tsx

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,5 @@
1-
import { useCallback, useEffect, useState } from 'react';
21
import { TemplateRenderer } from '@/components/molecules/TemplateRenderer';
32

4-
// const socket = new WebSocket(`ws://localhost:${Bun.env.BUN_PORT || 3000}/socket`);
5-
//
6-
// function Counter() {
7-
// const [count, setCount] = useState(0);
8-
//
9-
// useEffect(() => {
10-
// const onOpen = () => {
11-
// console.log('Connected');
12-
// };
13-
// socket.addEventListener('open', onOpen);
14-
//
15-
// const onMessage = (message: MessageEvent) => {
16-
// setCount(x => x + 1);
17-
// };
18-
// socket.addEventListener('message', onMessage);
19-
//
20-
// return () => {
21-
// socket.removeEventListener('open', onOpen);
22-
// socket.removeEventListener('message', onMessage);
23-
// };
24-
// });
25-
//
26-
// const send = useCallback(() => {
27-
// socket.send('Test');
28-
// }, []);
29-
//
30-
// return (
31-
// <div>
32-
// {count} <button onClick={send}>Clickity click</button>{' '}
33-
// </div>
34-
// );
35-
// }
36-
373
export function Exercise({ template }: { template: string }) {
38-
return <TemplateRenderer template={template} onSubmit={console.log} />;
4+
return <TemplateRenderer template={template} />;
395
}

src/pages/Logs.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useRef, type ReactElement, useState, useMemo, useCallback } from 'react';
2+
import type { Log } from '@/logger/logs.ts';
3+
import { ContentCard } from '@/components/atoms/ContentCard';
4+
import { toHumanTime } from '@/utils/toHumanTime.ts';
5+
6+
function LogRenderer({
7+
selected,
8+
log,
9+
onClick,
10+
}: {
11+
selected: boolean;
12+
log: Log;
13+
onClick: (log: Log) => void;
14+
}): ReactElement {
15+
const duration = useMemo(() => toHumanTime(log.at.getTime() - log.start.getTime()), [log.at, log.start]);
16+
17+
return (
18+
<button onClick={() => onClick(log)} className="cursor-pointer">
19+
<ContentCard className={`hover:bg-muted ${selected ? 'border-primary bg-muted' : ''}`}>
20+
<strong>{log.template}</strong> ({log.name})
21+
<br />
22+
{log.at.toLocaleString()} ({duration})
23+
</ContentCard>
24+
</button>
25+
);
26+
}
27+
28+
function LogsRenderer({ logs }: { logs: Log[] }): ReactElement {
29+
const [selected, setSelected] = useState<Log | null>(null);
30+
31+
const handleClick = useCallback((log: Log) => {
32+
setSelected(oldLog => (log === oldLog ? null : log));
33+
}, []);
34+
35+
if (logs.length === 0) return <ContentCard>No logs...</ContentCard>;
36+
return (
37+
<div className="flex gap-4 justify-center w-full m-8 grow">
38+
<div className="grow shrink basis-0 min-w-0 flex flex-col gap-2">
39+
{logs.map(log => (
40+
<LogRenderer selected={selected === log} log={log} onClick={handleClick} />
41+
))}
42+
</div>
43+
<ContentCard className="grow-2 shrink basis-0 min-w-0">
44+
{selected ? (
45+
<pre className="w-full m-4 text-start whitespace-pre-wrap">{selected.code}</pre>
46+
) : (
47+
<h2 className="text-muted-foreground">(Preview)</h2>
48+
)}
49+
</ContentCard>
50+
</div>
51+
);
52+
}
53+
54+
export function Logs(): ReactElement {
55+
const socketRef = useRef<WebSocket | null>(null);
56+
const [logs, setLogs] = useState<Log[]>([]);
57+
58+
if (!socketRef.current) {
59+
const socket = new WebSocket(`ws://${window.location.hostname}:${window.location.port}/socket`);
60+
socket.addEventListener('open', () => console.log('Socket connected'));
61+
socket.addEventListener('message', (message: MessageEvent) => {
62+
const newLogs = JSON.parse(message.data).map(
63+
(log: Log): Log => ({
64+
...log,
65+
at: new Date(log.at),
66+
start: new Date(log.start),
67+
})
68+
);
69+
setLogs(oldLogs => [...oldLogs, ...newLogs]);
70+
});
71+
socketRef.current = socket;
72+
}
73+
74+
return <LogsRenderer logs={logs} />;
75+
}

src/utils/toHumanTime.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
type Entry = {
2+
abbr: string;
3+
name: string;
4+
plur: string;
5+
time: number;
6+
count?: number;
7+
};
8+
export function toHumanTime(timeInMs: number, format: 'f2s' | 'hhmmss' | 'abs' = 'f2s'): string {
9+
const timeList: (
10+
| {
11+
abbr: string;
12+
name: string;
13+
plur: string;
14+
}
15+
| [number, number?]
16+
)[] = [
17+
{
18+
abbr: 'ms',
19+
name: 'millisecond',
20+
plur: 'milliseconds',
21+
},
22+
[1000],
23+
{
24+
abbr: 'sec',
25+
name: 'second',
26+
plur: 'seconds',
27+
},
28+
[60],
29+
{
30+
abbr: 'min',
31+
name: 'minute',
32+
plur: 'minutes',
33+
},
34+
[60],
35+
{
36+
abbr: 'hr',
37+
name: 'hour',
38+
plur: 'hours',
39+
},
40+
[24],
41+
{
42+
abbr: 'day',
43+
name: 'day',
44+
plur: 'days',
45+
},
46+
[7],
47+
{
48+
abbr: 'wk',
49+
name: 'week',
50+
plur: 'weeks',
51+
},
52+
[365, 7],
53+
{
54+
abbr: 'yr',
55+
name: 'year',
56+
plur: 'years',
57+
},
58+
[10],
59+
{
60+
abbr: 'dec',
61+
name: 'decade',
62+
plur: 'decades',
63+
},
64+
];
65+
const {
66+
entries: timeEntries,
67+
}: {
68+
scale: number;
69+
entries: Entry[];
70+
} = timeList.reduce(
71+
(acc, current) => {
72+
if (Array.isArray(current)) {
73+
const [mult, div = 1] = current;
74+
acc.scale *= mult / div;
75+
} else acc.entries.push({ ...current, time: acc.scale });
76+
return acc;
77+
},
78+
{ entries: [] as Entry[], scale: 1 }
79+
);
80+
if (format === 'hhmmss') timeEntries.splice(-3);
81+
let timeLeft = timeInMs;
82+
timeEntries.reverse().forEach(entry => {
83+
if (timeLeft >= entry.time) {
84+
const count = Math.floor(timeLeft / entry.time);
85+
entry.count = count;
86+
timeLeft -= count * entry.time;
87+
} else entry.count = 0;
88+
});
89+
timeEntries.reverse();
90+
switch (format) {
91+
case 'abs': {
92+
const firstIndex = timeEntries.findIndex(entry => entry.count! > 0);
93+
if (firstIndex === -1) return '0 ms';
94+
return timeEntries
95+
.slice(firstIndex, firstIndex + 2)
96+
.filter(entry => entry.count)
97+
.map(entry => `${entry.count} ${entry.count === 1 ? entry.name : entry.plur}`)
98+
.join(` and `);
99+
}
100+
case 'hhmmss': {
101+
const [ms, s, m, h, d] = timeEntries.map(entry => entry.count);
102+
return `${d ? `${d}:` : ''}${d || h ? `${h}:` : ''}${m}:${s}${ms ? `.${ms}` : ''}`;
103+
}
104+
case 'f2s':
105+
default: {
106+
return (
107+
timeEntries
108+
.filter(entry => entry.count)
109+
.reverse()
110+
.slice(0, 2)
111+
.map(entry => `${entry.count} ${entry.count === 1 ? entry.name : entry.plur}`)
112+
.join(` and `) || '0 ms'
113+
);
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)