diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index b35762f..1a82d10 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -24,6 +24,9 @@ jobs: - name: Validate SEO metadata and canonical URLs run: python3 scripts/seo-audit.py + - name: Test cron parsing and schedule evaluation + run: node --test scripts/crontab.test.cjs + - name: Validate HTML uses: Cyb3r-Jak3/html5validator-action@v7.2.0 with: @@ -48,6 +51,7 @@ jobs: kubernetes-rbac-explorer/index.html visual-subnet-calculator/index.html yaml-formatter/index.html + crontab/index.html terraform-modules/index.html fail: true jobSummary: true @@ -72,4 +76,5 @@ jobs: http://127.0.0.1:8080/kubernetes-rbac-explorer/ http://127.0.0.1:8080/visual-subnet-calculator/ http://127.0.0.1:8080/yaml-formatter/ + http://127.0.0.1:8080/crontab/ http://127.0.0.1:8080/terraform-modules/ diff --git a/README.md b/README.md index ae938d3..41032d0 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,45 @@ operations, sensitive cluster access, RBAC administration, aggregated ClusterRoles, and incomplete policy exports. Consecutive sample selections do not repeat the same scenario. +## Cron Schedule Builder + +Open `/crontab/` to build and explain cron expressions, preview +upcoming times in an IANA time zone alongside UTC, and export scheduler configuration. +Linux cron, Kubernetes CronJob, GitHub Actions, and AWS EventBridge Scheduler +have separate validation rules and configuration exports. Presets and field +shortcuts help build expressions without memorizing syntax. +The time-zone field accepts manual IANA names and provides a searchable dropdown +with mouse, touch, and keyboard selection. +Choose 1, 2, 5, 10, 25, or 50 upcoming runs; share links retain the selected count. + +The builder supports lists, ranges, steps, named months and weekdays, common +Linux/Kubernetes macros, Kubernetes question-mark wildcards, and AWS year, +last-day, nearest-weekday, last-weekday, and nth-weekday fields. It handles +missing and repeated local times, including half-hour clock changes, according +to the documented preview policy for each platform. GitHub's undocumented +fall-back behavior is explicitly modeled as the first occurrence. Actual +execution remains the scheduler's responsibility. + +The preview searches eight calendar years after a user-selected UTC instant. +Rare or impossible schedules report partial or empty results separately from +syntax errors. Configuration, CSV downloads, and share links are generated in +the browser. Sharing encodes settings in the URL fragment; there is no backend. +Quartz, seconds fields, rate expressions, and `@reboot` are not supported. + +The dependency-free engine is in `crontab/cron.js`; DOM interaction +and exports are in `app.js`, with page markup and responsive styles alongside them. +Run the calendar and timezone regression tests with Node.js 22 or later: + +```sh +node --test scripts/crontab.test.cjs +``` + +The page includes static instructions, crontab examples, a syntax reference, +visible breadcrumbs, and connected page/application/author/image structured data. +`python3 scripts/seo-audit.py` verifies these SEO relationships and related-tool +links. Follow the crontab checks in [`SEARCH_CONSOLE.md`](./SEARCH_CONSOLE.md) +after deployment to inspect indexing and measure actual search performance. + ## Terraform Modules Open `/terraform-modules/` to explore public AWS Terraform modules @@ -187,6 +226,7 @@ python3 -m http.server 8080 - `http://localhost:8080/kubernetes-rbac-explorer/` - `http://localhost:8080/visual-subnet-calculator/` - `http://localhost:8080/yaml-formatter/` +- `http://localhost:8080/crontab/` - `http://localhost:8080/terraform-modules/` ## Quality and search checks diff --git a/SEARCH_CONSOLE.md b/SEARCH_CONSOLE.md index 474c89c..08b658b 100644 --- a/SEARCH_CONSOLE.md +++ b/SEARCH_CONSOLE.md @@ -6,6 +6,8 @@ directives, matching Open Graph and structured-data URLs, and an entry in `sitemap.xml`. `robots.txt` advertises that sitemap. The duplicate `/json-formatter/` alias points directly to `/yaml-formatter/` through an instant meta refresh and a canonical link; it is excluded from the sitemap and navigation. +The cron tool is published only at `/crontab/`, with the same canonical URL in +its page metadata, structured data, navigation, and sitemap. Ownership verification must be completed by a Google account with access to Search Console. The verification value is account-issued and must not be @@ -35,8 +37,9 @@ invented or replaced with a placeholder. - `https://native-cube.com/kubernetes-rbac-explorer/` - `https://native-cube.com/visual-subnet-calculator/` - `https://native-cube.com/yaml-formatter/` + - `https://native-cube.com/crontab/` - `https://native-cube.com/terraform-modules/` -4. Do not submit `/json-formatter/`; it is a permanent redirect alias. +4. Do not submit `/json-formatter/`; it is a redirect alias. ## Confirm Google's selected canonical @@ -76,8 +79,8 @@ python3 scripts/check-redirects.py ``` It checks every sitemap page across HTTP/HTTPS, apex/`www`, and the old GitHub -Pages hostname, plus slashless paths, `index.html` duplicates, and formatter -aliases. It follows HTTP and instant meta-refresh redirects explicitly, rejects +Pages hostname, plus slashless paths, `index.html` duplicates, and the formatter +alias. It follows HTTP and instant meta-refresh redirects explicitly, rejects loops and temporary or insecure redirects, and checks that each final page is HTTP 200 with the expected canonical and no indexing restrictions. The separate **Live redirect checks** workflow runs weekly and can be started manually in @@ -98,7 +101,7 @@ permanently to the apex HTTPS URLs. The formatter alias still served the old `noindex` directive before this change was deployed. This is a consolidation signal issue; it does not prove the cause of an account's **Redirect error**. -## Hosting settings and the formatter alias +## Hosting settings and redirect aliases Keep **Settings → Pages → Custom domain** set to `native-cube.com` and **Enforce HTTPS** enabled. The repository's `CNAME` file does not by itself @@ -128,6 +131,48 @@ After publishing, rerun the live audit, resubmit the canonical sitemap, and use pages and validate actual **Redirect error** fixes. Historical exclusions remain until Google recrawls them, and intentional redirects remain excluded. +## Check and measure the crontab page + +After publishing changes to `/crontab/`: + +1. Inspect `https://native-cube.com/crontab/` with **Test live URL**. Confirm HTTP + 200, an indexable response, and the exact same user-declared canonical. The + slashless URL, `/index.html` variant, query strings, and share fragments must + not become separate sitemap entries. +2. Review the rendered HTML and screenshot. The crontab heading, quick-start + instructions, syntax guide, examples, scheduler notes, FAQ, and ordinary + navigation links are in the original HTML. Only interactive calculations + require JavaScript. Keep the tool's default preview at two runs. +3. Validate the JSON-LD with the + [Schema Markup Validator](https://validator.schema.org/) and Google's + [Rich Results Test](https://search.google.com/test/rich-results). `WebPage`, + `WebApplication`, `BreadcrumbList`, `Person`, and `ImageObject` describe the + actual content and free access. Google's + [software app rich-result requirements](https://developers.google.com/search/docs/appearance/structured-data/software-app) + include a genuine rating or review; the tool does not currently publish + either. Do not invent one to remove an eligibility warning. The visible FAQ + does not imply eligibility for FAQ rich results, which Google + [limits to qualifying government and health sites](https://developers.google.com/search/blog/2023/08/howto-faq-changes). +4. Request indexing for the canonical URL after the live checks pass, and + confirm that Google's selected canonical eventually matches it. Account + ownership and URL inspection are performed in Search Console; a passing + local audit cannot establish that a page is indexed. +5. In **Performance → Search results**, filter **Page** to the exact canonical + URL. Compare impressions, clicks, CTR, and average position over comparable + date ranges, then segment by device, country, and query. Initial query groups + to inspect include crontab generators, cron expression testers, common + schedules, and time-zone questions. These are hypotheses, not measured + keyword volumes or ranking predictions. +6. Check the **Core Web Vitals** report when field data is available. Local + Lighthouse scores measure a lab run, not real-user performance or ranking. + +The SEO audit verifies the crontab schema relationships, matching page and social +titles, static examples, incoming links from related tools, and agreement between +`dateModified` and sitemap `lastmod`. It runs in the existing Lighthouse and SEO +workflow. Shared schedules remain URL fragments and retain the same clean +canonical, following Google's +[canonicalization guidance](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls). + ## Keep sitemap dates accurate Update a page's `lastmod` when its public content or metadata changes. The diff --git a/assets/social/crontab.png b/assets/social/crontab.png new file mode 100644 index 0000000..5356073 Binary files /dev/null and b/assets/social/crontab.png differ diff --git a/assets/social/crontab.svg b/assets/social/crontab.svg new file mode 100644 index 0000000..4e5627d --- /dev/null +++ b/assets/social/crontab.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Native Cube + CLOUD-NATIVE TOOLBOX + CRONTAB / CRON TESTER + Crontab generator. + Preview every run. + + + + + + + + + BUILD · EXPLAIN · PREVIEW · EXPORT + + diff --git a/crontab/app.js b/crontab/app.js new file mode 100644 index 0000000..28a71f1 --- /dev/null +++ b/crontab/app.js @@ -0,0 +1,510 @@ +"use strict"; + +(() => { + const engine = CronScheduleBuilder; + const byId = (id) => document.getElementById(id); + const expression = byId("expression"); + const scheduler = byId("scheduler"); + const timeZone = byId("timezone"); + const after = byId("preview-after"); + const count = byId("run-count"); + const actions = ["copy-config", "download-config", "copy-expression", "share-schedule", "download-runs"].map(byId); + const presetExpressions = { + frequent: ["*/15 * * * *", "*/15 * * * ? *"], + daily: ["0 0 * * *", "0 0 * * ? *"], + weekdays: ["30 9 * * MON-FRI", "30 9 ? * MON-FRI *"], + weekly: ["0 8 * * MON", "0 8 ? * MON *"], + monthly: ["0 0 1 * *", "0 0 1 * ? *"], + }; + const platformNotes = { + linux: "Cronie-style preview: skipped local times do not match; repeated local times match twice. Other cron daemons may handle clock changes differently.", + kubernetes: "Preview uses spec.timeZone. Missing local times are skipped; repeated local times are listed twice. Concurrency and missed-job policies affect actual execution.", + github: "Missing local times advance to the next valid time. Repeated times use the first occurrence in this preview; GitHub does not document this edge case. Minimum interval: five minutes.", + aws: "EventBridge Scheduler skips missing local times and invokes repeated local times once. Preview shows the first occurrence. Flexible time windows are disabled in the export.", + }; + const exportNotes = { + linux: "Replace /path/to/your-command with your task. CRON_TZ requires a compatible implementation, such as Cronie; check your server's cron documentation.", + kubernetes: "Example manifest, with concurrencyPolicy: Forbid. Replace the container image and command, set appropriate resources, and validate against your cluster. spec.timeZone requires Kubernetes 1.27 or later.", + github: "Save as .github/workflows/scheduled-task.yml on the default branch and replace the example step. timezone requires a supporting GitHub version. Actual workflows may be delayed or dropped under load.", + aws: "Schedule properties only, not a complete create-schedule request. Add a schedule name, target ARN, and execution-role ARN separately. This is EventBridge Scheduler, not scheduled EventBridge rules.", + }; + const filenames = { linux: "crontab.txt", kubernetes: "cronjob.yaml", github: "scheduled-task.yml", aws: "schedule-properties.json" }; + let current = null; + let renderTimer; + let toastTimer; + const closeTimeZones = setupTimeZonePicker(); + + function setupTimeZonePicker() { + const picker = byId("timezone-picker"); + const toggle = byId("timezone-toggle"); + const list = byId("timezone-options"); + const matches = byId("timezone-status"); + const commonZones = ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "America/Los_Angeles", "Asia/Kolkata", "Asia/Tokyo", "Australia/Sydney"]; + let availableZones = []; + try { + availableZones = typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : []; + } catch { /* Common choices and manual input remain available. */ } + const zones = [...new Set([...commonZones, ...availableZones])]; + let filteredZones = []; + let activeIndex = -1; + + function close() { + list.hidden = true; + timeZone.setAttribute("aria-expanded", "false"); + timeZone.removeAttribute("aria-activedescendant"); + toggle.setAttribute("aria-expanded", "false"); + toggle.setAttribute("aria-label", "Show time zones"); + matches.textContent = ""; + activeIndex = -1; + } + + function open(query = "") { + const tokens = query.trim().toLowerCase().replaceAll("_", " ").split(/[\s/]+/).filter(Boolean); + filteredZones = zones.filter((zone) => tokens.every((token) => zone.toLowerCase().replaceAll("_", " ").includes(token))); + activeIndex = -1; + timeZone.removeAttribute("aria-activedescendant"); + const fragment = document.createDocumentFragment(); + filteredZones.forEach((zone, index) => { + const option = document.createElement("li"); + option.id = `timezone-option-${index}`; + option.className = "timezone-option"; + option.dataset.zone = zone; + option.dataset.index = index; + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", String(zone === timeZone.value.trim())); + option.textContent = zone; + fragment.append(option); + }); + if (!filteredZones.length) { + const empty = document.createElement("li"); + empty.className = "timezone-empty"; + empty.setAttribute("role", "option"); + empty.setAttribute("aria-disabled", "true"); + empty.textContent = "No matching suggestions. You can still type a full IANA time zone."; + fragment.append(empty); + } + list.replaceChildren(fragment); + list.hidden = false; + list.scrollTop = 0; + timeZone.setAttribute("aria-expanded", "true"); + toggle.setAttribute("aria-expanded", "true"); + toggle.setAttribute("aria-label", "Hide time zones"); + matches.textContent = filteredZones.length ? `${filteredZones.length} time zone${filteredZones.length === 1 ? "" : "s"} available.` : "No matching suggestions. Manual entry is still available."; + } + + function activate(index, scroll = true) { + if (!filteredZones.length) return; + activeIndex = Math.max(0, Math.min(index, filteredZones.length - 1)); + [...list.children].forEach((option, position) => { + option.classList.toggle("is-active", position === activeIndex); + option.setAttribute("aria-selected", String(position === activeIndex)); + }); + const option = list.children[activeIndex]; + timeZone.setAttribute("aria-activedescendant", option.id); + if (scroll) option.scrollIntoView({ block: "nearest", behavior: "instant" }); + } + + function choose(zone) { + timeZone.value = zone; + timeZone.focus({ preventScroll: true }); + timeZone.setSelectionRange(zone.length, zone.length); + close(); + render(); + } + + function resumeEditing() { + activeIndex = -1; + timeZone.removeAttribute("aria-activedescendant"); + [...list.children].forEach((option) => { + option.classList.remove("is-active"); + option.setAttribute("aria-selected", String(option.dataset.zone === timeZone.value.trim())); + }); + } + + timeZone.addEventListener("focus", () => open()); + timeZone.addEventListener("click", () => { + if (list.hidden) open(); + else resumeEditing(); + }); + timeZone.addEventListener("input", () => { + open(timeZone.value); + queueRender(); + }); + timeZone.addEventListener("keydown", (event) => { + if (event.isComposing) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (event.altKey && event.key === "ArrowUp") { close(); return; } + if (list.hidden) open(); + if (!event.altKey) activate(activeIndex < 0 ? event.key === "ArrowDown" ? 0 : filteredZones.length - 1 : activeIndex + (event.key === "ArrowDown" ? 1 : -1)); + } else if (event.key === "Enter" && !list.hidden) { + if (activeIndex >= 0) { + event.preventDefault(); + choose(filteredZones[activeIndex]); + } else close(); + } else if (event.key === "Escape" && !list.hidden) { + event.preventDefault(); + close(); + } else if (event.key === "Tab") close(); + else if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) resumeEditing(); + }); + toggle.addEventListener("mousedown", (event) => event.preventDefault()); + toggle.addEventListener("click", () => { + const wasOpen = !list.hidden; + timeZone.focus({ preventScroll: true }); + if (wasOpen) close(); + else open(); + }); + list.addEventListener("mousedown", (event) => event.preventDefault()); + list.addEventListener("pointermove", (event) => { + const option = event.target.closest("[data-zone]"); + if (option && Number(option.dataset.index) !== activeIndex) activate(Number(option.dataset.index), false); + }); + list.addEventListener("click", (event) => { + const option = event.target.closest("[data-zone]"); + if (option) choose(option.dataset.zone); + }); + document.addEventListener("pointerdown", (event) => { if (!picker.contains(event.target)) close(); }); + document.addEventListener("focusin", (event) => { if (!picker.contains(event.target)) close(); }); + window.addEventListener("blur", close); + return close; + } + + function notify(message) { + clearTimeout(toastTimer); + byId("toast").textContent = message; + byId("toast").classList.add("is-visible"); + toastTimer = setTimeout(() => byId("toast").classList.remove("is-visible"), 4500); + } + + function setNow() { + after.value = new Date().toISOString().slice(0, 16); + } + + function addOption(select, value, label) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + select.append(option); + } + + function buildFields() { + const container = byId("builder-fields"); + container.replaceChildren(); + engine.definitions(scheduler.value).forEach((definition, index) => { + const card = document.createElement("div"); + card.className = "builder-field"; + const label = document.createElement("label"); + label.className = "field"; + label.textContent = definition.name; + label.htmlFor = `builder-value-${index}`; + const select = document.createElement("select"); + select.dataset.index = index; + select.setAttribute("aria-label", `${definition.name} shortcut`); + addOption(select, "custom", "Custom expression…"); + addOption(select, "*", "Every value (*)"); + if (scheduler.value === "aws" && (index === 2 || index === 4)) addOption(select, "?", "Not specified (?)"); + if (index === 0 || index === 1) { + for (const step of index === 0 ? [5, 10, 15, 20, 30] : [2, 3, 4, 6, 12]) addOption(select, `*/${step}`, `Every ${step} ${index === 0 ? "minutes" : "hours"}`); + } + if (scheduler.value === "aws" && index === 2) addOption(select, "L", "Last day (L)"); + if (index !== 5) { + for (let value = definition.min; value <= definition.max; value += 1) { + const name = definition.names?.[value - definition.nameStart]; + addOption(select, name || String(value), name ? `${name} (${value})` : String(value).padStart(index < 2 ? 2 : 1, "0")); + } + } + const input = document.createElement("input"); + input.id = `builder-value-${index}`; + input.dataset.index = index; + input.maxLength = 100; + input.spellcheck = false; + input.setAttribute("aria-describedby", `builder-help-${index}`); + const help = document.createElement("span"); + help.className = "field-help"; + help.id = `builder-help-${index}`; + help.textContent = `${definition.min}–${definition.max}${definition.names ? ` · ${definition.names[0]}–${definition.names.at(-1)}` : ""}`; + card.append(label, select, input, help); + container.append(card); + select.addEventListener("change", () => { + if (select.value === "custom") { + input.focus(); + return; + } + input.value = select.value; + applyBuilder(); + }); + input.addEventListener("input", applyBuilder); + }); + const isAws = scheduler.value === "aws"; + byId("field-count").textContent = `${isAws ? 6 : 5} fields · minute precision`; + byId("expression-help").textContent = `Minute · Hour · Day of month · Month · Day of week${isAws ? " · Year (not seconds)" : ""}`; + byId("file-label").textContent = filenames[scheduler.value]; + byId("export-note").textContent = exportNotes[scheduler.value]; + } + + function applyBuilder() { + expression.value = [...byId("builder-fields").querySelectorAll("input")].map((input) => input.value.trim()).join(" "); + queueRender(); + } + + function syncBuilder(schedule) { + if (!schedule && byId("builder-fields").contains(document.activeElement)) return; + const tokens = schedule?.fields.map((field) => field.raw) || expression.value.trim().split(/\s+/); + byId("builder-fields").querySelectorAll("input").forEach((input, index) => { + if (document.activeElement !== input) input.value = tokens[index] || ""; + const select = input.previousElementSibling; + const raw = input.value.toUpperCase(); + select.value = [...select.options].some((option) => option.value === raw) ? raw : "custom"; + }); + } + + function clearResults() { + current = null; + for (const button of actions) button.disabled = true; + byId("runs-body").replaceChildren(); + byId("field-breakdown").replaceChildren(); + byId("config-output").textContent = "A valid schedule and time zone are needed to generate configuration."; + byId("runs-summary").textContent = "Resolve the validation message to preview upcoming times."; + byId("explanation").textContent = "Let’s check that schedule."; + byId("schedule-note").textContent = ""; + } + + function status(title, message, type = "") { + byId("validation-title").textContent = title; + byId("validation-message").textContent = message; + byId("validation-panel").className = `validation-panel${type ? ` is-${type}` : ""}`; + } + + function offsetLabel(offset) { + const absolute = Math.abs(offset); + return `UTC${offset >= 0 ? "+" : "−"}${String(Math.floor(absolute / 60)).padStart(2, "0")}:${String(absolute % 60).padStart(2, "0")}`; + } + + function dateLabel(timestamp, zone) { + return new Intl.DateTimeFormat("en-GB", { + timeZone: zone, year: "numeric", month: "short", day: "2-digit", + hour: "2-digit", minute: "2-digit", hourCycle: "h23", + }).format(timestamp); + } + + function gapLabel(milliseconds) { + const minutes = Math.round(milliseconds / 60000); + return [ + minutes >= 1440 ? `${Math.floor(minutes / 1440)}d` : "", + Math.floor(minutes % 1440 / 60) ? `${Math.floor(minutes % 1440 / 60)}h` : "", + minutes % 60 ? `${minutes % 60}m` : "", + ].filter(Boolean).join(" ") || "0m"; + } + + function renderRuns(result, zone) { + const fragment = document.createDocumentFragment(); + result.runs.forEach((run, index) => { + const row = document.createElement("tr"); + const position = document.createElement("td"); + position.textContent = String(index + 1).padStart(2, "0"); + const local = document.createElement("td"); + const localTime = document.createElement("time"); + localTime.dateTime = new Date(run.timestamp).toISOString(); + localTime.textContent = dateLabel(run.timestamp, zone); + const offset = document.createElement("small"); + offset.textContent = `${offsetLabel(run.offset)}${run.note ? ` · ${run.note}` : ""}`; + local.append(localTime, offset); + const utc = document.createElement("td"); + const utcTime = document.createElement("time"); + utcTime.dateTime = new Date(run.timestamp).toISOString(); + utcTime.textContent = new Date(run.timestamp).toISOString().slice(0, 16).replace("T", " "); + utc.append(utcTime); + const gap = document.createElement("td"); + gap.textContent = index ? gapLabel(run.timestamp - result.runs[index - 1].timestamp) : "—"; + row.append(position, local, utc, gap); + fragment.append(row); + }); + byId("runs-body").replaceChildren(fragment); + byId("zone-column").textContent = `Schedule time (${zone})`; + byId("runs-caption").textContent = `${zone} wall-clock times compared with UTC`; + } + + function renderBreakdown(schedule) { + const fragment = document.createDocumentFragment(); + schedule.fields.forEach((field, index) => { + const item = document.createElement("div"); + item.className = "breakdown-item"; + const name = document.createElement("strong"); + name.textContent = field.name; + const raw = document.createElement("code"); + raw.textContent = field.raw; + const meaning = document.createElement("span"); + meaning.textContent = engine.fieldDescription(field, index, schedule.mode); + item.append(name, raw, meaning); + fragment.append(item); + }); + byId("field-breakdown").replaceChildren(fragment); + } + + function previewStart() { + const value = after.value; + if (!value || !after.validity.valid) throw new Error("Choose a UTC start between 1970 and 2191. The eight-year preview stays inside the AWS year range."); + const timestamp = new Date(`${value}Z`).getTime(); + if (!Number.isFinite(timestamp)) throw new Error("Choose a valid preview start date in UTC."); + return timestamp; + } + + function render() { + clearTimeout(renderTimer); + clearResults(); + [expression, timeZone, after].forEach((input) => input.removeAttribute("aria-invalid")); + let input = expression; + try { + const schedule = engine.parse(expression.value, scheduler.value); + input = timeZone; + const zone = timeZone.value.trim(); + // Restrict to named IANA zones rather than engine-specific offset strings. + if (!/^[A-Za-z_]+(?:\/[A-Za-z0-9_+\-]+)*$/.test(zone)) throw new Error("Enter an IANA time zone such as UTC, Europe/London, or America/New_York."); + engine.createClock(zone); + input = after; + const start = previewStart(); + const requestedCount = Number(count.value); + const evaluated = engine.nextRuns(schedule, { + timeZone: zone, + after: start, + count: schedule.mode === "github" ? Math.max(requestedCount, 10) : requestedCount, + }); + const result = { ...evaluated, runs: evaluated.runs.slice(0, requestedCount) }; + const warnings = []; + const dayFields = schedule.fields.slice(2, 5); + if (schedule.mode !== "aws" && !dayFields[0].wildcard && !dayFields[2].wildcard) warnings.push("Both day fields are restricted: day of month OR day of week can match."); + const intervals = evaluated.runs.slice(1).map((run, index) => run.timestamp - evaluated.runs[index].timestamp); + if (schedule.mode === "github" && intervals.some((gap) => gap < 5 * 60000)) { + warnings.push("GitHub Actions supports a minimum interval of five minutes. This expression has more frequent matches; choose a less frequent schedule before deploying."); + } + if (new Set(result.runs.map((run) => run.offset)).size > 1) warnings.push("A UTC offset changes within this preview. Check the local and UTC columns around the clock change."); + if (result.runs.length < Number(count.value)) warnings.push(`Found ${result.runs.length} matching time${result.runs.length === 1 ? "" : "s"} in the eight-year search window. Check calendar dates, the year field, or move the preview start.`); + status(warnings.length ? "Valid expression · Review the schedule" : "Valid expression · Ready to use", warnings.join(" ") || `${engine.modes[schedule.mode].name} syntax checked. Times below are interpreted in ${zone}; actual execution depends on your scheduler.`, warnings.length ? "warning" : ""); + byId("explanation").textContent = engine.explain(schedule); + byId("schedule-note").textContent = `${zone} · ${platformNotes[schedule.mode]}`; + renderBreakdown(schedule); + renderRuns(result, zone); + const config = engine.snippet(schedule, zone); + byId("config-output").textContent = config; + byId("runs-summary").textContent = result.runs.length ? `${result.runs.length} ${result.runs.length === 1 ? "match" : "matches"} after ${new Date(start).toISOString().slice(0, 16).replace("T", " ")} UTC. Gap is elapsed time between consecutive matches.` : "No matching time found within eight calendar years. Valid syntax does not guarantee a possible calendar date."; + current = { schedule, zone, start, result, config }; + for (const button of actions) button.disabled = button.id === "download-runs" && !result.runs.length; + syncBuilder(schedule); + } catch (error) { + input.setAttribute("aria-invalid", "true"); + status("Check your schedule", error.message, "error"); + syncBuilder(); + } + } + + function queueRender() { + clearTimeout(renderTimer); + clearResults(); + status("Checking schedule…", "Updating the preview."); + renderTimer = setTimeout(render, 180); + } + + async function copy(text, message) { + try { + if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text); + else { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + const active = document.activeElement; + textarea.select(); + const copied = document.execCommand("copy"); + textarea.remove(); + active?.focus(); + if (!copied) throw new Error("Copy unavailable"); + } + notify(message); + } catch { + notify("Clipboard unavailable. Select and copy the expression or configuration manually."); + } + } + + function download(text, filename, type) { + const url = URL.createObjectURL(new Blob([text], { type })); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + function restoreShare() { + const hash = location.hash.slice(1); + if (!hash.startsWith("schedule=") || hash.length > 2500) return; + const params = new URLSearchParams(hash); + const mode = params.get("mode"); + if (Object.hasOwn(engine.modes, mode)) scheduler.value = mode; + expression.value = (params.get("schedule") || "").slice(0, 512); + if (params.has("zone")) timeZone.value = params.get("zone").slice(0, 100); + if (params.has("after")) after.value = params.get("after"); + if ([...count.options].some((option) => option.value === params.get("count"))) count.value = params.get("count"); + } + + byId("schedule-form").addEventListener("submit", (event) => { + event.preventDefault(); + render(); + }); + expression.addEventListener("input", queueRender); + after.addEventListener("change", render); + count.addEventListener("change", render); + scheduler.addEventListener("change", () => { + buildFields(); + render(); + if (!current) notify("Expression kept. Choose a preset to load this scheduler’s syntax."); + }); + byId("local-zone").addEventListener("click", () => { + closeTimeZones(); + timeZone.value = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + render(); + }); + byId("use-now").addEventListener("click", () => { setNow(); render(); }); + document.querySelectorAll("[data-preset]").forEach((button) => button.addEventListener("click", () => { + expression.value = presetExpressions[button.dataset.preset][scheduler.value === "aws" ? 1 : 0]; + render(); + })); + document.querySelectorAll("[data-example]").forEach((button) => button.addEventListener("click", () => { + scheduler.value = "linux"; + expression.value = button.dataset.example; + buildFields(); + render(); + expression.focus(); + expression.scrollIntoView({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth" }); + })); + byId("copy-config").addEventListener("click", () => { if (current) copy(current.config, "Scheduler configuration copied."); }); + byId("copy-expression").addEventListener("click", () => { + if (current) copy(current.schedule.mode === "aws" ? `cron(${current.schedule.expression})` : current.schedule.expression, "Cron expression copied."); + }); + byId("download-config").addEventListener("click", () => { + if (current) download(current.config, filenames[current.schedule.mode], "text/plain;charset=utf-8"); + }); + byId("download-runs").addEventListener("click", () => { + if (!current) return; + const quote = (value) => `"${String(value).replaceAll('"', '""')}"`; + const rows = [["Run", "Schedule time", "Time zone", "UTC offset", "UTC instant", "Note"]]; + current.result.runs.forEach((run, index) => rows.push([index + 1, dateLabel(run.timestamp, current.zone), current.zone, offsetLabel(run.offset), new Date(run.timestamp).toISOString(), run.note])); + download(rows.map((row) => row.map(quote).join(",")).join("\r\n") + "\r\n", "cron-upcoming-runs.csv", "text/csv;charset=utf-8"); + }); + byId("share-schedule").addEventListener("click", () => { + if (!current) return; + const params = new URLSearchParams({ schedule: current.schedule.expression, mode: current.schedule.mode, zone: current.zone, after: after.value, count: count.value }); + const url = new URL(location.href); + url.search = ""; + url.hash = params.toString(); + copy(url.href, "Share link copied. It includes your schedule settings."); + }); + window.addEventListener("hashchange", () => { closeTimeZones(); restoreShare(); buildFields(); render(); }); + setNow(); + restoreShare(); + buildFields(); + render(); +})(); diff --git a/crontab/cron.js b/crontab/cron.js new file mode 100644 index 0000000..24cc4dd --- /dev/null +++ b/crontab/cron.js @@ -0,0 +1,274 @@ +/* Cron parsing and wall-clock evaluation. No DOM, network, or host-zone dependency. */ +"use strict"; + +const CronScheduleBuilder = (() => { + const minuteMs = 60000; + const dayMs = 86400000; + const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + const weekdays = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; + const aliases = { + "@yearly": "0 0 1 1 *", + "@annually": "0 0 1 1 *", + "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", + "@midnight": "0 0 * * *", + "@hourly": "0 * * * *", + }; + const modes = { + linux: { name: "Linux cron", count: 5, sunday: 0, weekdayMax: 7 }, + kubernetes: { name: "Kubernetes CronJob", count: 5, sunday: 0, weekdayMax: 6 }, + github: { name: "GitHub Actions", count: 5, sunday: 0, weekdayMax: 6 }, + aws: { name: "AWS EventBridge Scheduler", count: 6, sunday: 1, weekdayMax: 7 }, + }; + + function definitions(mode) { + const platform = modes[mode]; + if (!platform) throw new Error("Choose a supported scheduler."); + const fields = [ + { name: "Minute", min: 0, max: 59 }, + { name: "Hour", min: 0, max: 23 }, + { name: "Day of month", min: 1, max: 31 }, + { name: "Month", min: 1, max: 12, names: months, nameStart: 1 }, + { name: "Day of week", min: platform.sunday, max: platform.weekdayMax, names: weekdays, nameStart: platform.sunday }, + ]; + if (mode === "aws") fields.push({ name: "Year", min: 1970, max: 2199 }); + return fields; + } + + function number(value, definition) { + const named = definition.names?.indexOf(value) ?? -1; + const parsed = named >= 0 ? named + definition.nameStart : /^\d+$/.test(value) ? Number(value) : NaN; + if (!Number.isInteger(parsed) || parsed < definition.min || parsed > definition.max) { + throw new Error(`${definition.name}: use ${definition.min}–${definition.max}${definition.names ? ` or ${definition.names[0]}–${definition.names.at(-1)}` : ""}.`); + } + return parsed; + } + + function parseField(raw, definition, mode, index) { + const values = new Set(); + const field = { ...definition, raw, values, wildcard: false, special: null }; + if (mode === "aws" && (index === 2 || index === 4)) { + if (raw === "?") return { ...field, wildcard: true }; + if (index === 2 && raw === "L") return { ...field, special: { type: "last-day" } }; + if (index === 2 && /^\d+W$/.test(raw)) { + return { ...field, special: { type: "weekday", day: number(raw.slice(0, -1), definition) } }; + } + if (index === 4 && raw === "L") raw = "7"; + const special = index === 4 && raw.match(/^(\d+|[A-Z]{3})(L|#[1-5])$/); + if (special) { + return { ...field, special: { type: special[2] === "L" ? "last-weekday" : "nth-weekday", day: number(special[1], definition) - 1, nth: Number(special[2].slice(1)) } }; + } + } + if (mode === "aws" && index === 4 && raw.includes("/")) { + throw new Error("Day of week: AWS supports lists, ranges, L, and #; put increments in another field."); + } + const parts = raw.split(","); + field.wildcard = mode === "kubernetes" + ? parts.some((part) => /^[*?]/.test(part)) + : raw.startsWith("*"); + for (const part of parts) { + const stepParts = part.split("/"); + if (!part || stepParts.length > 2 || (stepParts.length === 2 && !/^\d+$/.test(stepParts[1]))) { + throw new Error(`${definition.name}: use a value, list, range, or positive step such as */2.`); + } + const step = stepParts.length === 2 ? Number(stepParts[1]) : 1; + if (step < 1 || step > definition.max - definition.min + 1) { + throw new Error(`${definition.name}: step must be between 1 and ${definition.max - definition.min + 1}.`); + } + const base = stepParts[0]; + let start; + let end; + if (base === "*" || (base === "?" && mode === "kubernetes")) { + start = definition.min; + end = definition.max; + } else { + const range = base.split("-"); + if (range.length > 2) throw new Error(`${definition.name}: ranges must look like 1-5.`); + start = number(range[0], definition); + end = range.length === 2 ? number(range[1], definition) : stepParts.length === 2 ? definition.max : start; + if (start > end) throw new Error(`${definition.name}: range start must not exceed its end. Split a wrapping range into a list.`); + } + for (let value = start; value <= end; value += step) values.add(index === 4 && mode === "linux" && value === 7 ? 0 : value); + } + return field; + } + + function parse(expression, mode = "linux") { + const defs = definitions(mode); + let source = expression.trim().toUpperCase(); + if (source.length > 512) throw new Error("Keep the expression under 512 characters."); + if (source.startsWith("@")) { + if (mode !== "linux" && mode !== "kubernetes") throw new Error(`${modes[mode].name} requires numeric fields, not @ macros.`); + source = aliases[source.toLowerCase()]; + if (!source) throw new Error("Use @hourly, @daily, @weekly, @monthly, or @yearly. @reboot has no predictable next execution."); + } + if (mode === "aws" && /^CRON\(/.test(source) && source.endsWith(")")) source = source.slice(5, -1).trim(); + const parts = source ? source.split(/\s+/) : []; + if (parts.length !== defs.length) { + throw new Error(`${modes[mode].name} needs ${defs.length} fields: minute, hour, day of month, month, day of week${mode === "aws" ? ", year" : ""}. Paste only the schedule, without a command or seconds field.`); + } + if (mode === "aws" && ((parts[2] === "?") === (parts[4] === "?"))) { + throw new Error("AWS requires exactly one of day of month or day of week to be ?. For daily runs use: 0 9 * * ? *."); + } + const fields = parts.map((part, index) => parseField(part, defs[index], mode, index)); + return { mode, fields, expression: parts.join(" ") }; + } + + function dateMatches(schedule, date) { + const [year, month, day] = date; + const fields = schedule.fields; + if (!fields[3].values.has(month) || (fields[5] && !fields[5].values.has(year))) return false; + const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + let dom = fields[2].values.has(day); + const special = fields[2].special; + if (special?.type === "last-day") dom = day === lastDay; + if (special?.type === "weekday" && special.day <= lastDay) { + const wanted = new Date(Date.UTC(year, month - 1, special.day)).getUTCDay(); + let nearest = special.day; + if (wanted === 6) nearest += special.day === 1 ? 2 : -1; + if (wanted === 0) nearest += special.day === lastDay ? -2 : 1; + dom = day === nearest; + } + const weekSpecial = fields[4].special; + let dow = fields[4].values.has(weekday + modes[schedule.mode].sunday); + if (weekSpecial) { + dow = weekday === weekSpecial.day && (weekSpecial.type === "last-weekday" ? day + 7 > lastDay : Math.ceil(day / 7) === weekSpecial.nth); + } + if (schedule.mode === "aws") return fields[2].raw === "?" ? dow : dom; + return fields[2].wildcard || fields[4].wildcard ? dom && dow : dom || dow; + } + + function createClock(timeZone) { + let formatter; + try { + formatter = new Intl.DateTimeFormat("en-GB-u-ca-gregory-nu-latn", { + timeZone, year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", + }); + } catch { + throw new Error("Enter a valid IANA time zone, such as Europe/London or America/New_York."); + } + function parts(timestamp) { + const result = {}; + for (const part of formatter.formatToParts(timestamp)) if (part.type !== "literal") result[part.type] = Number(part.value); + return [result.year, result.month, result.day, result.hour, result.minute, result.second]; + } + function wall(timestamp) { + const [year, month, day, hour, minute, second] = parts(timestamp); + return Date.UTC(year, month - 1, day, hour, minute, second); + } + return { parts, wall, offset: (timestamp) => wall(timestamp) - timestamp }; + } + + // Evaluate calendar days, then resolve wall times against the offsets around + // that day. This handles half-hour offsets and DST folds without scanning + // millions of UTC minutes for sparse annual schedules. + function nextRuns(schedule, { timeZone = "UTC", after = Date.now(), count = 10, years = 8 } = {}) { + const start = Number(new Date(after)); + if (!Number.isFinite(start)) throw new Error("Choose a valid preview start date."); + if (!Number.isInteger(count) || count < 1 || count > 100) throw new Error("Preview count must be between 1 and 100."); + if (!Number.isInteger(years) || years < 1 || years > 8) throw new Error("Search horizon must be between 1 and 8 years."); + const clock = createClock(timeZone); + const [year, month, day] = clock.parts(start); + const firstDay = Date.UTC(year, month - 1, day); + const anniversary = new Date(start); + anniversary.setUTCFullYear(anniversary.getUTCFullYear() + years); + const end = anniversary.getTime(); + const hours = [...schedule.fields[1].values].sort((a, b) => a - b); + const minutes = [...schedule.fields[0].values].sort((a, b) => a - b); + const runs = []; + for (let date = firstDay; date < end + dayMs && runs.length < count; date += dayMs) { + const calendar = new Date(date); + if (!dateMatches(schedule, [calendar.getUTCFullYear(), calendar.getUTCMonth() + 1, calendar.getUTCDate()])) continue; + const offsets = [...new Set([-dayMs, 0, dayMs, dayMs * 2].map((delta) => clock.offset(date + delta)))]; + const candidates = new Map(); + for (const hour of hours) { + for (const minute of minutes) { + const wall = date + hour * 60 * minuteMs + minute * minuteMs; + let instants = offsets.map((offset) => wall - offset).filter((instant) => clock.wall(instant) === wall).sort((a, b) => a - b); + let note = ""; + if (!instants.length && schedule.mode === "github") { + // GitHub advances a missing wall time to the first valid minute. + const upper = Math.max(...offsets.map((offset) => wall - offset)); + let low = Math.min(...offsets.map((offset) => wall - offset)); + let high = upper; + while (high - low > minuteMs) { + const middle = Math.floor((low + high) / 2 / minuteMs) * minuteMs; + if (clock.wall(middle) < wall) low = middle; + else high = middle; + } + if (clock.wall(high) >= wall && clock.wall(high) < date + dayMs) { + instants = [high]; + note = "Advanced past a daylight-saving gap"; + } + } + if (instants.length > 1) { + if (schedule.mode === "aws" || schedule.mode === "github") instants = instants.slice(0, 1); + note = "Repeated local time at a clock change"; + } + for (const instant of instants) { + if (instant > start && instant < end) candidates.set(instant, { timestamp: instant, offset: clock.offset(instant) / minuteMs, note }); + } + } + } + runs.push(...[...candidates.values()].sort((a, b) => a.timestamp - b.timestamp).slice(0, count - runs.length)); + } + return { runs, until: end }; + } + + function fieldDescription(field, index, mode) { + if (field.raw === "?") return mode === "aws" ? "Not specified; the other day field applies" : "Every value"; + if (field.special) { + const special = field.special; + if (special.type === "last-day") return "Last day of the month"; + if (special.type === "weekday") return `Weekday nearest day ${special.day} (within the month)`; + return `${special.type === "last-weekday" ? "Last" : ["", "First", "Second", "Third", "Fourth", "Fifth"][special.nth]} ${weekdays[special.day]} of the month`; + } + const all = index === 4 && mode === "linux" ? 7 : field.max - field.min + 1; + if (field.values.size === all) return `Every ${["minute", "hour", "day of month", "month", "day of week", "year"][index]}`; + const step = field.raw.match(/^\*\/(\d+)$/); + if (step) return `Every ${step[1]} ${["minutes", "hours", "days", "months", "weekdays", "years"][index]}, starting at ${field.min}`; + const values = [...field.values].sort((a, b) => a - b); + const labels = values.map((value) => index === 3 ? months[value - 1] : index === 4 ? weekdays[value - modes[mode].sunday] : String(value)); + return labels.length > 12 ? `${labels.slice(0, 10).join(", ")} … (${labels.length} values)` : labels.join(", "); + } + + function explain(schedule) { + const [minute, hour, dom, month, dow, year] = schedule.fields; + let text; + if (minute.values.size === 1 && hour.values.size === 1) { + text = `At ${String([...hour.values][0]).padStart(2, "0")}:${String([...minute.values][0]).padStart(2, "0")}`; + } else if (minute.raw === "*" && hour.raw === "*") text = "Every minute"; + else if (/^\*\/\d+$/.test(minute.raw) && hour.raw === "*") text = `Every ${minute.raw.slice(2)} minutes within each hour`; + else if (minute.values.size === 1 && hour.raw === "*") text = `At minute ${[...minute.values][0]} of every hour`; + else text = `Minutes ${fieldDescription(minute, 0, schedule.mode)}; hours ${fieldDescription(hour, 1, schedule.mode)}`; + const domActive = !dom.wildcard; + const dowActive = !dow.wildcard; + if (schedule.mode === "aws") { + const active = dom.raw === "?" ? dow : dom; + if (active.raw !== "*") text += ` · ${dom.raw === "?" ? "on" : "day"} ${fieldDescription(active, dom.raw === "?" ? 4 : 2, schedule.mode)}`; + } else if (domActive && dowActive) text += ` · on day ${fieldDescription(dom, 2, schedule.mode)} OR ${fieldDescription(dow, 4, schedule.mode)}`; + else { + if (dom.raw !== "*" && dom.raw !== "?") text += ` · day ${fieldDescription(dom, 2, schedule.mode)}`; + if (dow.raw !== "*" && dow.raw !== "?") text += ` · on ${fieldDescription(dow, 4, schedule.mode)}`; + } + if (month.raw !== "*") text += ` · in ${fieldDescription(month, 3, schedule.mode)}`; + if (year && year.raw !== "*") text += ` · ${fieldDescription(year, 5, schedule.mode)}`; + return text; + } + + function snippet(schedule, timeZone) { + const expression = schedule.expression; + const zone = JSON.stringify(timeZone); + if (schedule.mode === "aws") return JSON.stringify({ ScheduleExpression: `cron(${expression})`, ScheduleExpressionTimezone: timeZone, FlexibleTimeWindow: { Mode: "OFF" } }, null, 2); + if (schedule.mode === "github") return `name: Scheduled task\non:\n schedule:\n - cron: "${expression}"\n timezone: ${zone}\n workflow_dispatch:\npermissions:\n contents: read\njobs:\n scheduled-task:\n runs-on: ubuntu-latest\n steps:\n - run: echo "Replace with your task"\n`; + if (schedule.mode === "kubernetes") return `apiVersion: batch/v1\nkind: CronJob\nmetadata:\n name: scheduled-task\nspec:\n schedule: "${expression}"\n timeZone: ${zone}\n concurrencyPolicy: Forbid\n successfulJobsHistoryLimit: 3\n failedJobsHistoryLimit: 1\n jobTemplate:\n spec:\n template:\n spec:\n restartPolicy: Never\n containers:\n - name: task\n image: busybox:stable\n command: ["/bin/sh", "-c", "date; echo Replace with your task"]\n`; + return `# For Cronie-compatible cron implementations supporting CRON_TZ.\nCRON_TZ=${timeZone}\n${expression} /path/to/your-command\n`; + } + + return { modes, definitions, parse, dateMatches, createClock, nextRuns, explain, fieldDescription, snippet }; +})(); + +if (typeof module !== "undefined" && module.exports) module.exports = CronScheduleBuilder; diff --git a/crontab/index.html b/crontab/index.html new file mode 100644 index 0000000..8e9b655 --- /dev/null +++ b/crontab/index.html @@ -0,0 +1,408 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Crontab Generator & Cron Expression Tester | Native Cube + + + + + + + + + + +

+ Two journeys, one shared future. Your contribution helps my + daughter pursue higher education and helps me continue creating thoughtful, + accessible open-source tools—free for everyone to use. +

+ +
+ +
+
+

Cron Schedule Builder

+

Crontab generator.
Know when it runs.

+
+

Create and test cron expressions for Linux, Kubernetes CronJobs, GitHub Actions, and AWS EventBridge Scheduler. Preview next runs in your time zone, check daylight-saving changes, and export the configuration. Free, with no signup.

+
+ + + +
+
+
01 / Compose your schedule

Cron Schedule Builder

+ Browser only · No signup +
+
+
+ +
+ +
+
+
+ + +
+ +

+
+ +
+

Type an IANA time zone or choose from the list. Use ↑/↓ to browse and Enter to select.

+
+
+
+
5 fields · minute precision
+
+

Minute · Hour · Day of month · Month · Day of week

+
+
+ Start with + + + + + +
+
+ Build visually Choose values for each cron field +

Edit any field below to update the expression. Lists, ranges, and steps work here too.

+
+
+
Ready to preview

Edit the expression or choose a preset.

+
+
+ In plain English +

Weekdays at 09:30

+

Times use the selected schedule time zone.

+
+
+
+
+
02 / Look ahead

Next scheduled times

+
+ + + +
+

Strictly after this instant. Preview searches up to eight calendar years.

+
+
Schedule time zone and UTC, side by side
#Schedule time (UTC)UTCGap
+
+

Upcoming matches appear here.

+
+
+
03 / Take it with you

Scheduler configuration

crontab
+
Your configuration will appear here.
+
+
+

Replace the example command with your task. CRON_TZ requires a compatible cron implementation.

+

Sharing includes the expression, scheduler, time zone, and preview start in the URL fragment. Nothing is uploaded.

+
+
+

This is a schedule preview, not a job runner. Actual execution also depends on the scheduler, its time-zone database, load, retry rules, and concurrency settings.

+
+ +
+
+
04 / Quick start

How to build and test a cron expression

+

Use the cron expression tester to check syntax and scheduled times before adding a task to your server, cluster, or workflow.

+
+
    +
  1. Choose the scheduler and time zone

    Select Linux cron, Kubernetes, GitHub Actions, or AWS Scheduler. Set the IANA time zone used by your scheduler, or choose UTC for a schedule without daylight-saving changes.

  2. +
  3. Enter or generate an expression

    Paste your schedule, choose a preset, or open the visual builder. Read the plain-English explanation and field breakdown; correct any validation errors before continuing.

  4. +
  5. Check the next runs and export

    Two runs appear by default. Show up to 50, compare local times with UTC, and move the preview start to test clock changes. Copy the configuration or download it, then replace the example task with your own.

  6. +
+
+ +
+
05 / Know the syntax

Crontab syntax: the five cron fields

A standard cron expression has minute, hour, day-of-month, month, and day-of-week fields. It describes wall-clock matches, not a fixed elapsed interval. AWS Scheduler adds a sixth field for the year.

+
+

Read from left to right

┌────── minute       0–59
+│ ┌──── hour         0–23
+│ │ ┌── day of month 1–31
+│ │ │ ┌ month        1–12
+│ │ │ │ ┌ day of week
+* * * * *

Sunday is 0 in standard cron (Linux also accepts 7). AWS uses 1 for Sunday through 7 for Saturday. Names such as MON and JAN reduce ambiguity.

+

Use the building blocks

*
Every value in a field
,
A list: 0,15,30,45
-
An inclusive range: MON-FRI
/
A step: */15 or 9-17/2
?
AWS: leave one day field unspecified. Kubernetes: wildcard.

*/35 in minutes means minute 0 and 35 of each hour. The gaps alternate between 35 and 25 minutes.

+

Handle calendar boundaries

With standard cron, restricting both day fields matches either one. 0 9 1 * MON runs on the first of the month and every Monday.

AWS requires exactly one day field to be ?. It also supports L (last), W (nearest weekday), and # (nth weekday), for example MON#2.

A day that does not exist never matches. Use L in AWS mode for the last day of every month.

+
+
+ +
+
06 / Common schedules

Cron examples: minutes, hours, days, and weeks

These crontab examples use five-field Linux syntax and run in the selected time zone. Open one to inspect or adapt it in the builder.

+
+ + + + + + + + + +
+
+ +
+
07 / Scheduler differences

Linux, Kubernetes, GitHub Actions, and AWS cron syntax

Choose the platform before validating an expression. These notes explain what the preview models and what to verify before deployment.

+
+

Linux cron

Five fields; Sunday 0 or 7; common @hourly through @yearly macros. This preview models Cronie-style wall-clock matching: missing times are skipped and repeated times appear twice.

CRON_TZ is implementation-specific. Other cron daemons may compensate for clock changes differently.

Read the crontab manual ↗
+

Kubernetes CronJob

Five fields; Sunday 0–6; macros and ? wildcards. Exports include spec.timeZone, stable since Kubernetes 1.27, and concurrencyPolicy: Forbid.

The preview skips missing local times and lists repeated ones. Job creation is approximate; deadlines and concurrency can change actual runs.

Read the CronJob documentation ↗
+

GitHub Actions

Five fields; Sunday 0–6; no macros. UTC is the default; the generated workflow sets timezone explicitly. The minimum supported interval is five minutes.

Missing local times advance to the first valid time. For repeated times this preview uses the first occurrence; GitHub does not document that edge case. Workflows can be delayed or dropped under load.

Read the schedule event documentation ↗
+

AWS EventBridge Scheduler

Six fields with year 1970–2199. Exactly one day field must be ?. Supports last-day L, nearest-weekday nW, last-weekday nL, and nth-weekday n#1 through n#5.

Missing times are skipped; repeated times run once. Exports are schedule properties; add your name, target, and execution role separately. EventBridge rules are a different service.

Read the Scheduler documentation ↗
+
+
+ +
+
08 / Frequently asked questions

Crontab and cron expression FAQ

+
+
What is the difference between cron and crontab?

Cron is a scheduler that runs tasks at matching times. A crontab is a table of those schedules and their commands. In a standard user crontab, each job line starts with five schedule fields followed by the command. Paste only the schedule fields into this tester.

+
How do I schedule a cron job every five minutes?

Use */5 * * * * in a five-field crontab. It matches minutes 0, 5, 10, and so on through 55 of every hour. In AWS Scheduler mode, use cron(*/5 * * * ? *). Choose the scheduler first because the field counts and day rules differ.

+
How do I run a cron job every day at midnight?

Use 0 0 * * * for Linux, Kubernetes, or GitHub Actions. Midnight is evaluated in the scheduler's configured time zone. In AWS Scheduler mode, use cron(0 0 * * ? *). Preview the next runs to check the UTC equivalents.

+
How do I test a cron expression?

Choose a scheduler, paste the expression, and select the time zone used by the actual scheduler. Review upcoming local and UTC times, then move the preview start to a month boundary or clock change. This builder calculates matches locally and does not run your task.

+
Does a cron expression include a time zone?

The expression describes calendar fields; the scheduler supplies the time zone. This tool exports a separate time-zone setting for each platform. A fixed UTC offset such as UTC+1 is not equivalent to Europe/London, whose offset changes during the year.

+
Why does an expression with six fields fail?

Six-field formats are not interchangeable. AWS Scheduler uses a final year field, while Quartz commonly adds seconds at the beginning and has different rules. Select AWS mode for AWS schedules. Quartz, seconds-based expressions, rate expressions, and @reboot are not supported.

+
Why are there fewer runs than requested?

The preview searches eight calendar years after the selected start. Rare schedules such as leap day may have fewer matches; impossible dates such as February 31 have none. A fixed AWS year may also fall outside the search window. That is different from a syntax error.

+
Is my schedule sent to a server?

No. Parsing, time-zone conversion, and configuration generation happen in your browser. The share button puts the schedule settings in a URL fragment so you can send the link yourself. Anyone with that link can read those settings.

+
+
+ +
+ + + +
+ + diff --git a/crontab/styles.css b/crontab/styles.css new file mode 100644 index 0000000..bad27ed --- /dev/null +++ b/crontab/styles.css @@ -0,0 +1,1504 @@ +:root { + color-scheme: light; + --brand-accent: #2563eb; + --ink: #14243b; + --muted: #637187; + --soft: #8995a7; + --paper: #f2f5f9; + --card: #fbfdff; + --card-strong: #ffffff; + --line: #d6dfeb; + --line-strong: #b7c5d8; + --blue: #2563eb; + --blue-dark: #174ea6; + --blue-soft: #e8f0ff; + --green: #116c4b; + --green-soft: #e1f3ea; + --red: #b33d4a; + --red-soft: #fbeaec; + --amber: #b56a08; + --amber-soft: #fff4dc; + --editor: #0d2038; + --editor-line: #21374f; + --editor-ink: #e8eef7; + --shadow: 0 20px 56px rgba(23, 55, 94, 0.1); + --select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23637187' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-width: 320px; + color: var(--ink); + background: + radial-gradient(circle at 90% 5%, rgba(37, 99, 235, 0.13), transparent 31rem), + radial-gradient(circle at 7% 10%, rgba(22, 128, 90, 0.09), transparent 26rem), + linear-gradient(180deg, #f9fbfe 0, var(--paper) 54rem); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + -webkit-font-smoothing: antialiased; +} + +body::before { + position: fixed; + inset: 0; + z-index: -1; + background-image: + linear-gradient(rgba(37, 99, 235, 0.032) 1px, transparent 1px), + linear-gradient(90deg, rgba(37, 99, 235, 0.032) 1px, transparent 1px); + background-size: 32px 32px; + content: ""; + -webkit-mask-image: linear-gradient(to bottom, black, transparent 72%); + mask-image: linear-gradient(to bottom, black, transparent 72%); + pointer-events: none; +} + +button, +input, +select, +textarea { + color: inherit; + font: inherit; +} + +button, +select { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +a:focus-visible { + outline: 3px solid rgba(37, 99, 235, 0.25); + outline-offset: 2px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.skip-link { + position: fixed; + top: 8px; + left: 8px; + z-index: 20; + padding: 10px 14px; + color: white; + background: var(--blue); + border-radius: 8px; + transform: translateY(-150%); +} + +.skip-link:focus { + transform: translateY(0); +} + +.site-header, +.community-note, +.site-footer, +main { + width: min(1480px, calc(100% - 56px)); + margin-inline: auto; +} + +.site-header { + display: flex; + min-height: 76px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(121, 137, 128, 0.24); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 11px; + color: var(--ink); + text-decoration: none; +} + +.brand-logo { + width: 34px; + height: 34px; + flex: 0 0 auto; + filter: drop-shadow(0 6px 10px rgba(37, 99, 235, 0.16)); +} + +.brand-copy { + display: flex; + flex-direction: column; + gap: 1px; +} + +.brand-name { + font-size: 15px; + font-weight: 820; + letter-spacing: -0.035em; +} + +.brand-name strong { + color: var(--brand-accent); +} + +.brand-tagline, +.product-lockup span, +.eyebrow, +.step-label { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + text-transform: uppercase; +} + +.brand-tagline { + color: var(--muted); + font-size: 8px; + font-weight: 650; + letter-spacing: 0.08em; +} + +.product-lockup { + display: flex; + align-items: flex-end; + flex-direction: column; + gap: 2px; +} + +.product-lockup span { + color: var(--blue); + font-size: 8px; + font-weight: 750; + letter-spacing: 0.1em; +} + +.product-lockup strong { + font-size: 11px; + letter-spacing: -0.01em; +} + +.header-actions { + display: flex; + align-items: center; + gap: 18px; +} + +.community-link { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 40px; + padding: 8px 15px; + color: white; + background: linear-gradient(135deg, #2563eb, #174ea6); + border: 1px solid #174ea6; + border-radius: 999px; + box-shadow: + 0 8px 20px rgba(37, 99, 235, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + text-decoration: none; + font-size: 12px; + font-weight: 800; + letter-spacing: -0.01em; + white-space: nowrap; + transition: + background 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} + +.community-link:hover { + color: white; + background: linear-gradient(135deg, #3473f4, #1c57ba); + box-shadow: + 0 11px 26px rgba(37, 99, 235, 0.36), + inset 0 1px 0 rgba(255, 255, 255, 0.24); + transform: translateY(-2px); +} + +.community-link:focus-visible { + outline: 3px solid rgba(37, 99, 235, 0.35); + outline-offset: 3px; +} + +.community-note { + margin-top: 16px; + margin-bottom: 0; + padding: 12px 16px; + color: var(--muted); + background: linear-gradient(90deg, rgba(232, 240, 255, 0.94), rgba(251, 253, 255, 0.86)); + border: 1px solid var(--line); + border-left: 4px solid var(--brand-accent); + border-radius: 12px; + font-size: 12px; + line-height: 1.55; + box-shadow: 0 8px 24px rgba(23, 55, 94, 0.06); +} + +.community-note strong { + color: var(--ink); +} + +.community-note + main .hero { + padding-top: 24px; +} + +:target { + scroll-margin-top: 24px; +} + +.breadcrumbs { + margin-top: 24px; + color: var(--muted); + font-size: 11px; + line-height: 1.7; +} + +.breadcrumbs ol { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.breadcrumbs li + li { + display: inline-flex; + align-items: center; + gap: 12px; +} + +.breadcrumbs li + li::before { + width: 5px; + height: 5px; + border-top: 1px solid currentColor; + border-right: 1px solid currentColor; + content: ""; + transform: rotate(45deg); +} + +.breadcrumbs a, +.page-navigation a { + color: var(--blue-dark); + text-underline-offset: 3px; +} + +.page-navigation { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 24px; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); +} + +.page-navigation a { + display: inline-flex; + min-height: 44px; + align-items: center; + padding: 10px 12px; + border-radius: 7px; + font-size: 12px; + font-weight: 650; + text-decoration: none; +} + +.page-navigation a:hover { + background: var(--blue-soft); + text-decoration: underline; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.7fr); + gap: 72px; + align-items: end; + padding: 76px 0 62px; +} + +.eyebrow, +.step-label { + display: block; + color: var(--blue); + font-size: 11px; + font-weight: 750; + letter-spacing: 0.12em; +} + +.eyebrow { + margin: 0; +} + +.hero h1 { + margin: 14px 0 0; + font-size: clamp(48px, 6.4vw, 92px); + font-weight: 720; + line-height: 0.98; + letter-spacing: -0.065em; +} + +.hero h1 em { + color: var(--blue); + font-family: Georgia, "Times New Roman", serif; + font-weight: 400; +} + +.hero-copy { + margin: 0 0 4px; + color: var(--muted); + font-size: 17px; + line-height: 1.68; +} + +.tool-shell { + padding: 30px; + background: rgba(251, 253, 255, 0.97); + border: 1px solid var(--line); + border-top: 2px solid #b9ceff; + border-radius: 18px; + box-shadow: var(--shadow); +} + +.tool-heading, +.editor-heading, +.validation-panel { + display: flex; + align-items: center; +} + +.tool-heading { + justify-content: space-between; + gap: 24px; +} + +.tool-heading h2 { + margin: 7px 0 0; + font-size: 24px; + letter-spacing: -0.035em; +} + +.privacy-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 11px; + color: var(--green); + background: var(--green-soft); + border: 1px solid #bfe2d2; + border-radius: 999px; + font-size: 11px; + font-weight: 800; +} + +.privacy-badge span { + font-size: 8px; +} + +.site-footer { + display: flex; + min-height: 94px; + align-items: center; + justify-content: space-between; + gap: 24px; + color: var(--muted); + border-top: 1px solid var(--line); + font-size: 12px; +} + +.footer-links { + display: flex; + align-items: center; + gap: 22px; +} + +.site-footer a { + color: var(--blue); + font-weight: 800; + text-decoration: none; +} + +.site-footer a:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +.author-credit { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.author-credit span { + color: var(--muted); + font-weight: 500; +} + +.toast { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 20; + max-width: min(360px, calc(100% - 44px)); + padding: 12px 16px; + color: white; + background: var(--ink); + border-radius: 10px; + box-shadow: 0 14px 40px rgba(13, 32, 56, 0.24); + font-size: 12px; + font-weight: 750; + opacity: 0; + pointer-events: none; + transform: translateY(12px); + transition: + opacity 160ms ease, + transform 160ms ease; +} + +.toast.is-visible { + opacity: 1; + transform: translateY(0); +} + +[hidden] { + display: none !important; +} + +.field { + display: flex; + min-width: 0; + flex-direction: column; + gap: 8px; + font-size: 12px; + font-weight: 750; + line-height: 1.4; +} + +input, +select { + width: 100%; + min-width: 0; + min-height: 44px; + padding: 10px 12px; + background: var(--card-strong); + border: 1px solid var(--line-strong); + border-radius: 8px; + font-size: 13px; + line-height: 1.4; +} + +select { + padding-right: 40px; + background-image: var(--select-arrow); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 12px 8px; +} + +select { + -webkit-appearance: none; + appearance: none; + text-overflow: ellipsis; +} + +input[aria-invalid="true"] { + border-color: var(--red); + box-shadow: 0 0 0 1px var(--red); +} + +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + align-items: start; + margin-top: 28px; +} + +.zone-control, +.expression-row { + display: flex; + gap: 10px; +} + +.zone-control { + margin-top: 8px; +} + +.zone-control input, +.expression-row input { + flex: 1 1 0%; +} + +.zone-control button { + flex: 0 0 auto; +} + +.timezone-picker { + position: relative; + flex: 1 1 0%; + min-width: 0; +} + +.timezone-input { + position: relative; +} + +.timezone-input input { + padding-right: 44px; +} + +#timezone-toggle { + position: absolute; + top: 1px; + right: 1px; + bottom: 1px; + display: flex; + width: 42px; + align-items: center; + justify-content: center; + padding: 0; + color: var(--muted); + background: transparent; + border: 0; + border-radius: 0 7px 7px 0; +} + +#timezone-toggle:hover, +#timezone-toggle[aria-expanded="true"] { + color: var(--blue-dark); + background: var(--blue-soft); +} + +.timezone-options { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 30; + width: 100%; + max-height: min(280px, 45vh); + overflow-y: auto; + overscroll-behavior: contain; + margin: 0; + padding: 6px; + background: var(--card-strong); + border: 1px solid var(--line-strong); + border-radius: 9px; + box-shadow: 0 12px 28px rgba(23, 55, 94, 0.16); + list-style: none; +} + +.timezone-option { + display: flex; + min-height: 44px; + align-items: center; + padding: 9px 10px; + border-radius: 5px; + font-size: 13px; + line-height: 1.5; + overflow-wrap: anywhere; + cursor: pointer; +} + +.timezone-option.is-active, +.timezone-option:hover { + color: var(--blue-dark); + background: var(--blue-soft); +} + +.timezone-option[aria-selected="true"] { + font-weight: 750; +} + +.timezone-empty { + padding: 10px; + color: var(--muted); + font-size: 12px; + line-height: 1.65; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + padding: 0; + border: 0; + clip-path: inset(50%); + white-space: nowrap; +} + +.primary-button, +.quiet-button, +.preset-row button { + display: inline-flex; + min-height: 44px; + align-items: center; + justify-content: center; + gap: 12px; + padding: 10px 16px; + border: 1px solid var(--line-strong); + border-radius: 8px; + font-size: 12px; + font-weight: 750; + white-space: nowrap; +} + +.primary-button { + color: white; + background: var(--blue); + border-color: var(--blue); +} + +.primary-button:hover:not(:disabled) { + background: var(--blue-dark); +} + +.quiet-button { + color: var(--ink); + background: var(--card-strong); +} + +.quiet-button:hover:not(:disabled), +.preset-row button:hover { + color: var(--blue-dark); + background: var(--blue-soft); + border-color: var(--blue); +} + +.expression-panel { + margin-top: 22px; + padding: 22px; + color: var(--editor-ink); + background: var(--editor); + border: 1px solid var(--editor-line); + border-radius: 12px; +} + +.expression-label { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + font-size: 12px; + font-weight: 750; +} + +.expression-label span, +.expression-panel p { + color: #acbdd4; + font-size: 11px; + line-height: 1.7; +} + +.expression-row input { + min-height: 60px; + color: var(--editor-ink); + background: #152c47; + border-color: #4c6585; + font: 600 clamp(17px, 2.6vw, 32px)/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.025em; +} + +.expression-row input:focus-visible { + outline-color: #9abaff; +} + +.expression-panel p { + margin: 12px 0 0; +} + +.preset-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 16px 0 22px; +} + +.preset-row > span { + margin-right: 4px; + color: var(--muted); + font-size: 11px; +} + +.preset-row button { + min-height: 36px; + padding: 7px 11px; + background: var(--card-strong); + border-color: var(--line); + border-radius: 6px; + font-size: 11px; +} + +summary { + position: relative; + padding-left: 20px; + list-style: none; + cursor: pointer; +} + +summary::-webkit-details-marker { + display: none; +} + +summary::before { + position: absolute; + top: 0.4em; + left: 2px; + width: 6px; + height: 6px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + content: ""; + transform: rotate(-45deg); +} + +details[open] > summary::before { + transform: rotate(45deg); +} + +summary:focus-visible { + outline: 3px solid var(--blue); + outline-offset: 5px; +} + +.builder { + padding: 18px; + background: #f5f8fc; + border: 1px solid var(--line); + border-radius: 10px; +} + +.builder summary { + font-size: 13px; + font-weight: 750; + line-height: 1.6; +} + +.builder summary span { + margin-left: 10px; + color: var(--muted); + font-size: 11px; + font-weight: 400; +} + +.builder-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + margin-top: 18px; +} + +.builder-field { + display: grid; + min-width: 0; + align-content: start; + gap: 8px; + padding: 12px; + background: var(--card-strong); + border: 1px solid var(--line); + border-radius: 8px; +} + +.builder-field input { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.builder-field .field-help { + display: block; + margin: 0; +} + +.field-help, +.preview-disclaimer { + margin: 10px 0 0; + color: var(--muted); + font-size: 11px; + line-height: 1.65; +} + +.validation-panel { + align-items: flex-start; + flex-direction: column; + gap: 4px; + min-height: 72px; + margin-top: 18px; + padding: 14px 18px; + color: var(--green); + background: var(--green-soft); + border: 1px solid #bfe2d2; + border-radius: 10px; + font-size: 12px; +} + +.validation-panel p { + margin: 0; + color: var(--ink); + line-height: 1.6; +} + +.validation-panel.is-error { + color: var(--red); + background: var(--red-soft); + border-color: #ecc1c8; +} + +.validation-panel.is-warning { + color: #855006; + background: var(--amber-soft); + border-color: #e8d2a9; +} + +.explanation { + margin: 28px 0; + padding: 24px; + background: linear-gradient(110deg, var(--blue-soft), #f6f9ff); + border: 1px solid #ceddf9; + border-radius: 12px; +} + +.explanation h3 { + margin: 12px 0 8px; + font-size: clamp(21px, 2.5vw, 30px); + line-height: 1.4; + letter-spacing: -0.035em; + overflow-wrap: anywhere; +} + +.explanation > p { + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} + +.field-breakdown { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 10px; + margin-top: 22px; +} + +.breakdown-item { + min-width: 0; + padding: 12px; + background: var(--card-strong); + border: 1px solid var(--line); + border-radius: 8px; +} + +.breakdown-item strong, +.breakdown-item code, +.breakdown-item span { + display: block; + overflow-wrap: anywhere; +} + +.breakdown-item strong { + color: var(--muted); + font-size: 10px; + font-weight: 600; +} + +.breakdown-item code { + margin: 9px 0; + color: var(--blue-dark); + font-size: 20px; + font-weight: 750; +} + +.breakdown-item span { + font-size: 10px; + line-height: 1.6; +} + +.results-grid { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + gap: 22px; + align-items: start; +} + +.result-card { + min-width: 0; + padding: 20px; + background: var(--card-strong); + border: 1px solid var(--line); + border-radius: 12px; +} + +.card-heading { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 14px; + margin-bottom: 20px; +} + +.card-heading h3 { + margin: 6px 0 0; + font-size: 18px; + letter-spacing: -0.035em; +} + +.preview-settings { + display: grid; + grid-template-columns: minmax(220px, 1fr) 112px auto; + gap: 10px; + align-items: end; +} + +.preview-settings input { + font-size: 13px; +} + +.preview-settings input::-webkit-date-and-time-value { + text-align: left; +} + +.file-label { + color: var(--muted); + font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.table-scroll { + max-height: 520px; + overflow: auto; + margin-top: 16px; + border: 1px solid var(--line); + border-radius: 8px; +} + +table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +caption { + padding: 10px 12px; + color: var(--muted); + text-align: left; + font-size: 10px; +} + +th, +td { + padding: 12px 10px; + border-top: 1px solid var(--line); + line-height: 1.5; + white-space: nowrap; +} + +th { + color: var(--muted); + background: #f5f8fc; + font-size: 10px; + font-weight: 650; + white-space: normal; +} + +tbody tr:first-child { + background: #f0f8f4; +} + +td:first-child, +td:last-child { + color: var(--muted); + font-size: 10px; +} + +td small { + display: block; + max-width: 28ch; + color: var(--muted); + font-size: 10px; + white-space: normal; + overflow-wrap: anywhere; +} + +.code-preview { + max-height: 400px; + overflow: auto; + margin: 0 0 18px; + padding: 18px; + color: var(--editor-ink); + background: var(--editor); + border: 1px solid var(--editor-line); + border-radius: 9px; + font: 11px/1.9 ui-monospace, SFMono-Regular, Menlo, monospace; + tab-size: 2; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.action-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.secondary-actions { + margin-top: 10px; +} + +.preview-disclaimer { + margin-top: 22px; +} + +.content-section { + padding-top: 72px; +} + +.content-heading { + display: grid; + grid-template-columns: 1fr 0.65fr; + gap: 60px; + align-items: end; + margin-bottom: 28px; +} + +.content-section h2 { + margin: 12px 0 0; + font-size: clamp(27px, 3vw, 38px); + line-height: 1.15; + letter-spacing: -0.045em; +} + +.quick-start-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + margin: 0; + padding: 0; + list-style: none; + counter-reset: quick-start; +} + +.quick-start-grid li { + min-width: 0; + padding: 24px; + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + counter-increment: quick-start; +} + +.quick-start-grid li::before { + color: var(--blue); + font: 750 12px ui-monospace, SFMono-Regular, Menlo, monospace; + content: counter(quick-start, decimal-leading-zero); +} + +.quick-start-grid h3 { + margin: 14px 0 12px; + font-size: 16px; + line-height: 1.5; + letter-spacing: -0.025em; +} + +.quick-start-grid p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.85; +} + +.content-heading > p, +.reference-card p, +.faq-list p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.85; +} + +.reference-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.reference-card { + min-width: 0; + padding: 24px; + background: rgba(251, 253, 255, 0.85); + border: 1px solid var(--line); + border-radius: 12px; +} + +.reference-card h3 { + margin: 0 0 16px; + font-size: 16px; + letter-spacing: -0.025em; +} + +.reference-card p + p, +.reference-card a { + display: block; + margin-top: 14px; +} + +.reference-card a, +.related-tools a { + color: var(--blue-dark); + font-size: 12px; + font-weight: 650; + line-height: 1.7; + text-underline-offset: 3px; +} + +.reference-card p code, +.faq-list code { + color: var(--ink); + font-size: 11px; + overflow-wrap: anywhere; +} + +.syntax-diagram { + overflow-x: auto; + margin: 0 0 18px; + color: var(--blue-dark); + font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.operator-list { + margin: 0 0 18px; +} + +.operator-list > div { + display: flex; + gap: 16px; + padding: 7px 0; + border-bottom: 1px solid var(--line); + font-size: 12px; + line-height: 1.7; +} + +.operator-list dt { + color: var(--blue); + font-size: 16px; + font-weight: 800; +} + +.operator-list dd { + margin: 0; + color: var(--muted); +} + +.examples-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.example-card { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 12px; + padding: 24px; + background: var(--card-strong); + border: 1px solid var(--line); + border-radius: 12px; + text-align: left; + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.example-card:hover { + border-color: var(--blue); + box-shadow: 0 8px 24px rgba(37, 99, 235, 0.08); +} + +.example-card code { + color: var(--blue-dark); + font-size: 16px; +} + +.example-card strong { + font-size: 14px; +} + +.example-card > span { + display: flex; + width: 100%; + justify-content: space-between; + gap: 10px; + color: var(--muted); + font-size: 11px; + line-height: 1.6; + margin-top: auto; +} + +.example-card b { + color: var(--blue); +} + +.platform-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.platform-grid .reference-card { + display: flex; + flex-direction: column; +} + +.platform-grid .reference-card a { + margin-top: auto; + padding-top: 14px; +} + +.faq-section { + display: grid; + grid-template-columns: 0.8fr 1fr; + gap: 60px; +} + +.faq-list details { + padding: 20px 0; + border-bottom: 1px solid var(--line); +} + +.faq-list details:first-child { + padding-top: 0; +} + +.faq-list summary { + font-size: 14px; + font-weight: 650; + line-height: 1.65; +} + +.faq-list p { + padding-top: 12px; +} + +.related-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px 28px; + margin: 64px 0; + padding: 24px; + background: var(--blue-soft); + border: 1px solid #ceddf9; + border-radius: 12px; +} + +.related-tools strong { + margin-right: auto; + font-size: 18px; +} + +.notice { + padding: 20px; + color: var(--ink); + background: var(--amber-soft); + border: 1px solid var(--line); + border-radius: 10px; + line-height: 1.7; +} + +@media (max-width: 1180px) { + .results-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 840px) { + .site-header, + .community-note, + .site-footer, + main { + width: min(100% - 32px, 1480px); + } + + .hero, + .content-heading, + .faq-section { + grid-template-columns: 1fr; + gap: 24px; + } + + .hero { + padding: 48px 0 38px; + } + + .hero h1 { + font-size: clamp(44px, 8vw, 68px); + } + + .hero-copy { + max-width: 600px; + font-size: 15px; + } + + .reference-grid, + .examples-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .builder-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .quick-start-grid { + grid-template-columns: 1fr; + } + + .field-breakdown { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .reference-grid:not(.platform-grid) > .reference-card:last-child { + grid-column: 1 / -1; + } + + .expression-row { + flex-wrap: wrap; + } + + .expression-row input { + flex-basis: 100%; + } + + .expression-row .primary-button { + width: 100%; + } +} + +@media (max-width: 600px) { + .site-header, + .community-note, + .site-footer, + main { + width: min(100% - 22px, 1480px); + } + + .product-lockup { + display: none; + } + + .community-link { + min-height: 36px; + padding: 7px 11px; + font-size: 11px; + } + + .community-note { + margin-top: 12px; + padding: 11px 13px; + font-size: 11px; + } + + .tool-shell { + padding: 18px 12px; + border-radius: 14px; + } + + .tool-heading, + .expression-label, + .site-footer, + .footer-links, + .related-tools { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + + .settings-grid, + .reference-grid, + .examples-grid { + grid-template-columns: 1fr; + gap: 16px; + } + + input, + select, + .preview-settings input { + font-size: 16px; + } + + .builder-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + .field-breakdown { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .breakdown-item:last-child:nth-child(odd) { + grid-column: 1 / -1; + } + + .builder, + .expression-panel, + .explanation, + .result-card { + padding: 16px 12px; + } + + .builder summary span { + display: block; + margin: 8px 0 0; + line-height: 1.6; + } + + .preview-settings { + grid-template-columns: minmax(0, 1fr) auto; + } + + .preview-settings .field:first-child { + grid-column: 1 / -1; + } + + .content-section { + padding-top: 48px; + } + + .site-footer { + padding: 28px 0; + } +} + +@media (forced-colors: active) { + select { + padding-right: 12px; + background-image: none; + } + + select { + -webkit-appearance: auto; + appearance: auto; + } + + .timezone-option.is-active, + .timezone-option:hover { + color: HighlightText; + background: Highlight; + forced-color-adjust: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition: none !important; + } +} + diff --git a/index.html b/index.html index 0043bc3..dd9eba5 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ /> @@ -62,7 +62,7 @@ "name": "Native Cube", "alternateName": ["Native Cube Tools", "native-cube.com"], "url": "https://native-cube.com/", - "description": "Free browser-based cloud-native tools and public AWS Terraform modules for Kubernetes, Argo CD, Helm chart authoring, network planning, configuration formatting, and infrastructure as code.", + "description": "Free browser-based cloud-native tools for Kubernetes, Helm, Argo CD, cron schedules, subnet planning, and YAML formatting, plus public AWS Terraform modules.", "creator": { "@type": "Person", "name": "Marcin Cuber", @@ -498,8 +498,8 @@

Free tools for cloud-native work.

Generate Kubernetes manifests and Helm charts, preview Argo CD fleets, - review RBAC access, visually plan IPv4 subnets, format configuration, - and discover reusable Terraform modules. + review RBAC access, visually plan IPv4 subnets, test cron schedules, + format configuration, and discover reusable Terraform modules.

Created and maintained by @@ -623,6 +623,21 @@

YAML & JSON Formatter

+ + Crontab generator & tester + +

Cron Schedule Builder

+

+ Generate and test cron expressions, preview runs across time zones, and export + Linux, Kubernetes, GitHub Actions, or AWS schedules. +

+ +
Public AWS modules Free Kubernetes Manifest Generator | Native Cube - +