oscillation-cli 1.0.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/README.md +44 -0
- package/bin/oscillation.js +57 -0
- package/package.json +26 -0
- package/src/api.js +36 -0
- package/src/commands/init.js +98 -0
- package/src/commands/status.js +32 -0
- package/src/commands/submit.js +47 -0
- package/src/config.js +34 -0
- package/src/editor.js +85 -0
- package/src/ui.js +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# oscillation-cli
|
|
2
|
+
|
|
3
|
+
Run your [Oscillation](https://tryoscillation.com) technical assessment from your terminal.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g oscillation-cli
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Start your assessment. Run this in the folder where you want to work — **it starts your timer**, so only run it when you're ready.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
oscillation init <assessment-id>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
This downloads the project into a new folder, opens it in Cursor or VS Code, and starts the clock. Your assessment id is on your Oscillation dashboard.
|
|
18
|
+
|
|
19
|
+
Check how much time you have left:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
oscillation status
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Submit your work, from anywhere inside the assessment folder:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
oscillation submit
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Notes
|
|
32
|
+
|
|
33
|
+
- Re-running `init` never resets your timer — the clock is kept on the server, and the original deadline stands.
|
|
34
|
+
- `init` never overwrites an existing folder; it creates `sample-assessment-2`, `-3`, and so on.
|
|
35
|
+
- If neither Cursor nor VS Code is found, the CLI prints the folder path for you to open yourself.
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
| Variable | Default | Purpose |
|
|
40
|
+
| --------------------- | ---------------------------- | -------------------------------- |
|
|
41
|
+
| `OSCILLATION_API_URL` | `https://tryoscillation.com` | Point the CLI at another backend. |
|
|
42
|
+
| `NO_COLOR` | — | Disable colored output. |
|
|
43
|
+
|
|
44
|
+
Requires Node.js 20 or newer.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { CliError } from "../src/api.js";
|
|
3
|
+
import { init } from "../src/commands/init.js";
|
|
4
|
+
import { status } from "../src/commands/status.js";
|
|
5
|
+
import { submit } from "../src/commands/submit.js";
|
|
6
|
+
import { bold, dim, red } from "../src/ui.js";
|
|
7
|
+
|
|
8
|
+
const HELP = `
|
|
9
|
+
${bold("oscillation")} — run your Oscillation assessment
|
|
10
|
+
|
|
11
|
+
${bold("Commands")}
|
|
12
|
+
init <assessment-id> Download the assessment and start your timer
|
|
13
|
+
status Show how much time you have left
|
|
14
|
+
submit Submit your work
|
|
15
|
+
|
|
16
|
+
${dim("Run init in the folder where you want to work. Your timer starts then.")}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const [command, ...args] = process.argv.slice(2);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
switch (command) {
|
|
23
|
+
case "init":
|
|
24
|
+
await init(args[0]);
|
|
25
|
+
break;
|
|
26
|
+
case "status":
|
|
27
|
+
await status();
|
|
28
|
+
break;
|
|
29
|
+
case "submit":
|
|
30
|
+
await submit();
|
|
31
|
+
break;
|
|
32
|
+
case "help":
|
|
33
|
+
case "--help":
|
|
34
|
+
case "-h":
|
|
35
|
+
case undefined:
|
|
36
|
+
console.log(HELP);
|
|
37
|
+
break;
|
|
38
|
+
case "--version":
|
|
39
|
+
case "-v": {
|
|
40
|
+
const { default: pkg } = await import("../package.json", {
|
|
41
|
+
with: { type: "json" },
|
|
42
|
+
});
|
|
43
|
+
console.log(pkg.version);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
default:
|
|
47
|
+
console.error(`Unknown command: ${command}`);
|
|
48
|
+
console.log(HELP);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err instanceof CliError) {
|
|
53
|
+
console.error(`\n${red("✗")} ${err.message}\n`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oscillation-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Run Oscillation technical assessments from your terminal.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"oscillation": "bin/oscillation.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"tar": "^7.5.20"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"oscillation",
|
|
22
|
+
"assessment",
|
|
23
|
+
"hiring"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT"
|
|
26
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { API_BASE } from "./config.js";
|
|
2
|
+
|
|
3
|
+
/** Errors we print as a clean message rather than a stack trace. */
|
|
4
|
+
export class CliError extends Error {}
|
|
5
|
+
|
|
6
|
+
async function request(pathname, init) {
|
|
7
|
+
let res;
|
|
8
|
+
try {
|
|
9
|
+
res = await fetch(`${API_BASE}/api/cli${pathname}`, init);
|
|
10
|
+
} catch {
|
|
11
|
+
throw new CliError(`Can't reach Oscillation at ${API_BASE}.`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
const body = await res.json().catch(() => null);
|
|
16
|
+
throw new CliError(body?.error ?? `Request failed (${res.status}).`);
|
|
17
|
+
}
|
|
18
|
+
return res;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function getInvite(id) {
|
|
22
|
+
return (await request(`/invites/${id}`)).json();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function startInvite(id) {
|
|
26
|
+
return (await request(`/invites/${id}/start`, { method: "POST" })).json();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function submitInvite(id) {
|
|
30
|
+
return (await request(`/invites/${id}/submit`, { method: "POST" })).json();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Web stream of the assessment project as .tar.gz. */
|
|
34
|
+
export async function getBundle(id) {
|
|
35
|
+
return (await request(`/invites/${id}/bundle`)).body;
|
|
36
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import * as tar from "tar";
|
|
6
|
+
import { getBundle, getInvite, startInvite, CliError } from "../api.js";
|
|
7
|
+
import { writeState } from "../config.js";
|
|
8
|
+
import { openEditor } from "../editor.js";
|
|
9
|
+
import { bold, dim, green, formatDeadline, formatDuration } from "../ui.js";
|
|
10
|
+
|
|
11
|
+
/** "Sample Assessment" -> "sample-assessment" */
|
|
12
|
+
function slugify(title) {
|
|
13
|
+
return (
|
|
14
|
+
title
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
17
|
+
.replace(/^-|-$/g, "") || "assessment"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Never clobber an existing folder: sample-assessment, -2, -3, ... */
|
|
22
|
+
async function availableDir(base) {
|
|
23
|
+
for (let n = 1; ; n++) {
|
|
24
|
+
const dir = n === 1 ? base : `${base}-${n}`;
|
|
25
|
+
try {
|
|
26
|
+
await fs.access(dir);
|
|
27
|
+
} catch {
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function init(id) {
|
|
34
|
+
if (!id) {
|
|
35
|
+
throw new CliError(
|
|
36
|
+
"Usage: oscillation init <assessment-id>\n" +
|
|
37
|
+
"Find your assessment id on your Oscillation dashboard.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const invite = await getInvite(id);
|
|
42
|
+
|
|
43
|
+
if (invite.status === "SUBMITTED") {
|
|
44
|
+
throw new CliError("You've already submitted this assessment.");
|
|
45
|
+
}
|
|
46
|
+
if (invite.status === "EXPIRED") {
|
|
47
|
+
throw new CliError("This assessment has expired.");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { assessment } = invite;
|
|
51
|
+
console.log(`\n${bold(assessment.title)} ${dim("·")} ${assessment.company}`);
|
|
52
|
+
if (assessment.description) console.log(dim(assessment.description));
|
|
53
|
+
|
|
54
|
+
const dir = path.resolve(await availableDir(slugify(assessment.title)));
|
|
55
|
+
|
|
56
|
+
// Download and unpack first, then start the clock — a slow network shouldn't
|
|
57
|
+
// eat into the candidate's time.
|
|
58
|
+
console.log(dim("\nDownloading assessment..."));
|
|
59
|
+
await fs.mkdir(dir, { recursive: true });
|
|
60
|
+
const bundle = await getBundle(id);
|
|
61
|
+
await pipeline(Readable.fromWeb(bundle), tar.extract({ cwd: dir }));
|
|
62
|
+
|
|
63
|
+
// INVITED means this call starts the clock; STARTED means it was already
|
|
64
|
+
// running and the server kept the original deadline.
|
|
65
|
+
const resuming = invite.status === "STARTED";
|
|
66
|
+
const started = await startInvite(id);
|
|
67
|
+
await writeState(dir, {
|
|
68
|
+
inviteId: id,
|
|
69
|
+
title: assessment.title,
|
|
70
|
+
company: assessment.company,
|
|
71
|
+
startedAt: started.startedAt,
|
|
72
|
+
deadline: started.deadline,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
console.log(
|
|
76
|
+
`${green("✓")} Ready in ${bold(path.relative(process.cwd(), dir) || ".")}`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const editor = await openEditor(dir);
|
|
80
|
+
console.log(
|
|
81
|
+
editor
|
|
82
|
+
? `${green("✓")} Opening in ${editor}...`
|
|
83
|
+
: dim(` Open this folder in your editor: ${dir}`),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
console.log(
|
|
87
|
+
`\n${bold(
|
|
88
|
+
resuming ? "⏱ Your timer is already running." : "⏱ Your timer has started.",
|
|
89
|
+
)} ` +
|
|
90
|
+
`${formatDuration(started.secondsRemaining)} remaining ` +
|
|
91
|
+
dim(`(due by ${formatDeadline(started.deadline)})`),
|
|
92
|
+
);
|
|
93
|
+
console.log(dim(" Read README.md for the task."));
|
|
94
|
+
console.log(
|
|
95
|
+
`\n Check the clock: ${bold("oscillation status")}\n` +
|
|
96
|
+
` When you're done: ${bold("oscillation submit")}\n`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getInvite, CliError } from "../api.js";
|
|
2
|
+
import { findState } from "../config.js";
|
|
3
|
+
import { bold, dim, green, yellow, formatDeadline, formatDuration } from "../ui.js";
|
|
4
|
+
|
|
5
|
+
export async function status() {
|
|
6
|
+
const found = await findState();
|
|
7
|
+
if (!found) {
|
|
8
|
+
throw new CliError(
|
|
9
|
+
"No assessment found here.\n" +
|
|
10
|
+
"Run this from inside your assessment folder (the one `oscillation init` created).",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Ask the server rather than trusting the local clock — it's the timer of record.
|
|
15
|
+
const invite = await getInvite(found.state.inviteId);
|
|
16
|
+
console.log(
|
|
17
|
+
`\n${bold(invite.assessment.title)} ${dim("·")} ${invite.assessment.company}`,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
if (invite.status === "SUBMITTED") {
|
|
21
|
+
console.log(`${green("✓")} Submitted.\n`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log(
|
|
26
|
+
invite.secondsRemaining > 0
|
|
27
|
+
? `⏱ ${bold(formatDuration(invite.secondsRemaining))} remaining ` +
|
|
28
|
+
dim(`(due by ${formatDeadline(invite.deadline)})`)
|
|
29
|
+
: yellow("⏱ Time is up — submit as soon as you can."),
|
|
30
|
+
);
|
|
31
|
+
console.log(dim(` Submit with: oscillation submit\n`));
|
|
32
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { submitInvite, CliError } from "../api.js";
|
|
3
|
+
import { findState } from "../config.js";
|
|
4
|
+
import { bold, dim, green, yellow, formatDuration } from "../ui.js";
|
|
5
|
+
|
|
6
|
+
async function confirm(question) {
|
|
7
|
+
// Non-interactive (CI, piped input): don't hang waiting on a prompt.
|
|
8
|
+
if (!process.stdin.isTTY) return true;
|
|
9
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
+
try {
|
|
11
|
+
const answer = await rl.question(`${question} ${dim("(y/N)")} `);
|
|
12
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
13
|
+
} finally {
|
|
14
|
+
rl.close();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function submit() {
|
|
19
|
+
const found = await findState();
|
|
20
|
+
if (!found) {
|
|
21
|
+
throw new CliError(
|
|
22
|
+
"No assessment found here.\n" +
|
|
23
|
+
"Run this from inside your assessment folder (the one `oscillation init` created).",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { state } = found;
|
|
28
|
+
console.log(`\n${bold(state.title)} ${dim("·")} ${state.company}`);
|
|
29
|
+
|
|
30
|
+
const remaining = Math.max(
|
|
31
|
+
0,
|
|
32
|
+
Math.floor((new Date(state.deadline) - Date.now()) / 1000),
|
|
33
|
+
);
|
|
34
|
+
console.log(
|
|
35
|
+
remaining > 0
|
|
36
|
+
? dim(`${formatDuration(remaining)} still on the clock.`)
|
|
37
|
+
: yellow("Your time is already up — this will submit late."),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (!(await confirm("\nSubmit your work? This can't be undone."))) {
|
|
41
|
+
console.log(dim("Cancelled — nothing was submitted.\n"));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await submitInvite(state.inviteId);
|
|
46
|
+
console.log(`\n${green("✓")} Submitted. ${dim("Thanks — you're all done.")}\n`);
|
|
47
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Points at the deployed app; overridable for local development. */
|
|
5
|
+
export const API_BASE =
|
|
6
|
+
process.env.OSCILLATION_API_URL ?? "https://tryoscillation.com";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Written into the assessment folder by `init` so `submit` knows which invite
|
|
10
|
+
* it belongs to without the candidate pasting the uuid again.
|
|
11
|
+
*/
|
|
12
|
+
export const STATE_FILE = ".oscillation.json";
|
|
13
|
+
|
|
14
|
+
export async function writeState(dir, state) {
|
|
15
|
+
await fs.writeFile(
|
|
16
|
+
path.join(dir, STATE_FILE),
|
|
17
|
+
JSON.stringify(state, null, 2) + "\n",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Walks up from cwd so `submit` works from anywhere inside the project. */
|
|
22
|
+
export async function findState(from = process.cwd()) {
|
|
23
|
+
let dir = path.resolve(from);
|
|
24
|
+
while (true) {
|
|
25
|
+
try {
|
|
26
|
+
const raw = await fs.readFile(path.join(dir, STATE_FILE), "utf8");
|
|
27
|
+
return { dir, state: JSON.parse(raw) };
|
|
28
|
+
} catch {
|
|
29
|
+
const parent = path.dirname(dir);
|
|
30
|
+
if (parent === dir) return null;
|
|
31
|
+
dir = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/editor.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Cursor first, then VS Code — matching the editors the assessment flow assumes.
|
|
4
|
+
const EDITORS = [
|
|
5
|
+
{
|
|
6
|
+
name: "Cursor",
|
|
7
|
+
cmd: "cursor",
|
|
8
|
+
// The CLI launcher inside the app bundle, for the common case where the
|
|
9
|
+
// user never ran "Install 'cursor' command in PATH".
|
|
10
|
+
mac: "/Applications/Cursor.app/Contents/Resources/app/bin/code",
|
|
11
|
+
app: "Cursor",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: "VS Code",
|
|
15
|
+
cmd: "code",
|
|
16
|
+
mac: "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
|
|
17
|
+
app: "Visual Studio Code",
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Spawns without a shell — the candidate's path may contain spaces or shell
|
|
23
|
+
* metacharacters, and shell args are concatenated rather than escaped.
|
|
24
|
+
* Windows launchers are `.cmd` shims, so cmd.exe is invoked directly there
|
|
25
|
+
* with the path kept as its own argument.
|
|
26
|
+
*/
|
|
27
|
+
function trySpawn(cmd, args) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const child =
|
|
30
|
+
process.platform === "win32"
|
|
31
|
+
? spawn("cmd.exe", ["/c", cmd, ...args], {
|
|
32
|
+
stdio: "ignore",
|
|
33
|
+
detached: true,
|
|
34
|
+
})
|
|
35
|
+
: spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
36
|
+
|
|
37
|
+
child.on("error", () => resolve(false));
|
|
38
|
+
child.on("spawn", () => {
|
|
39
|
+
child.unref();
|
|
40
|
+
resolve(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Opens the assessment folder in the first editor we can find. Returns the
|
|
47
|
+
* editor name, or null if none is installed — never throws, since failing to
|
|
48
|
+
* open an editor must not fail `init`.
|
|
49
|
+
*/
|
|
50
|
+
export async function openEditor(dir) {
|
|
51
|
+
for (const editor of EDITORS) {
|
|
52
|
+
const candidates = [
|
|
53
|
+
// On PATH (works when the user installed the shell command).
|
|
54
|
+
process.platform === "win32" ? `${editor.cmd}.cmd` : editor.cmd,
|
|
55
|
+
// The launcher inside the macOS app bundle.
|
|
56
|
+
...(process.platform === "darwin" ? [editor.mac] : []),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const cmd of candidates) {
|
|
60
|
+
if (await trySpawn(cmd, [dir])) return editor.name;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Last resort on macOS: hand the folder to LaunchServices, which finds the
|
|
64
|
+
// app wherever it's installed.
|
|
65
|
+
if (
|
|
66
|
+
process.platform === "darwin" &&
|
|
67
|
+
(await trySpawnOpen(editor.app, dir))
|
|
68
|
+
) {
|
|
69
|
+
return editor.name;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `open -a` exits non-zero when the app isn't installed, so unlike the spawns
|
|
77
|
+
* above we have to wait for the exit code rather than trust a successful spawn.
|
|
78
|
+
*/
|
|
79
|
+
function trySpawnOpen(appName, dir) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
const child = spawn("open", ["-a", appName, dir], { stdio: "ignore" });
|
|
82
|
+
child.on("error", () => resolve(false));
|
|
83
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
84
|
+
});
|
|
85
|
+
}
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2
|
+
const wrap = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
3
|
+
|
|
4
|
+
export const bold = wrap("1");
|
|
5
|
+
export const dim = wrap("2");
|
|
6
|
+
export const green = wrap("32");
|
|
7
|
+
export const yellow = wrap("33");
|
|
8
|
+
export const red = wrap("31");
|
|
9
|
+
|
|
10
|
+
export function formatDuration(seconds) {
|
|
11
|
+
const h = Math.floor(seconds / 3600);
|
|
12
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
13
|
+
if (h === 0) return `${m}m`;
|
|
14
|
+
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Local wall-clock time, so "due by" matches the candidate's own clock. */
|
|
18
|
+
export function formatDeadline(iso) {
|
|
19
|
+
return new Date(iso).toLocaleTimeString([], {
|
|
20
|
+
hour: "2-digit",
|
|
21
|
+
minute: "2-digit",
|
|
22
|
+
});
|
|
23
|
+
}
|