BusinessDaysCalc
Counts the working days between two dates, or finds a date N working days away, with your own weekends and holidays, listing each day it skipped.
Demo
Working days
22 working days
32 calendar days, skipping 8 weekend days and 2 holidays.
- Sat 7 Mar 2026: weekend
- Sun 8 Mar 2026: weekend
- Sat 14 Mar 2026: weekend
- Sun 15 Mar 2026: weekend
- Tue 17 Mar 2026: St Patrick's Day
- Sat 21 Mar 2026: weekend
- Sun 22 Mar 2026: weekend
- Sat 28 Mar 2026: weekend
- Sun 29 Mar 2026: weekend
- Fri 3 Apr 2026: Good Friday
Delivery promise (Gulf week)
Tue 5 Jan 2027
6 calendar days, skipping 2 weekend days and 1 holiday.
- Thu 31 Dec 2026: New Year's Eve
- Fri 1 Jan 2027: New Year's Day
- Sat 2 Jan 2027: weekend
Change a figure to see on_change.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/business_days_calc.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/business_days_calc.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/business_days_calc.qwe
component BusinessDaysCalcDemoLive() { state due = null <div.demo-stack> <BusinessDaysCalc id="business-days-calc-demo-add" title="Delivery promise (Gulf week)" name="promise" mode="add" start="2026-12-30" count={3} weekend={[5, 6]} holidays={[{ date: "12-31", name: "New Year's Eve" }, "2027-01-01 New Year's Day"]} on_change={(r) => { due = r.date }}/> <p.demo-note>{due == null ? "Change a figure to see on_change." : `on_change: the date is ${due}.`}</p> </div>}view BusinessDaysCalcDemo() = <div.demo-stack> <BusinessDaysCalc id="business-days-calc-demo" start="2026-03-02" end="2026-04-03" holidays={["2026-04-03 Good Friday", "2026-03-17 St Patrick's Day"]}/> <BusinessDaysCalcDemoLive/> </div>Source
ui/business_days_calc.qwe
// BusinessDaysCalc: how many working days lie between two dates, or which date falls// N working days from a date, either way along the calendar. Weekends are a set of// weekdays (Saturday and Sunday, Friday and Saturday, Sunday alone) and holidays are// data, one date a line, so it fits any country. It lists every day it skipped and why.// Dates are worked out as calendar dates, never as hours, so a clock change cannot// lose or gain a day.//// <BusinessDaysCalc start="2026-11-02" end="2026-12-24" holidays={["2026-11-26 Thanksgiving"]}/>// <BusinessDaysCalc mode="add" start="2026-12-23" count={5} weekend={[5, 6]}// holidays={[{ date: "12-25", name: "Christmas Day" }]}// on_change={(r) => { due = r.date }}/>//// mode: "between" (the default) or "add". start, end: where the fields start// ("YYYY-MM-DD"). count: the working days to add; negative goes back. weekend: the days// off each week, 1 for Monday to 7 for Sunday ([6, 7]). holidays: a list, each// "YYYY-MM-DD Name" text or { date, name? }, where a date may be "MM-DD" for every year.// count_first: whether the first day counts ("between" only; false, so Monday to Friday// is 4 days, as a deadline is reckoned; true makes it 5, as Excel's NETWORKDAYS).// title: the heading. id: a prefix for its fields' ids, unique on the page. The fields// are named mode, start, end, count, count_first, weekend and holidays; name: a prefix// for those names. on_change(result): inside one of your components, after each// change that gives a result; see business_days_calc_between and _add.//// A program can use the logic alone: business_days_calc_calendar(holidays) reads// holidays into { days, yearly, bad }; business_days_calc_between(start, end, weekend,// calendar, count_first) gives { count, weekends, holidays, skipped, calendar_days };// business_days_calc_add(start, n, weekend, calendar) gives the same with `date`.// Each gives { error } for what it cannot do. It walks at most 36,600 days (100 years).import ui/theme.qwe use ui_clslet business_days_calc_css = css`.ui-business-days { display: grid; gap: 16px; min-width: 0; max-width: 640px; padding: 20px 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-business-days .ui-business-days-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-business-days-modes, .ui-business-days-weekend { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 0; padding: 0; border: 0; min-width: 0; }.ui-business-days-modes legend, .ui-business-days-weekend legend { float: left; margin-right: 8px; padding: 0; font-size: 13px; font-weight: 500; }.ui-business-days-modes label, .ui-business-days-weekend label, .ui-business-days-first { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }.ui-business-days input[type=radio], .ui-business-days input[type=checkbox] { margin: 0; accent-color: var(--ui-accent); }.ui-business-days-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px 14px; }.ui-business-days-field { display: grid; gap: 5px; min-width: 0; font-size: 13px; font-weight: 500; }.ui-business-days-field input, .ui-business-days-field textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 8px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.4 var(--ui-font); }.ui-business-days-field textarea { font: 13px/1.5 var(--ui-mono); resize: vertical; }.ui-business-days-field input:focus-visible, .ui-business-days-field textarea:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-business-days-field [aria-invalid="true"] { border-color: var(--ui-danger); }.ui-business-days-error { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; color: var(--ui-danger); font-size: 13px; }.ui-business-days-out { display: grid; gap: 8px; padding: 12px 16px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-business-days-answer { margin: 0; font-size: 22px; font-weight: 700; color: var(--ui-accent); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }.ui-business-days-sum { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-business-days-skipped { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 2px 14px; max-height: 220px; overflow: auto; margin: 0; padding: 0; list-style: none; font-size: 13px; font-variant-numeric: tabular-nums; }.ui-business-days-holiday { color: var(--ui-warning); font-weight: 600; }`let business_days_calc_limit = 36600let business_days_calc_most = 10000let business_days_calc_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]// A date the walk can leave safely: `date.add_days` fails past the years 1 and 9999.fn business_days_calc_date_ok(d) -> bool = type_of(d) == "str" && date.valid(d, "date") && d >= "1000-01-01" && d <= "9000-12-31"// "MM-DD", a day of every year (02-29 stands, for leap years).fn business_days_calc_month_day_ok(d: str) -> bool = re.test(d, "^\\d{2}-\\d{2}$") && date.valid(`2024-${d}`, "date")// Holidays as maps: { days: date -> name, yearly: "MM-DD" -> name, bad: what was not// understood }. `list` is what the prop takes, or the lines of the text box.fn business_days_calc_calendar(list) -> map { let days = {} let yearly = {} let bad = [] let items = type_of(list) == "list" ? list : [] if len(items) > 2000 { bad = bad | push("Only the first 2,000 holidays are read.") } for [i, item] in enumerate(take(items, 2000)) { let text = type_of(item) == "map" ? trim(`${item.date ?? ""} ${item.name ?? ""}`) : trim(str(item ?? "")) if text != "" { let cut = re.find(text, "^(\\S+)\\s*(.*)$") let when = cut.groups[0] let name = filled(cut.groups[1]) ?? "Holiday" if business_days_calc_date_ok(when) { days = set(days, when, name) } else if business_days_calc_month_day_ok(when) { yearly = set(yearly, when, name) } else { bad = bad | push(`Holiday ${i + 1}: "${slice(when, 0, 30)}" is not a date. Write 2026-12-25 or, for every year, 12-25.`) } } } { days, yearly, bad }}// The name of the holiday on `d`, or null.fn business_days_calc_holiday(d: str, cal: map) = get(cal.days, d) ?? get(cal.yearly, slice(d, 5, 10))// Why `d` is not a working day: { date, why: "weekend" | "holiday", name }, or null. A// holiday on a weekend is one day off, not two, and says it is both.fn business_days_calc_off(d: str, weekend: list, cal: map) { let h = business_days_calc_holiday(d, cal) if contains(weekend, date.weekday(d)) { return { date: d, why: "weekend", name: h } } h != null ? { date: d, why: "holiday", name: h } : null}// What every result shares: the skipped days counted.fn business_days_calc_tally(skipped: list) -> map { let weekends = skipped | filter((s) => s.why == "weekend") | len { weekends, holidays: len(skipped) - weekends }}// What cannot be walked at all, or null.fn business_days_calc_refuse(weekend: list) { if len(unique(weekend | filter((w) => type_of(w) == "int" && w >= 1 && w <= 7))) >= 7 { return "Every day of the week is marked as a weekend, so there are no working days." } null}// The working days from `start` to `end`: negative when `end` comes first. The day// walked from counts only with `count_first`, and the day reached always does.fn business_days_calc_between(start, end, weekend: list, cal: map, count_first: bool = false) -> map { if !business_days_calc_date_ok(start) { return { error: "The first date is not a date from 1000 to 9000." } } if !business_days_calc_date_ok(end) { return { error: "The second date is not a date from 1000 to 9000." } } let refused = business_days_calc_refuse(weekend) if refused != null { return { error: refused } } let span = date.days_between(start, end) if abs(span) > business_days_calc_limit { return { error: `These dates are ${format_number(abs(span), "en-US", 0)} days apart, and the limit is ${format_number(business_days_calc_limit, "en-US", 0)} days (about 100 years).` } } let step = span < 0 ? -1 : 1 let count = 0 let skipped = [] let i = count_first ? 0 : 1 while i <= abs(span) { let d = date.add_days(start, i * step) let off = business_days_calc_off(d, weekend, cal) if off == null { count += 1 } else { skipped = skipped | push(off) } i += 1 } { count: count * step, calendar_days: abs(span), skipped } + business_days_calc_tally(skipped)}// The date `n` working days from `start`, later or earlier. The first day walked to// is the day after `start`, so a Friday plus 1 is Monday. Plus 0 stays on `start`.fn business_days_calc_add(start, n, weekend: list, cal: map) -> map { if !business_days_calc_date_ok(start) { return { error: "The first date is not a date from 1000 to 9000." } } if type_of(n) != "int" { return { error: "The number of working days must be a whole number." } } if abs(n) > business_days_calc_most { return { error: `That is more than ${format_number(business_days_calc_most, "en-US", 0)} working days, the most this adds.` } } let refused = business_days_calc_refuse(weekend) if refused != null { return { error: refused } } let step = n < 0 ? -1 : 1 let left = abs(n) let d = start let skipped = [] let walked = 0 while left > 0 { if walked >= business_days_calc_limit { return { error: "No date was found within 100 years: the calendar has too few working days." } } d = date.add_days(d, step) walked += 1 let off = business_days_calc_off(d, weekend, cal) if off == null { left -= 1 } else { skipped = skipped | push(off) } } { date: d, count: n, calendar_days: walked, skipped } + business_days_calc_tally(skipped)}// A count as typed: an int, or null. The minus may be the typographic one.fn business_days_calc_read(text) { let t = replace(trim(str(text ?? "")), "−", "-") if !re.test(t, "^[+-]?\\d{1,12}$") { return null } let n = int(replace(t, "+", "")) is_err(n) ? null : n}// A day as it reads in the results: "Sat 7 Nov 2026".fn business_days_calc_show(d: str) -> str = date.format(d, "ddd D MMM YYYY")fn business_days_calc_plural(n: int, one: str) -> str = `${format_number(n, "en-US", 0)} ${one}${abs(n) == 1 ? "" : "s"}`component BusinessDaysCalc(mode: str = "between", start: str = "", end: str = "", count: int = 0, weekend: list = [6, 7], holidays: list = [], count_first: bool = false, title: str = "Working days", id: str = "business-days-calc", name: str? = null, on_change = null, class: str? = null) { style global business_days_calc_css let line = (h) => type_of(h) == "map" ? trim(`${h.date ?? ""} ${h.name ?? ""}`) : str(h) state how = mode == "add" ? "add" : "between" state from = start state to = end state n_text = str(count) state off = sort(unique(weekend | filter((w) => type_of(w) == "int" && w >= 1 && w <= 7))) state text = holidays | map(line) | join("\n") state first_too = count_first let named = (n: str) => name != null ? `${name}_${n}` : n let lines = split(text, "\n") let cal = business_days_calc_calendar(lines) let wanted = business_days_calc_read(n_text) let work = (w, c, kind, a, b, k, f) => { if a == "" { return { error: "Choose the first date." } } if kind == "add" { if k == null { return { error: `"${slice(trim(n_text), 0, 30)}" is not a whole number of working days.` } } return business_days_calc_add(a, k, w, c) } if b == "" { return { error: "Choose the second date." } } business_days_calc_between(a, b, w, c, f) } let r = work(off, cal, how, from, to, wanted, first_too) let tell = () => { let g = work(off, business_days_calc_calendar(split(text, "\n")), how, from, to, business_days_calc_read(n_text), first_too) if on_change != null && g.error == null { on_change(g) } } let shown = (r.skipped ?? []) | take(100) let answer = r.error != null ? "" : (how == "add" ? business_days_calc_show(r.date) : business_days_calc_plural(r.count, "working day")) let sum = r.error != null ? "" : `${business_days_calc_plural(r.calendar_days, "calendar day")}, skipping ${business_days_calc_plural(r.weekends, "weekend day")} and ${business_days_calc_plural(r.holidays, "holiday")}.` let note = r.error == null && how == "add" && r.count == 0 && business_days_calc_off(from, off, cal) != null ? `${business_days_calc_show(from)} is not itself a working day.` : null <section class={ui_cls(["ui-business-days", class])} aria-labelledby={`${id}-title`}> <h3.ui-business-days-title id={`${id}-title`}>{title}</h3> <fieldset.ui-business-days-modes> <legend>Work out</legend> <label><input type="radio" name={named("mode")} value="between" checked={how == "between"} @change={(e) => { how = "between"; tell() }}/> Days between two dates</label> <label><input type="radio" name={named("mode")} value="add" checked={how == "add"} @change={(e) => { how = "add"; tell() }}/> A date plus working days</label> </fieldset> <div.ui-business-days-grid> <label.ui-business-days-field>{how == "add" ? "From" : "First date"} <input type="date" id={`${id}-start`} name={named("start")} value={from} @input={(e) => { from = e.value; tell() }}/> </label> {how == "between" ? <label.ui-business-days-field>Second date <input type="date" id={`${id}-end`} name={named("end")} value={to} @input={(e) => { to = e.value; tell() }}/> </label> : <label.ui-business-days-field>Working days (negative goes back) <input type="text" inputmode="numeric" id={`${id}-count`} name={named("count")} value={n_text} autocomplete="off" aria-invalid={wanted == null ? "true" : null} @input={(e) => { n_text = e.value; tell() }}/> </label>} </div> {how == "between" && <label.ui-business-days-first> <input type="checkbox" id={`${id}-first`} name={named("count_first")} checked={first_too} @change={(e) => { first_too = e.checked; tell() }}/> Count the first date too </label>} <fieldset.ui-business-days-weekend> <legend>Weekend</legend> {for [i, day] in enumerate(business_days_calc_days) => <label key={day}> <input type="checkbox" name={named("weekend")} value={i + 1} checked={contains(off, i + 1)} aria-label={day} @change={(e) => { off = e.checked ? sort(off | push(i + 1)) : remove(off, i + 1); tell() }}/> <span aria-hidden="true">{slice(day, 0, 3)}</span> </label>} </fieldset> <label.ui-business-days-field>Holidays, one a line: a date and a name; 12-25 means every year <textarea id={`${id}-holidays`} name={named("holidays")} rows="3" spellcheck="false" aria-invalid={len(cal.bad) > 0 ? "true" : null} @input={(e) => { text = e.value; tell() }}>{text}</textarea> </label> {len(cal.bad) > 0 && <ul.ui-business-days-error role="alert">{for b in take(cal.bad, 5) => <li key={b}>{b}</li>}</ul>} <div aria-live="polite"> {r.error != null ? <p.ui-business-days-error role="alert">{r.error}</p> : <div.ui-business-days-out> <p.ui-business-days-answer>{answer}</p> <p.ui-business-days-sum>{sum}{note != null ? ` ${note}` : ""}</p> {len(shown) > 0 && <ul.ui-business-days-skipped aria-label="Days skipped"> {for s in shown => <li key={s.date} class={s.name != null ? "ui-business-days-holiday" : null}> {business_days_calc_show(s.date)}: {s.name != null ? s.name : "weekend"} </li>} </ul>} {len(r.skipped) > 100 && <p.ui-business-days-sum>The first 100 of {len(r.skipped)} are listed.</p>} </div>} </div> </section>}Related
CriticalPathRuns the forward and backward passes over tasks and dependencies: slack, the critical path and real dates with weekends off, naming cycles and missing tasks.3 views · 6 downloadsHabitTrackerA grid of days per habit that works out streaks, completion and grace days, on chosen weekdays only, and tells streaks apart across a missed day.3 views · 7 downloadsSlotFinderFinds common free slots from several people's busy lists, in working hours in a chosen time zone, ranking the closest by who is affected when none suits all.3 views · 6 downloadsExpressionInputA number field that works out 12*3+4, 15% of 80 and (1+2)/3 exactly with its own parser, shows the answer as typed, points at what it cannot read, and sends the answer.2 views · 5 downloadsFormulaGridA small spreadsheet: formulas with ranges and SUM AVG MIN MAX COUNT IF, dependency-ordered recalculation, exact decimals, keyboard movement and errors in the cell.2 views · 6 downloadsMarginCalcCost, price, margin and markup bound together: edit any and the rest follow from the two set last, with a price for a target margin and a warning under cost.3 views · 6 downloads
Thread
Thread 0 posts
On BusinessDaysCalc: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.