saterminal 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -12
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/cli.ts +899 -0
- package/src/main.ts +10 -1
- package/src/progress.ts +48 -0
- package/src/state.ts +115 -6
- package/src/tui/app.ts +51 -15
- package/src/tui/frame.ts +210 -0
- package/src/tui/input.ts +33 -2
- package/src/tui/kit.ts +0 -20
- package/src/tui/question.ts +2 -60
- package/src/tui/render.ts +74 -83
- package/src/tui/types.ts +3 -0
- package/src/types/terminal-kit.d.ts +3 -0
- package/src/types.ts +17 -0
- package/test/cli.test.ts +223 -0
- package/test/frame.test.ts +94 -0
- package/test/progress.test.ts +18 -0
- package/test/state.test.ts +73 -3
- package/src/tui/terminal.ts +0 -110
package/src/cli.ts
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
import { difficultyLabels, domainLabels, focusSummary, skillLabels } from "./focus.ts";
|
|
2
|
+
import { progressBarText } from "./progress.ts";
|
|
3
|
+
import { buildSummaryRows, loadAttemptEvents, loadAttempts, loadFocus } from "./state.ts";
|
|
4
|
+
import type { Attempt, AttemptEvent, Focus, SummaryRow } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export type CliCommand = "history" | "stats" | "focus" | "weak";
|
|
7
|
+
export type OutputFormat = "text" | "pretty" | "json";
|
|
8
|
+
|
|
9
|
+
export type HistoryFilters = {
|
|
10
|
+
wrong?: boolean;
|
|
11
|
+
corrected?: boolean;
|
|
12
|
+
limit?: number;
|
|
13
|
+
since?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ParsedCli =
|
|
17
|
+
| { kind: "tui" }
|
|
18
|
+
| { kind: "review" }
|
|
19
|
+
| { kind: "help" }
|
|
20
|
+
| { kind: "error"; message: string }
|
|
21
|
+
| {
|
|
22
|
+
kind: "command";
|
|
23
|
+
command: CliCommand;
|
|
24
|
+
format: OutputFormat;
|
|
25
|
+
color?: boolean;
|
|
26
|
+
filters?: HistoryFilters;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type FormatOptions = {
|
|
30
|
+
color?: boolean;
|
|
31
|
+
events?: AttemptEvent[];
|
|
32
|
+
filters?: HistoryFilters;
|
|
33
|
+
now?: Date;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type Style = {
|
|
37
|
+
color: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type Writable = {
|
|
41
|
+
write(value: string): unknown;
|
|
42
|
+
isTTY?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type ActivityDay = {
|
|
46
|
+
date: string;
|
|
47
|
+
count: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type ActivityStats = {
|
|
51
|
+
streak: number;
|
|
52
|
+
activeDays: number;
|
|
53
|
+
todayCount: number;
|
|
54
|
+
totalEvents: number;
|
|
55
|
+
days: ActivityDay[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type WeakRow = {
|
|
59
|
+
skill: string;
|
|
60
|
+
label: string;
|
|
61
|
+
domain: string;
|
|
62
|
+
domainLabel: string;
|
|
63
|
+
total: number;
|
|
64
|
+
mastered: number;
|
|
65
|
+
missed: number;
|
|
66
|
+
accuracy: number;
|
|
67
|
+
avg_seconds: number;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const reportCommands = new Set<CliCommand>(["history", "stats", "focus", "weak"]);
|
|
71
|
+
const ansi = {
|
|
72
|
+
reset: "\x1b[0m",
|
|
73
|
+
bold: "\x1b[1m",
|
|
74
|
+
dim: "\x1b[2m",
|
|
75
|
+
gray: "\x1b[90m",
|
|
76
|
+
red: "\x1b[31m",
|
|
77
|
+
green: "\x1b[32m",
|
|
78
|
+
yellow: "\x1b[33m",
|
|
79
|
+
cyan: "\x1b[36m",
|
|
80
|
+
bgGray: "\x1b[100m",
|
|
81
|
+
bgRed: "\x1b[41m",
|
|
82
|
+
bgGreen: "\x1b[42m",
|
|
83
|
+
bgYellow: "\x1b[43m",
|
|
84
|
+
heatEmpty: "\x1b[38;5;238m",
|
|
85
|
+
heatLow: "\x1b[38;5;22m",
|
|
86
|
+
heatMid: "\x1b[38;5;28m",
|
|
87
|
+
heatHigh: "\x1b[38;5;34m",
|
|
88
|
+
heatMax: "\x1b[38;5;40m",
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
export function parseArgs(args: string[]): ParsedCli {
|
|
92
|
+
if (args.length === 0) {
|
|
93
|
+
return { kind: "tui" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let command: string | undefined;
|
|
97
|
+
let pretty = false;
|
|
98
|
+
let json = false;
|
|
99
|
+
let noColor = false;
|
|
100
|
+
const filters: HistoryFilters = {};
|
|
101
|
+
const extra: string[] = [];
|
|
102
|
+
|
|
103
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
104
|
+
const arg = args[index] ?? "";
|
|
105
|
+
|
|
106
|
+
if (arg === "-h" || arg === "--help" || arg === "help") {
|
|
107
|
+
return { kind: "help" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (arg === "-p" || arg === "--pretty") {
|
|
111
|
+
pretty = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (arg === "--json") {
|
|
116
|
+
json = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (arg === "--no-color") {
|
|
121
|
+
noColor = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (arg === "--wrong") {
|
|
126
|
+
filters.wrong = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (arg === "--corrected") {
|
|
131
|
+
filters.corrected = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (arg === "--limit" || arg.startsWith("--limit=")) {
|
|
136
|
+
const value = optionValue(args, index, "--limit");
|
|
137
|
+
if (!value) {
|
|
138
|
+
return { kind: "error", message: "Missing value for `--limit`." };
|
|
139
|
+
}
|
|
140
|
+
if (arg === "--limit") {
|
|
141
|
+
index += 1;
|
|
142
|
+
}
|
|
143
|
+
const limit = Number(value);
|
|
144
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
145
|
+
return { kind: "error", message: "`--limit` must be a positive integer." };
|
|
146
|
+
}
|
|
147
|
+
filters.limit = limit;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (arg === "--since" || arg.startsWith("--since=")) {
|
|
152
|
+
const value = optionValue(args, index, "--since");
|
|
153
|
+
if (!value) {
|
|
154
|
+
return { kind: "error", message: "Missing value for `--since`." };
|
|
155
|
+
}
|
|
156
|
+
if (!isSinceValue(value)) {
|
|
157
|
+
return { kind: "error", message: "`--since` must be an ISO date or a relative value like `7d` or `2w`." };
|
|
158
|
+
}
|
|
159
|
+
if (arg === "--since") {
|
|
160
|
+
index += 1;
|
|
161
|
+
}
|
|
162
|
+
filters.since = value;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (arg.startsWith("-")) {
|
|
167
|
+
return { kind: "error", message: `Unknown option: ${arg}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!command) {
|
|
171
|
+
command = arg;
|
|
172
|
+
} else {
|
|
173
|
+
extra.push(arg);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!command) {
|
|
178
|
+
return { kind: "error", message: "Missing command. Use `sat history`, `sat stats`, `sat focus`, `sat weak`, or `sat review`." };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (extra.length > 0) {
|
|
182
|
+
return { kind: "error", message: `Unexpected argument: ${extra[0]}` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (pretty && json) {
|
|
186
|
+
return { kind: "error", message: "Choose either `--pretty` or `--json`, not both." };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (command === "review") {
|
|
190
|
+
if (pretty || json || hasHistoryFilters(filters)) {
|
|
191
|
+
return { kind: "error", message: "`sat review` opens the TUI and does not support report flags." };
|
|
192
|
+
}
|
|
193
|
+
return { kind: "review" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!reportCommands.has(command as CliCommand)) {
|
|
197
|
+
return { kind: "error", message: `Unknown command: ${command}` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (command !== "history" && hasHistoryFilters(filters)) {
|
|
201
|
+
return { kind: "error", message: "History filters only work with `sat history`." };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const parsed: ParsedCli = {
|
|
205
|
+
kind: "command",
|
|
206
|
+
command: command as CliCommand,
|
|
207
|
+
format: json ? "json" : pretty ? "pretty" : "text",
|
|
208
|
+
...(noColor ? { color: false } : {}),
|
|
209
|
+
...(command === "history" && hasHistoryFilters(filters) ? { filters } : {}),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return parsed;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function runCliCommand(
|
|
216
|
+
parsed: Exclude<ParsedCli, { kind: "tui" | "review" }>,
|
|
217
|
+
stdout: Writable = process.stdout,
|
|
218
|
+
stderr: Writable = process.stderr,
|
|
219
|
+
): Promise<number> {
|
|
220
|
+
if (parsed.kind === "help") {
|
|
221
|
+
stdout.write(`${helpText()}\n`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (parsed.kind === "error") {
|
|
226
|
+
stderr.write(`${parsed.message}\n\n${helpText()}\n`);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const color = parsed.format === "pretty" ? resolveColor(parsed.color, stdout) : false;
|
|
231
|
+
const output = await commandOutput(parsed.command, parsed.format, {
|
|
232
|
+
color,
|
|
233
|
+
filters: parsed.filters,
|
|
234
|
+
});
|
|
235
|
+
stdout.write(`${output}\n`);
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function commandOutput(command: CliCommand, format: OutputFormat, options: FormatOptions = {}): Promise<string> {
|
|
240
|
+
if (command === "history") {
|
|
241
|
+
const attempts = await loadAttempts();
|
|
242
|
+
return formatHistory([...attempts.values()], format, options);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (command === "stats") {
|
|
246
|
+
const attempts = await loadAttempts();
|
|
247
|
+
const events = await loadAttemptEvents();
|
|
248
|
+
return formatStats(buildSummaryRows(attempts), format, { ...options, events });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (command === "weak") {
|
|
252
|
+
const attempts = await loadAttempts();
|
|
253
|
+
return formatWeak([...attempts.values()], format, options);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return formatFocus(await loadFocus(), format, options);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function formatHistory(attempts: Attempt[], format: OutputFormat, options: FormatOptions = {}): string {
|
|
260
|
+
const rows = filterHistory(attempts, options.filters, options.now);
|
|
261
|
+
const style = styleFor(options);
|
|
262
|
+
|
|
263
|
+
if (format === "json") {
|
|
264
|
+
return JSON.stringify(rows);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (rows.length === 0) {
|
|
268
|
+
return format === "pretty" ? [heading("history", style), muted("No attempts matched.", style)].join("\n") : "No attempts matched.";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (format === "pretty") {
|
|
272
|
+
return formatPrettyHistory(rows, style);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return formatTable(
|
|
276
|
+
["question", "outcome", "updated_at", "time"],
|
|
277
|
+
rows.map((attempt) => [
|
|
278
|
+
attempt.question_id,
|
|
279
|
+
attempt.outcome,
|
|
280
|
+
attempt.updated_at,
|
|
281
|
+
formatDuration(attempt.elapsed_seconds),
|
|
282
|
+
]),
|
|
283
|
+
false,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function formatStats(rows: SummaryRow[], format: OutputFormat, options: FormatOptions = {}): string {
|
|
288
|
+
const stats = statsObject(rows);
|
|
289
|
+
const activity = options.events ? buildActivityStats(options.events, options.now) : undefined;
|
|
290
|
+
const style = styleFor(options);
|
|
291
|
+
|
|
292
|
+
if (format === "json") {
|
|
293
|
+
return JSON.stringify(activity ? { ...stats, activity: activityPayload(activity) } : stats);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const entries = [
|
|
297
|
+
["answered", String(stats.answered)],
|
|
298
|
+
["correct", String(stats.correct)],
|
|
299
|
+
["incorrect", String(stats.incorrect)],
|
|
300
|
+
["corrected", String(stats.corrected)],
|
|
301
|
+
["accuracy", `${Math.round(stats.accuracy * 100)}%`],
|
|
302
|
+
["avg seconds", `${stats.avg_seconds.toFixed(1)}s`],
|
|
303
|
+
...(activity ? [["streak", `${activity.streak} days`], ["active days", String(activity.activeDays)]] : []),
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
if (format === "pretty") {
|
|
307
|
+
return formatPrettyStats(stats, activity, style);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return formatTable(["metric", "value"], entries, false);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function formatFocus(focus: Focus, format: OutputFormat, options: FormatOptions = {}): string {
|
|
314
|
+
const style = styleFor(options);
|
|
315
|
+
|
|
316
|
+
if (format === "json") {
|
|
317
|
+
return JSON.stringify(focus);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (format === "pretty") {
|
|
321
|
+
return [
|
|
322
|
+
heading("focus", style),
|
|
323
|
+
muted(focusSummary(focus), style),
|
|
324
|
+
"",
|
|
325
|
+
section("difficulty", style),
|
|
326
|
+
...focus.difficulties.map((value) => option(value, difficultyLabels[value], ansi.yellow, style)),
|
|
327
|
+
"",
|
|
328
|
+
section("domains", style),
|
|
329
|
+
...focus.domains.map((value) => option(value, domainLabels[value], ansi.cyan, style)),
|
|
330
|
+
"",
|
|
331
|
+
section("skills", style),
|
|
332
|
+
...focus.skills.map((value) => option(value, skillLabels[value], ansi.green, style)),
|
|
333
|
+
].join("\n");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return [
|
|
337
|
+
`difficulties: ${focus.difficulties.join(",")}`,
|
|
338
|
+
`domains: ${focus.domains.join(",")}`,
|
|
339
|
+
`skills: ${focus.skills.join(",")}`,
|
|
340
|
+
].join("\n");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function formatWeak(attempts: Attempt[], format: OutputFormat, options: FormatOptions = {}): string {
|
|
344
|
+
const rows = buildWeakRows(attempts);
|
|
345
|
+
const style = styleFor(options);
|
|
346
|
+
|
|
347
|
+
if (format === "json") {
|
|
348
|
+
return JSON.stringify(rows);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (rows.length === 0) {
|
|
352
|
+
const message = "No metadata-backed attempts yet. Answer new questions to build this report.";
|
|
353
|
+
return format === "pretty" ? [heading("weak spots", style), muted(message, style)].join("\n") : message;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (format === "pretty") {
|
|
357
|
+
return formatPrettyWeak(rows, style);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return formatTable(
|
|
361
|
+
["skill", "accuracy", "missed", "total", "avg", "focus"],
|
|
362
|
+
rows.map((row) => [
|
|
363
|
+
row.skill,
|
|
364
|
+
`${Math.round(row.accuracy * 100)}%`,
|
|
365
|
+
String(row.missed),
|
|
366
|
+
String(row.total),
|
|
367
|
+
formatDuration(Math.round(row.avg_seconds)),
|
|
368
|
+
row.label,
|
|
369
|
+
]),
|
|
370
|
+
false,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function helpText(): string {
|
|
375
|
+
return [
|
|
376
|
+
"usage: sat [command] [options]",
|
|
377
|
+
"",
|
|
378
|
+
"commands:",
|
|
379
|
+
" history Show answered question attempts",
|
|
380
|
+
" stats Show progress stats, streak, and activity",
|
|
381
|
+
" weak Show weak skills from recorded attempts",
|
|
382
|
+
" focus Show current practice focus",
|
|
383
|
+
" review Practice missed and corrected questions in the TUI",
|
|
384
|
+
"",
|
|
385
|
+
"history filters:",
|
|
386
|
+
" --wrong Show currently missed questions",
|
|
387
|
+
" --corrected Show corrected questions",
|
|
388
|
+
" --limit N Limit rows",
|
|
389
|
+
" --since WHEN Show rows since an ISO date, 7d, or 2w",
|
|
390
|
+
"",
|
|
391
|
+
"options:",
|
|
392
|
+
" -p, --pretty Use colorized human-readable output",
|
|
393
|
+
" --json Output JSON",
|
|
394
|
+
" --no-color Disable ANSI color in pretty output",
|
|
395
|
+
" -h, --help Show this help",
|
|
396
|
+
"",
|
|
397
|
+
"Run `sat` with no command to open the interactive TUI.",
|
|
398
|
+
].join("\n");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function filterHistory(attempts: Attempt[], filters: HistoryFilters = {}, now = new Date()): Attempt[] {
|
|
402
|
+
let rows = [...attempts].sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
403
|
+
|
|
404
|
+
if (filters.since) {
|
|
405
|
+
const since = parseSince(filters.since, now);
|
|
406
|
+
rows = rows.filter((attempt) => {
|
|
407
|
+
const updated = new Date(attempt.updated_at);
|
|
408
|
+
return Number.isFinite(updated.getTime()) && updated >= since;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (filters.wrong || filters.corrected) {
|
|
413
|
+
rows = rows.filter((attempt) =>
|
|
414
|
+
(filters.wrong && attempt.outcome === "incorrect") || (filters.corrected && attempt.outcome === "corrected")
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (filters.limit) {
|
|
419
|
+
rows = rows.slice(0, filters.limit);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return rows;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function buildActivityStats(events: AttemptEvent[], now = new Date(), days = 84): ActivityStats {
|
|
426
|
+
const counts = new Map<string, number>();
|
|
427
|
+
for (const event of events) {
|
|
428
|
+
const answered = new Date(event.answered_at);
|
|
429
|
+
if (Number.isNaN(answered.getTime())) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const key = dateKey(answered);
|
|
433
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const today = startOfDay(now);
|
|
437
|
+
const rangeStart = addDays(today, -(days - 1));
|
|
438
|
+
const activityDays: ActivityDay[] = [];
|
|
439
|
+
for (let offset = 0; offset < days; offset += 1) {
|
|
440
|
+
const date = addDays(rangeStart, offset);
|
|
441
|
+
const key = dateKey(date);
|
|
442
|
+
activityDays.push({ date: key, count: counts.get(key) ?? 0 });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
streak: streakDays(counts, today),
|
|
447
|
+
activeDays: activityDays.filter((day) => day.count > 0).length,
|
|
448
|
+
todayCount: counts.get(dateKey(today)) ?? 0,
|
|
449
|
+
totalEvents: events.length,
|
|
450
|
+
days: activityDays,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function buildWeakRows(attempts: Attempt[]): WeakRow[] {
|
|
455
|
+
const groups = new Map<string, WeakRow>();
|
|
456
|
+
|
|
457
|
+
for (const attempt of attempts) {
|
|
458
|
+
if (!attempt.skill) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const existing = groups.get(attempt.skill) ?? {
|
|
463
|
+
skill: attempt.skill,
|
|
464
|
+
label: attempt.skill_desc || skillLabels[attempt.skill as keyof typeof skillLabels] || attempt.skill,
|
|
465
|
+
domain: attempt.domain ?? "",
|
|
466
|
+
domainLabel: attempt.domain_desc || (attempt.domain ? domainLabels[attempt.domain as keyof typeof domainLabels] : "") || "",
|
|
467
|
+
total: 0,
|
|
468
|
+
mastered: 0,
|
|
469
|
+
missed: 0,
|
|
470
|
+
accuracy: 0,
|
|
471
|
+
avg_seconds: 0,
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
existing.total += 1;
|
|
475
|
+
existing.avg_seconds += attempt.elapsed_seconds;
|
|
476
|
+
if (attempt.outcome === "incorrect") {
|
|
477
|
+
existing.missed += 1;
|
|
478
|
+
} else {
|
|
479
|
+
existing.mastered += 1;
|
|
480
|
+
}
|
|
481
|
+
groups.set(attempt.skill, existing);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return [...groups.values()]
|
|
485
|
+
.map((row) => ({
|
|
486
|
+
...row,
|
|
487
|
+
accuracy: row.total === 0 ? 0 : row.mastered / row.total,
|
|
488
|
+
avg_seconds: row.total === 0 ? 0 : row.avg_seconds / row.total,
|
|
489
|
+
}))
|
|
490
|
+
.sort((a, b) =>
|
|
491
|
+
b.missed - a.missed ||
|
|
492
|
+
a.accuracy - b.accuracy ||
|
|
493
|
+
b.total - a.total ||
|
|
494
|
+
a.skill.localeCompare(b.skill)
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function formatPrettyHistory(rows: Attempt[], style: Style): string {
|
|
499
|
+
const mastered = rows.filter((attempt) => attempt.outcome === "correct" || attempt.outcome === "corrected").length;
|
|
500
|
+
const missed = rows.filter((attempt) => attempt.outcome === "incorrect").length;
|
|
501
|
+
const averageSeconds = rows.reduce((sum, attempt) => sum + attempt.elapsed_seconds, 0) / rows.length;
|
|
502
|
+
const tableRows = rows.map((attempt) => [
|
|
503
|
+
paint(attempt.question_id, style, ansi.cyan),
|
|
504
|
+
paint(attempt.outcome, style, outcomeColor(attempt.outcome), ansi.bold),
|
|
505
|
+
paint(attempt.skill ?? "-", style, ansi.green),
|
|
506
|
+
paint(formatDuration(attempt.elapsed_seconds), style, ansi.yellow),
|
|
507
|
+
muted(formatTimestamp(attempt.updated_at), style),
|
|
508
|
+
]);
|
|
509
|
+
|
|
510
|
+
return [
|
|
511
|
+
heading("history", style),
|
|
512
|
+
[
|
|
513
|
+
`${paint(String(rows.length), style, ansi.bold)} attempts`,
|
|
514
|
+
`${paint(String(mastered), style, ansi.green, ansi.bold)} mastered`,
|
|
515
|
+
`${paint(String(missed), style, missed > 0 ? ansi.red : ansi.gray, ansi.bold)} needs review`,
|
|
516
|
+
`${paint(formatDuration(Math.round(averageSeconds)), style, ansi.cyan, ansi.bold)} avg`,
|
|
517
|
+
].join(" "),
|
|
518
|
+
"",
|
|
519
|
+
prettyTable(["question", "result", "skill", "time", "updated"], tableRows, style),
|
|
520
|
+
].join("\n");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function formatPrettyStats(stats: ReturnType<typeof statsObject>, activity: ActivityStats | undefined, style: Style): string {
|
|
524
|
+
const accuracyPercent = Math.round(stats.accuracy * 100);
|
|
525
|
+
const lines = [
|
|
526
|
+
heading("stats", style),
|
|
527
|
+
[
|
|
528
|
+
`${paint(String(stats.answered), style, ansi.bold)} answered`,
|
|
529
|
+
`${paint(`${accuracyPercent}%`, style, accuracyColor(stats.accuracy), ansi.bold)} accuracy`,
|
|
530
|
+
`${paint(formatDuration(Math.round(stats.avg_seconds)), style, ansi.cyan, ansi.bold)} avg`,
|
|
531
|
+
...(activity ? [`${paint(String(activity.streak), style, ansi.green, ansi.bold)} day streak`] : []),
|
|
532
|
+
].join(" "),
|
|
533
|
+
"",
|
|
534
|
+
metricBar("correct", stats.correct, stats.answered, ansi.green, ansi.bgGreen, style),
|
|
535
|
+
metricBar("incorrect", stats.incorrect, stats.answered, ansi.red, ansi.bgRed, style),
|
|
536
|
+
metricBar("corrected", stats.corrected, stats.answered, ansi.yellow, ansi.bgYellow, style),
|
|
537
|
+
];
|
|
538
|
+
|
|
539
|
+
if (activity) {
|
|
540
|
+
lines.push(
|
|
541
|
+
"",
|
|
542
|
+
section("activity", style),
|
|
543
|
+
muted(`last 12 weeks | ${activity.activeDays} active days | ${activity.todayCount} today`, style),
|
|
544
|
+
...heatmapLines(activity.days, style),
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return lines.join("\n");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function formatPrettyWeak(rows: WeakRow[], style: Style): string {
|
|
552
|
+
const top = rows[0];
|
|
553
|
+
const tableRows = rows.map((row) => [
|
|
554
|
+
paint(row.skill, style, ansi.cyan, ansi.bold),
|
|
555
|
+
paint(`${Math.round(row.accuracy * 100)}%`, style, accuracyColor(row.accuracy), ansi.bold),
|
|
556
|
+
paint(`${row.missed}/${row.total}`, style, row.missed > 0 ? ansi.red : ansi.green),
|
|
557
|
+
paint(formatDuration(Math.round(row.avg_seconds)), style, ansi.yellow),
|
|
558
|
+
bar(row.mastered, row.total, ansi.green, ansi.bgGreen, style),
|
|
559
|
+
row.label,
|
|
560
|
+
]);
|
|
561
|
+
|
|
562
|
+
return [
|
|
563
|
+
heading("weak spots", style),
|
|
564
|
+
top
|
|
565
|
+
? `${paint(top.skill, style, ansi.cyan, ansi.bold)} has ${paint(String(top.missed), style, top.missed > 0 ? ansi.red : ansi.green, ansi.bold)} misses`
|
|
566
|
+
: muted("No weak spots yet.", style),
|
|
567
|
+
"",
|
|
568
|
+
prettyTable(["skill", "acc", "miss", "avg", "mastery", "focus"], tableRows, style),
|
|
569
|
+
].join("\n");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function metricBar(label: string, value: number, total: number, color: string, bgColor: string, style: Style): string {
|
|
573
|
+
const labelText = paint(label.padEnd(9), style, color, ansi.bold);
|
|
574
|
+
const valueText = paint(String(value).padStart(3), style, ansi.bold);
|
|
575
|
+
return `${labelText} ${valueText} ${bar(value, total, color, bgColor, style)}`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function bar(value: number, total: number, color: string, bgColor: string, style: Style): string {
|
|
579
|
+
const width = 24;
|
|
580
|
+
const ratio = total === 0 ? 0 : Math.min(1, Math.max(0, value / total));
|
|
581
|
+
|
|
582
|
+
if (!style.color) {
|
|
583
|
+
return `[${progressBarText(ratio, width)}]`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const exact = ratio * width;
|
|
587
|
+
const filledCells = Math.min(width, value > 0 && exact < 1 ? 1 : Math.round(exact));
|
|
588
|
+
const emptyCells = Math.max(0, width - filledCells);
|
|
589
|
+
const filled = filledCells > 0 ? paint(" ".repeat(filledCells), style, bgColor) : "";
|
|
590
|
+
const empty = emptyCells > 0 ? paint(" ".repeat(emptyCells), style, ansi.bgGray) : "";
|
|
591
|
+
return `${filled}${empty}`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function prettyTable(headers: string[], rows: string[][], style: Style): string {
|
|
595
|
+
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => visibleLength(row[index] ?? ""))));
|
|
596
|
+
const lines = [
|
|
597
|
+
headers.map((header, index) => muted(header.padEnd(widths[index] ?? header.length), style)).join(" ").trimEnd(),
|
|
598
|
+
];
|
|
599
|
+
|
|
600
|
+
lines.push(...rows.map((row) => row.map((value, index) => padEndVisible(value, widths[index] ?? visibleLength(value))).join(" ").trimEnd()));
|
|
601
|
+
return lines.join("\n");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function statsObject(rows: SummaryRow[]): {
|
|
605
|
+
answered: number;
|
|
606
|
+
correct: number;
|
|
607
|
+
incorrect: number;
|
|
608
|
+
corrected: number;
|
|
609
|
+
accuracy: number;
|
|
610
|
+
avg_seconds: number;
|
|
611
|
+
} {
|
|
612
|
+
const values = Object.fromEntries(rows.map((row) => [row.metric, row.value]));
|
|
613
|
+
return {
|
|
614
|
+
answered: readNumber(values.answered),
|
|
615
|
+
correct: readNumber(values.correct),
|
|
616
|
+
incorrect: readNumber(values.incorrect),
|
|
617
|
+
corrected: readNumber(values.corrected),
|
|
618
|
+
accuracy: readNumber(values.accuracy),
|
|
619
|
+
avg_seconds: readNumber(values.avg_seconds),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function activityPayload(activity: ActivityStats): Omit<ActivityStats, "days"> & { heatmap: ActivityDay[] } {
|
|
624
|
+
return {
|
|
625
|
+
streak: activity.streak,
|
|
626
|
+
activeDays: activity.activeDays,
|
|
627
|
+
todayCount: activity.todayCount,
|
|
628
|
+
totalEvents: activity.totalEvents,
|
|
629
|
+
heatmap: activity.days,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function heatmapLines(days: ActivityDay[], style: Style): string[] {
|
|
634
|
+
if (days.length === 0) {
|
|
635
|
+
return [];
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const first = parseDateKey(days[0]?.date ?? dateKey(new Date()));
|
|
639
|
+
const last = parseDateKey(days[days.length - 1]?.date ?? dateKey(new Date()));
|
|
640
|
+
const start = startOfWeek(first);
|
|
641
|
+
const dayMap = new Map(days.map((day) => [day.date, day.count]));
|
|
642
|
+
const weeks = Math.floor((startOfDay(last).getTime() - start.getTime()) / (7 * 86_400_000)) + 1;
|
|
643
|
+
const labels = [" ", "Mon", " ", "Wed", " ", "Fri", " "];
|
|
644
|
+
const lines: string[] = [monthLabelLine(start, weeks, first, last)];
|
|
645
|
+
|
|
646
|
+
for (let weekday = 0; weekday < 7; weekday += 1) {
|
|
647
|
+
const cells: string[] = [];
|
|
648
|
+
for (let week = 0; week < weeks; week += 1) {
|
|
649
|
+
const date = addDays(start, week * 7 + weekday);
|
|
650
|
+
if (date < first || date > last) {
|
|
651
|
+
cells.push(blankHeatCell());
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const count = dayMap.get(dateKey(date)) ?? 0;
|
|
655
|
+
cells.push(heatCell(count, style));
|
|
656
|
+
}
|
|
657
|
+
lines.push(`${labels[weekday]} ${cells.join(heatCellGap)}`.trimEnd());
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return lines;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const heatCellGap = " ";
|
|
664
|
+
const heatWeekWidth = 2;
|
|
665
|
+
|
|
666
|
+
function monthLabelLine(start: Date, weeks: number, first: Date, last: Date): string {
|
|
667
|
+
const columns = Array.from({ length: Math.max(0, weeks * heatWeekWidth - heatCellGap.length) }, () => " ");
|
|
668
|
+
let previousMonth = "";
|
|
669
|
+
|
|
670
|
+
for (let week = 0; week < weeks; week += 1) {
|
|
671
|
+
const weekStart = addDays(start, week * 7);
|
|
672
|
+
const weekEnd = addDays(weekStart, 6);
|
|
673
|
+
if (weekEnd < first || weekStart > last) {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const monthKey = `${weekStart.getFullYear()}-${weekStart.getMonth()}`;
|
|
678
|
+
const label = monthKey !== previousMonth ? monthName(weekStart).slice(0, 3) : "";
|
|
679
|
+
const offset = Math.min(week * heatWeekWidth, Math.max(0, columns.length - label.length));
|
|
680
|
+
for (let index = 0; index < label.length && offset + index < columns.length; index += 1) {
|
|
681
|
+
columns[offset + index] = label[index] ?? " ";
|
|
682
|
+
}
|
|
683
|
+
previousMonth = monthKey;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return ` ${columns.join("")}`.trimEnd();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function heatCell(count: number, style: Style): string {
|
|
690
|
+
if (!style.color) {
|
|
691
|
+
return count === 0 ? "□" : "■";
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return paint("■", style, heatColor(count));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function blankHeatCell(): string {
|
|
698
|
+
return " ";
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function heatColor(count: number): string {
|
|
702
|
+
if (count === 0) {
|
|
703
|
+
return ansi.heatEmpty;
|
|
704
|
+
}
|
|
705
|
+
if (count <= 2) {
|
|
706
|
+
return ansi.heatLow;
|
|
707
|
+
}
|
|
708
|
+
if (count <= 4) {
|
|
709
|
+
return ansi.heatMid;
|
|
710
|
+
}
|
|
711
|
+
if (count <= 6) {
|
|
712
|
+
return ansi.heatHigh;
|
|
713
|
+
}
|
|
714
|
+
return ansi.heatMax;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function streakDays(counts: Map<string, number>, today: Date): number {
|
|
718
|
+
if (counts.size === 0) {
|
|
719
|
+
return 0;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let cursor = startOfDay(today);
|
|
723
|
+
if ((counts.get(dateKey(cursor)) ?? 0) === 0) {
|
|
724
|
+
cursor = addDays(cursor, -1);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
let streak = 0;
|
|
728
|
+
while ((counts.get(dateKey(cursor)) ?? 0) > 0) {
|
|
729
|
+
streak += 1;
|
|
730
|
+
cursor = addDays(cursor, -1);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return streak;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function parseSince(value: string, now: Date): Date {
|
|
737
|
+
const relative = /^(\d+)(d|w)$/.exec(value);
|
|
738
|
+
if (relative) {
|
|
739
|
+
const amount = Number(relative[1]);
|
|
740
|
+
const unit = relative[2];
|
|
741
|
+
return addDays(startOfDay(now), -(unit === "w" ? amount * 7 : amount));
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const date = new Date(value);
|
|
745
|
+
if (Number.isNaN(date.getTime())) {
|
|
746
|
+
throw new Error(`Invalid --since value: ${value}`);
|
|
747
|
+
}
|
|
748
|
+
return date;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function isSinceValue(value: string): boolean {
|
|
752
|
+
return /^(\d+)(d|w)$/.test(value) || !Number.isNaN(new Date(value).getTime());
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function optionValue(args: string[], index: number, name: "--limit" | "--since"): string | undefined {
|
|
756
|
+
const arg = args[index] ?? "";
|
|
757
|
+
if (arg.startsWith(`${name}=`)) {
|
|
758
|
+
return arg.slice(name.length + 1);
|
|
759
|
+
}
|
|
760
|
+
const next = args[index + 1];
|
|
761
|
+
return next && !next.startsWith("-") ? next : undefined;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function hasHistoryFilters(filters: HistoryFilters): boolean {
|
|
765
|
+
return Boolean(filters.wrong || filters.corrected || filters.limit || filters.since);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function resolveColor(parsedColor: boolean | undefined, stdout: Writable): boolean {
|
|
769
|
+
if (parsedColor === false) {
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
return stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function styleFor(options: FormatOptions): Style {
|
|
776
|
+
return { color: options.color ?? true };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function readNumber(value: string | undefined): number {
|
|
780
|
+
const number = Number(value);
|
|
781
|
+
return Number.isFinite(number) ? number : 0;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function formatTimestamp(value: string): string {
|
|
785
|
+
const date = new Date(value);
|
|
786
|
+
if (Number.isNaN(date.getTime())) {
|
|
787
|
+
return value;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][date.getUTCMonth()];
|
|
791
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
792
|
+
const hour = String(date.getUTCHours()).padStart(2, "0");
|
|
793
|
+
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
|
794
|
+
return `${month} ${day} ${date.getUTCFullYear()} ${hour}:${minute}`;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function formatDuration(seconds: number): string {
|
|
798
|
+
const rounded = Math.max(0, Math.round(seconds));
|
|
799
|
+
const minutes = Math.floor(rounded / 60);
|
|
800
|
+
const remaining = rounded % 60;
|
|
801
|
+
return `${minutes}:${String(remaining).padStart(2, "0")}`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function formatTable(headers: string[], rows: string[][], separator: boolean): string {
|
|
805
|
+
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)));
|
|
806
|
+
const lines = [formatTableRow(headers, widths)];
|
|
807
|
+
|
|
808
|
+
if (separator) {
|
|
809
|
+
lines.push(widths.map((width) => "-".repeat(width)).join(" "));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
lines.push(...rows.map((row) => formatTableRow(row, widths)));
|
|
813
|
+
return lines.join("\n");
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function formatTableRow(row: string[], widths: number[]): string {
|
|
817
|
+
return row.map((value, index) => value.padEnd(widths[index] ?? value.length)).join(" ").trimEnd();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function heading(value: string, style: Style): string {
|
|
821
|
+
return paint(value, style, ansi.bold, ansi.cyan);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function section(value: string, style: Style): string {
|
|
825
|
+
return paint(value, style, ansi.bold);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function option(code: string, label: string, color: string, style: Style): string {
|
|
829
|
+
return ` ${paint(code.padEnd(3), style, color, ansi.bold)} ${label}`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function muted(value: string, style: Style): string {
|
|
833
|
+
return paint(value, style, ansi.gray);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function outcomeColor(outcome: Attempt["outcome"]): string {
|
|
837
|
+
if (outcome === "correct") {
|
|
838
|
+
return ansi.green;
|
|
839
|
+
}
|
|
840
|
+
if (outcome === "corrected") {
|
|
841
|
+
return ansi.yellow;
|
|
842
|
+
}
|
|
843
|
+
return ansi.red;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function accuracyColor(accuracy: number): string {
|
|
847
|
+
if (accuracy >= 0.8) {
|
|
848
|
+
return ansi.green;
|
|
849
|
+
}
|
|
850
|
+
if (accuracy >= 0.6) {
|
|
851
|
+
return ansi.yellow;
|
|
852
|
+
}
|
|
853
|
+
return ansi.red;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function paint(value: string, style: Style, ...styles: string[]): string {
|
|
857
|
+
if (!style.color || styles.length === 0) {
|
|
858
|
+
return value;
|
|
859
|
+
}
|
|
860
|
+
return `${styles.join("")}${value}${ansi.reset}`;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function padEndVisible(value: string, width: number): string {
|
|
864
|
+
return `${value}${" ".repeat(Math.max(0, width - visibleLength(value)))}`;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function visibleLength(value: string): number {
|
|
868
|
+
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function startOfDay(date: Date): Date {
|
|
872
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function startOfWeek(date: Date): Date {
|
|
876
|
+
return addDays(startOfDay(date), -startOfDay(date).getDay());
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function addDays(date: Date, days: number): Date {
|
|
880
|
+
const next = new Date(date);
|
|
881
|
+
next.setDate(next.getDate() + days);
|
|
882
|
+
return next;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function dateKey(date: Date): string {
|
|
886
|
+
const year = date.getFullYear();
|
|
887
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
888
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
889
|
+
return `${year}-${month}-${day}`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function parseDateKey(value: string): Date {
|
|
893
|
+
const [year = "0", month = "1", day = "1"] = value.split("-");
|
|
894
|
+
return new Date(Number(year), Number(month) - 1, Number(day));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function monthName(date: Date): string {
|
|
898
|
+
return ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][date.getMonth()] ?? "";
|
|
899
|
+
}
|