Skip to content

Commit 7c34d49

Browse files
committed
feat: add Cron Builder developer tool
- new /tools/cron-builder route: visual 5-field cron editor (Every/Specific/Range/Interval per field) synced with a raw expression input, deterministic human-readable description, next 5 runs previewed in the browser's timezone, and 8 presets - cron parsing, validation, and next-run calculation are hand-rolled (no new dependency — every version of cron-parser requires luxon), keeping the supported syntax strictly under our own control - /tools now renders from a small typed registry (src/lib/tools.ts) instead of one hard-coded card, ready for the next lightweight tool - 42 unit tests (parsing/validation/next-run/description) + 8 Playwright e2e tests, including an axe accessibility check
1 parent d3fed4a commit 7c34d49

22 files changed

Lines changed: 2153 additions & 52 deletions

‎CHANGELOG.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@ InfraLens's history as a standalone product (2026-01-06 to 2026-08-10) is
99
frozen in [`docs/infralens/CHANGELOG.md`](docs/infralens/CHANGELOG.md).
1010
InfraLens changes since its native migration are recorded here.
1111

12+
## [1.2.0] — 2026-08-15
13+
14+
### Added
15+
16+
- **Cron Builder** (`/tools/cron-builder`) — a new developer tool for
17+
building, validating, and understanding standard Unix 5-field cron
18+
expressions. A visual per-field editor (Every/Specific/Range/Interval
19+
modes) stays in sync with a raw expression input, backed by a
20+
deterministic human-readable description, the next 5 execution times
21+
previewed in the browser's detected timezone, and 8 common presets.
22+
Parsing, validation, and next-run calculation are hand-rolled rather
23+
than pulled from a cron library — every version of `cron-parser`
24+
mandates `luxon` as a dependency, and hand-rolling keeps the tool's
25+
supported syntax (wildcards, lists, ranges, steps — no seconds/year/
26+
Quartz extensions) strictly under our own control instead of a
27+
general-purpose parser's.
28+
29+
### Changed
30+
31+
- `/tools` now renders its cards from a small typed registry
32+
(`src/lib/tools.ts`) instead of one hard-coded card, so the next
33+
lightweight tool (JSON Studio, MetaLens) is a registry entry, not
34+
copy-pasted markup.
35+
1236
## [1.1.0] — 2026-08-15
1337

1438
### Added

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ app/
6363
projects/ # Project case studies (listing + [slug])
6464
tools/ # Developer tools
6565
infralens/ # InfraLens — native routes (analyze, compare, docs, privacy)
66+
cron-builder/ # Cron Builder — visual cron expression editor
6667
rss.xml/ # RSS feed
6768
sitemap.ts, robots.ts, manifest.ts, opengraph-image.tsx
6869
@@ -71,6 +72,7 @@ src/
7172
components/ # Shared UI (map, layout, shadcn/ui primitives)
7273
lib/ # Content registries, brand tokens, navigation, JSON-LD
7374
infralens/ # InfraLens engine — checks, security/SSRF, DNS, scoring, history
75+
cron-builder/ # Cron Builder engine — parsing, validation, next-run calculation
7476
7577
docs/
7678
infralens/ # InfraLens developer docs, changelog, security policy, MIT license

‎app/sitemap.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,20 @@ export default function sitemap(): MetadataRoute.Sitemap {
9191
},
9292
];
9393

94+
const cronBuilderRoutes: MetadataRoute.Sitemap = [
95+
{
96+
url: `${BASE_URL}/tools/cron-builder`,
97+
lastModified: new Date(),
98+
changeFrequency: "monthly",
99+
priority: 0.6,
100+
},
101+
];
102+
94103
return [
95104
...staticRoutes,
96105
...articleRoutes,
97106
...projectRoutes,
98107
...infralensRoutes,
108+
...cronBuilderRoutes,
99109
];
100110
}

‎app/tools/cron-builder/page.tsx‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { PageShell } from "@/components/layout/page-shell";
2+
import { CronBuilder } from "@/cron-builder/components/cron-builder";
3+
import { CronReference } from "@/cron-builder/components/cron-reference";
4+
import { brand } from "@/lib/brand";
5+
import type { Metadata } from "next";
6+
7+
export const metadata: Metadata = {
8+
title: "Cron Builder — Randy Code",
9+
description:
10+
"Build, validate, and understand cron expressions with a visual editor and upcoming execution preview.",
11+
alternates: { canonical: "/tools/cron-builder" },
12+
};
13+
14+
export default function CronBuilderPage() {
15+
return (
16+
<PageShell
17+
label="Developer Tool"
18+
title="Cron Builder"
19+
tagline="Build, validate, and understand cron expressions without memorizing the syntax."
20+
color={brand.colors.green[500]}
21+
icon="clock"
22+
>
23+
<CronBuilder />
24+
<CronReference />
25+
</PageShell>
26+
);
27+
}

‎app/tools/page.tsx‎

Lines changed: 61 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { PageShell } from "@/components/layout/page-shell";
22
import { brand } from "@/lib/brand";
3+
import { tools } from "@/lib/tools";
34
import type { Metadata } from "next";
45
import Image from "next/image";
56
import Link from "next/link";
@@ -20,59 +21,68 @@ export default function ToolsPage() {
2021
color={brand.colors.green[500]}
2122
icon="wrench"
2223
>
23-
<section className="mb-12">
24-
<div
25-
className="rounded-2xl border p-8"
26-
style={{
27-
borderColor: `${brand.colors.green[500]}30`,
28-
background: brand.colors.surface[2],
29-
}}
30-
>
31-
<div className="mb-4 flex items-center justify-between">
32-
<div className="flex items-center gap-2">
33-
<Image
34-
src="/infralens/brand/logo-symbol.png"
35-
alt=""
36-
width={24}
37-
height={24}
38-
className="h-6 w-6"
39-
/>
40-
<span className="sr-only">InfraLens</span>
41-
</div>
42-
<span
43-
className="inline-block rounded-md px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider"
44-
style={{
45-
backgroundColor: `${brand.colors.green[500]}18`,
46-
color: brand.colors.green[500],
47-
}}
24+
<div className="flex flex-col gap-6">
25+
{tools.map((tool) => (
26+
<section
27+
key={tool.slug}
28+
className="rounded-2xl border p-8"
29+
style={{
30+
borderColor: `${brand.colors.green[500]}30`,
31+
background: brand.colors.surface[2],
32+
}}
33+
>
34+
<div
35+
className={`mb-4 flex items-center ${tool.logo ? "justify-between" : "justify-end"}`}
4836
>
49-
Disponible
50-
</span>
51-
</div>
37+
{tool.logo && (
38+
<div className="flex items-center gap-2">
39+
<Image
40+
src={tool.logo.src}
41+
alt={tool.logo.alt}
42+
width={24}
43+
height={24}
44+
className="h-6 w-6"
45+
/>
46+
<span className="sr-only">{tool.name}</span>
47+
</div>
48+
)}
49+
<span
50+
className="inline-block rounded-md px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider"
51+
style={{
52+
backgroundColor: `${brand.colors.green[500]}18`,
53+
color: brand.colors.green[500],
54+
}}
55+
>
56+
{tool.status}
57+
</span>
58+
</div>
5259

53-
<h2 className="text-2xl font-bold text-foreground">InfraLens</h2>
54-
<p className="mt-2 max-w-lg text-sm text-muted-foreground">
55-
Outil open source d&apos;inspection technique de sites web : DNS,
56-
TLS, headers HTTP et sécurité, infrastructure, robots.txt et
57-
sitemap, en un seul rapport lisible.
58-
</p>
59-
<p className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
60-
<Link
61-
href="/tools/infralens"
62-
className="text-foreground underline underline-offset-2 hover:text-muted-foreground"
63-
>
64-
Ouvrir l&apos;outil
65-
</Link>
66-
<span aria-hidden="true">·</span>
67-
<Link
68-
href="/projects/infralens"
69-
className="underline underline-offset-2 hover:text-foreground"
70-
>
71-
Étude de cas
72-
</Link>
73-
</p>
74-
</div>
75-
</section>
60+
<h2 className="text-2xl font-bold text-foreground">{tool.name}</h2>
61+
<p className="mt-2 max-w-lg text-sm text-muted-foreground">
62+
{tool.description}
63+
</p>
64+
<p className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
65+
<Link
66+
href={tool.href}
67+
className="text-foreground underline underline-offset-2 hover:text-muted-foreground"
68+
>
69+
Ouvrir l&apos;outil
70+
</Link>
71+
{tool.caseStudyHref && (
72+
<>
73+
<span aria-hidden="true">·</span>
74+
<Link
75+
href={tool.caseStudyHref}
76+
className="underline underline-offset-2 hover:text-foreground"
77+
>
78+
Étude de cas
79+
</Link>
80+
</>
81+
)}
82+
</p>
83+
</section>
84+
))}
85+
</div>
7686
</PageShell>
7787
);
7888
}

‎e2e/cron-builder.spec.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import AxeBuilder from "@axe-core/playwright";
2+
import { expect, test } from "@playwright/test";
3+
4+
const SEVERE_IMPACTS = ["serious", "critical"];
5+
6+
test.describe("cron builder", () => {
7+
test("route loads with a valid default expression and description", async ({
8+
page,
9+
}) => {
10+
await page.goto("/tools/cron-builder");
11+
12+
await expect(page.locator("h1")).toHaveText("Cron Builder");
13+
await expect(page.locator("#cron-raw-input")).toHaveValue("0 9 * * 1-5");
14+
await expect(
15+
page.getByText("At 09:00, Monday through Friday."),
16+
).toBeVisible();
17+
await expect(page.locator("#cron-raw-error")).toHaveCount(0);
18+
});
19+
20+
test("upcoming runs render for the default schedule", async ({ page }) => {
21+
await page.goto("/tools/cron-builder");
22+
23+
await expect(
24+
page.getByRole("heading", { name: "Next runs" }),
25+
).toBeVisible();
26+
// 5 upcoming runs, each formatted like "Mon, Aug 17 · 09:00".
27+
await expect(page.locator("ol li")).toHaveCount(5);
28+
await expect(page.getByText(/Previewed in/)).toBeVisible();
29+
});
30+
31+
test("selecting a preset updates the expression and description", async ({
32+
page,
33+
}) => {
34+
await page.goto("/tools/cron-builder");
35+
36+
await page.getByRole("button", { name: "Every 5 minutes" }).click();
37+
38+
await expect(page.locator("#cron-raw-input")).toHaveValue("*/5 * * * *");
39+
await expect(page.getByText("Every 5 minutes.")).toBeVisible();
40+
});
41+
42+
test("editing the raw expression updates the description", async ({
43+
page,
44+
}) => {
45+
await page.goto("/tools/cron-builder");
46+
47+
await page.fill("#cron-raw-input", "0 0 1 * *");
48+
49+
await expect(
50+
page.getByText("At 00:00 on the first day of every month."),
51+
).toBeVisible();
52+
});
53+
54+
test("an invalid raw expression is rejected without destroying the visual editor", async ({
55+
page,
56+
}) => {
57+
await page.goto("/tools/cron-builder");
58+
59+
await page.fill("#cron-raw-input", "60 * * * *");
60+
61+
await expect(page.locator("#cron-raw-error")).toHaveText(
62+
"Minute must be between 0 and 59.",
63+
);
64+
// The last valid state (hour 9 selected) must still be reflected.
65+
await expect(page.getByRole("button", { name: "Hour 9" })).toHaveAttribute(
66+
"aria-pressed",
67+
"true",
68+
);
69+
});
70+
71+
test("copying the expression exposes a success state", async ({ page }) => {
72+
// Headless Chromium's real clipboard permissions are unreliable in CI,
73+
// so stub the Clipboard API and assert on what was actually written.
74+
await page.addInitScript(() => {
75+
(window as unknown as { __copied: string[] }).__copied = [];
76+
Object.defineProperty(navigator, "clipboard", {
77+
configurable: true,
78+
value: {
79+
writeText: (text: string) => {
80+
(window as unknown as { __copied: string[] }).__copied.push(text);
81+
return Promise.resolve();
82+
},
83+
},
84+
});
85+
});
86+
await page.goto("/tools/cron-builder");
87+
88+
await page.getByRole("button", { name: "Copy cron expression" }).click();
89+
90+
await expect(
91+
page.getByRole("button", { name: "Cron expression copied" }),
92+
).toBeVisible();
93+
const copied = await page.evaluate(
94+
() => (window as unknown as { __copied: string[] }).__copied,
95+
);
96+
expect(copied).toEqual(["0 9 * * 1-5"]);
97+
});
98+
99+
test("mobile viewport has no horizontal overflow", async ({ page }) => {
100+
await page.setViewportSize({ width: 375, height: 812 });
101+
await page.goto("/tools/cron-builder");
102+
103+
const hasOverflow = await page.evaluate(
104+
() =>
105+
document.documentElement.scrollWidth >
106+
document.documentElement.clientWidth + 1,
107+
);
108+
expect(hasOverflow).toBe(false);
109+
});
110+
111+
test("has no serious/critical axe violations", async ({ page }) => {
112+
await page.goto("/tools/cron-builder");
113+
const results = await new AxeBuilder({ page }).analyze();
114+
const severe = results.violations.filter((v) =>
115+
SEVERE_IMPACTS.includes(v.impact ?? ""),
116+
);
117+
118+
expect(
119+
severe,
120+
severe
121+
.map((v) => `${v.id}: ${v.description} (${v.nodes.length} node(s))`)
122+
.join("\n"),
123+
).toEqual([]);
124+
});
125+
});

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "randy-code",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"private": true,
55
"packageManager": "pnpm@10.7.0",
66
"scripts": {

‎src/components/layout/page-shell.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ArrowLeft,
66
BookOpen,
77
Building2,
8+
Clock,
89
FlaskConical,
910
Mail,
1011
User,
@@ -19,6 +20,7 @@ const iconMap = {
1920
user: User,
2021
bookOpen: BookOpen,
2122
mail: Mail,
23+
clock: Clock,
2224
} as const;
2325

2426
export type PageIcon = keyof typeof iconMap;

0 commit comments

Comments
 (0)