About making inexpensive models smarter by providing tools and context. (local models, gpt-5-mini, gpt-4.1-mini, gpt-4o-mini ...)

Phew, this one took longer than I thought.

Date Calculator Tool

combines multiple functions:

  • provide a list of date → weekday entries between 2 dates
  • get duration between 2 dates (in different units (as floats if needed) and split up as time segment object like 3 days, 2 hours, 5 minutes
  • add time segments like days, hours, … to a date
  • get weekday for a given date
  • get date for a given weekday shiftet by N weeks
  • get a date for a given day of month, shiftet by N months
  • convert epoch times ↔ date-time strings

This was the second big shortcoming of LLMs next to numeric calculations. (Btw. why doesn’t OpenAI provide tools like this internally to their models, so they don’t have to create the result based on linguistic probability instead?)

Example questions:

  • How long was at least one window opened in <roomname> today (Only gpt-5-mini of the small OpenAI models will be able to see the problem of intersecting timespans with multiple windows in one room on its own. gpt-4.1-mini will be able to solve it on its own once giving the hint. gpt-4o-mini will need a step by step calculation instruction to get the correct result.)
  • How long is it until Christmas eve (yes this makes the kids happy :stuck_out_tongue:)
  • Whose birthday is next?

This one is also a Node-RED based solution like the ‘Short term history access’ script.

Here are the steps to add it to your installation:

  • First add my generic “Call a Node-RED flow and wait for the response” script from here
    Only the base script, not the samples (Node-RED flow and sample caller script).
    Do not expose this helper script to assist.
[{"id":"096745da26d1cde7","type":"group","z":"64f4dc3ef013c268","name":"Date Calculator","style":{"label":false,"stroke":"none","fill":"#d1d1d1","fill-opacity":"0.5"},"nodes":["305dfadf3c7d393b","e1b80314e9d30514","22430ad99fb5b08b","b42821cc7aa98550","f8173a43e943921c","f831aee8394153d7","c7d58db3c15313f0","2af6a2c1c40a9ca4","3a63c07faed94d13"],"x":14,"y":799,"w":1052,"h":242},{"id":"305dfadf3c7d393b","type":"ha-fire-event","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"","server":"ef6aa0b.3fe4a6","version":0,"event":"nodered_request.response","data":"","dataType":"jsonata","x":880,"y":960,"wires":[[]]},{"id":"e1b80314e9d30514","type":"function","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Date Calculator","func":"// @ts-nocheck\n/**\n * Node-RED Function: Date Calculator (local timezone)\n *\n * Input envelope:\n *   msg.payload.parameters = { function: \"<one of below>\", ...params }\n *\n * Output on success:\n *   msg.payload.result = { ... }    // function-specific fields\n *   msg.payload.error  = null\n *\n * Output on error:\n *   msg.payload.result = {}\n *   msg.payload.error  = { error_code: string, message: string }\n *\n * Date format everywhere:\n *   \"YYYY-MM-DD HH:mm:SS\" (e.g., \"2025-08-13 11:39:00\"), parsed/output in LOCAL TIME.\n *\n * Supported functions (with required parameters):\n *\n * 1) duration_between_dates\n *    - Required:\n *        - date:  string \"YYYY-MM-DD HH:mm:SS\"\n *        - date2: string \"YYYY-MM-DD HH:mm:SS\"\n *    - Returns:\n *        - duration_in_s, duration_in_minutes, duration_in_hours, duration_in_days, duration_in_weeks\n *        - duration_in_months (calendar-based float), duration_in_years (calendar-based float)\n *        - duration_in_segments { sign:\"+|-\", years, months, weeks, days, hours, minutes, seconds }\n *\n * 2) date_by_adding_segments\n *    - Required:\n *        - date:     string \"YYYY-MM-DD HH:mm:SS\"\n *        - segments: object with optional { years, months, days, hours, minutes, seconds } (numbers; ±/0)\n *    - Returns:\n *        - new_date: string \"YYYY-MM-DD HH:mm:SS\" (local time)\n *        - weekday:  string (\"Sunday\"...\"Saturday\")\n *        - epoch_time_s: number (Unix epoch seconds of new_date)\n *\n * 3) weekday_for_date\n *    - Required:\n *        - date: string \"YYYY-MM-DD HH:mm:SS\"\n *    - Returns:\n *        - date: string (normalized)\n *        - weekday: string (\"Sunday\"...\"Saturday\")\n *\n * 4) date_for_weekday_in_n_weeks\n *    - Required:\n *        - weekday: \"sunday\"...\"saturday\" (case-insensitive)\n *        - n: integer >= 0\n *    - Returns:\n *        - date: \"YYYY-MM-DD 00:00:00\", weekday, days_from_today\n *\n * 5) date_for_day_of_month_in_n_months\n *    - Required:\n *        - day_of_month: integer 1..31\n *        - n: integer >= 0\n *    - Returns:\n *        - date: \"YYYY-MM-DD 00:00:00\", day_of_month, days_from_today, weekday\n *\n * 6) list_calendar_days\n *    - Required:\n *        - date:  \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:mm:SS\" (local)\n *        - date2: \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:mm:SS\" (local)\n *    - Returns:\n *        - days: array of { date: \"YYYY-MM-DD\", weekday: \"Sunday\"...\"Saturday\" }\n *\n * 7) epoch_to_date\n *    - Required:\n *        - epoch_time_s: integer (Unix epoch seconds; NOT ms)\n *    - Returns:\n *        - date: \"YYYY-MM-DD HH:mm:SS\" (local time)\n *        - weekday: \"Sunday\"...\"Saturday\"\n *        - epoch_time_s: number (echoed)\n *\n * 8) date_to_epoch\n *    - Required:\n *        - date: \"YYYY-MM-DD HH:mm:SS\" (local time)\n *    - Returns:\n *        - epoch_time_s: number (Unix epoch seconds)\n *        - date: string (normalized)\n *        - weekday: \"Sunday\"...\"Saturday\"\n */\n\n/////////////////////////////// Helpers ///////////////////////////////\n\nfunction pad(n) { return String(n).padStart(2, \"0\"); }\n\n// Format in local time: \"YYYY-MM-DD HH:mm:SS\"\nfunction formatLocal(d) {\n    return (\n        d.getFullYear() + \"-\" +\n        pad(d.getMonth() + 1) + \"-\" +\n        pad(d.getDate()) + \" \" +\n        pad(d.getHours()) + \":\" +\n        pad(d.getMinutes()) + \":\" +\n        pad(d.getSeconds())\n    );\n}\n\n// Parse \"YYYY-MM-DD HH:mm:SS\" as local time with validation\nfunction parseLocalDate(str) {\n    if (typeof str !== \"string\") return { error: \"invalid_type\" };\n    const m = str.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n    if (!m) return { error: \"invalid_format\" };\n    const [_, y, mo, d, hh, mm, ss] = m;\n    const year = +y, month = +mo, day = +d, hour = +hh, minute = +mm, second = +ss;\n    if (month < 1 || month > 12) return { error: \"invalid_date\" };\n    if (day < 1 || day > 31) return { error: \"invalid_date\" };\n    if (hour < 0 || hour > 23) return { error: \"invalid_date\" };\n    if (minute < 0 || minute > 59) return { error: \"invalid_date\" };\n    if (second < 0 || second > 59) return { error: \"invalid_date\" };\n    const dObj = new Date(year, month - 1, day, hour, minute, second, 0); // local\n    if (\n        dObj.getFullYear() !== year ||\n        dObj.getMonth() + 1 !== month ||\n        dObj.getDate() !== day ||\n        dObj.getHours() !== hour ||\n        dObj.getMinutes() !== minute ||\n        dObj.getSeconds() !== second\n    ) {\n        return { error: \"invalid_date\" };\n    }\n    return { date: dObj };\n}\n\nfunction todayStart() {\n    const now = new Date();\n    return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);\n}\n\nfunction diffSeconds(dateA, dateB) {\n    return (dateB.getTime() - dateA.getTime()) / 1000;\n}\n\n// English weekdays (0=Sunday ... 6=Saturday)\nconst DOW_NAMES_EN = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"];\nconst DOW_MAP_EN = {\n    \"sunday\": 0, \"monday\": 1, \"tuesday\": 2,\n    \"wednesday\": 3, \"thursday\": 4, \"friday\": 5, \"saturday\": 6\n};\n\nfunction normalizeWeekdayEn(input) {\n    if (typeof input !== \"string\") return { error: \"invalid_weekday\" };\n    const key = input.trim().toLowerCase();\n    if (!key) return { error: \"invalid_weekday\" };\n    if (!(key in DOW_MAP_EN)) return { error: \"invalid_weekday\" };\n    const dow = DOW_MAP_EN[key];\n    return { dow, name: DOW_NAMES_EN[dow] };\n}\n\nfunction weekdayNameEn(dow) {\n    return DOW_NAMES_EN[dow];\n}\n\n// DST-safe, type-checker-friendly difference in whole days (UTC midnight)\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nfunction daysBetweenDates(a, b) {\n    const utcA = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());\n    const utcB = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());\n    return Math.round((utcB - utcA) / MS_PER_DAY);\n}\n\nfunction addSegments(base, segs) {\n    const years = +segs.years || 0;\n    const months = +segs.months || 0;\n    const days = +segs.days || 0;\n    const hours = +segs.hours || 0;\n    const minutes = +segs.minutes || 0;\n    const seconds = +segs.seconds || 0;\n\n    const d = new Date(base.getTime());\n\n    // years/months first (JS handles overflow across months)\n    if (years || months) {\n        const y = d.getFullYear() + years;\n        const m = d.getMonth() + months;\n        d.setFullYear(y);\n        d.setMonth(m);\n    }\n    if (days) d.setDate(d.getDate() + days);\n    if (hours) d.setHours(d.getHours() + hours);\n    if (minutes) d.setMinutes(d.getMinutes() + minutes);\n    if (seconds) d.setSeconds(d.getSeconds() + seconds);\n\n    return d;\n}\n\nfunction fail(error_code, message) {\n    msg.payload = msg.payload || {};\n    msg.payload.result = {};\n    msg.payload.error = { error_code, message };\n    return msg;\n}\n\nfunction ok(resultObj) {\n    msg.payload = msg.payload || {};\n    msg.payload.result = resultObj || {};\n    msg.payload.error = null;\n    return msg;\n}\n\nfunction validateParams(actual, requiredKeys, optionalKeys) {\n    const req = new Set(requiredKeys || []);\n    const opt = new Set(optionalKeys || []);\n    const allowed = new Set([...req, ...opt, \"function\"]);\n\n    const missing = [];\n    req.forEach(k => { if (!(k in actual)) missing.push(k); });\n    if (missing.length) return { ok: false, type: \"missing\", keys: missing };\n\n    const unexpected = [];\n    Object.keys(actual || {}).forEach(k => {\n        if (!allowed.has(k)) unexpected.push(k);\n    });\n    if (unexpected.length) return { ok: false, type: \"unexpected\", keys: unexpected };\n\n    return { ok: true };\n}\n\n// Calendar-based greedy split: years -> months -> weeks -> days -> hours -> minutes -> seconds\n// Also returns \"anchor\" (cursor after applying full years+months) and the remaining seconds.\nfunction diffToCalendarSegments(dA, dB) {\n    const sign = dB >= dA ? \"+\" : \"-\";\n    const start = (sign === \"+\") ? dA : dB;\n    const end = (sign === \"+\") ? dB : dA;\n\n    let cursor = new Date(start.getTime());\n\n    // Years\n    let years = end.getFullYear() - cursor.getFullYear();\n    let candidate = new Date(cursor.getTime());\n    candidate.setFullYear(candidate.getFullYear() + years);\n    if (candidate > end) {\n        years--;\n        candidate = new Date(cursor.getTime());\n        candidate.setFullYear(candidate.getFullYear() + years);\n    }\n    cursor = candidate;\n\n    // Months\n    let months = (end.getFullYear() - cursor.getFullYear()) * 12 + (end.getMonth() - cursor.getMonth());\n    candidate = new Date(cursor.getTime());\n    candidate.setMonth(candidate.getMonth() + months);\n    if (candidate > end) {\n        months--;\n        candidate = new Date(cursor.getTime());\n        candidate.setMonth(candidate.getMonth() + months);\n    }\n    cursor = candidate;\n\n    // Remaining seconds to split into weeks..seconds\n    let remSec = Math.floor((end.getTime() - cursor.getTime()) / 1000); // integer seconds\n    const WEEK = 7 * 24 * 3600;\n    const DAY = 24 * 3600;\n    const HOUR = 3600;\n    const MIN = 60;\n\n    const weeks = Math.floor(remSec / WEEK); remSec -= weeks * WEEK;\n    const days = Math.floor(remSec / DAY); remSec -= days * DAY;\n    const hours = Math.floor(remSec / HOUR); remSec -= hours * HOUR;\n    const minutes = Math.floor(remSec / MIN); remSec -= minutes * MIN;\n    const seconds = remSec;\n\n    return { sign, years, months, weeks, days, hours, minutes, seconds, anchor: new Date(cursor.getTime()), remainder_seconds: (end.getTime() - cursor.getTime()) / 1000 };\n}\n\n// Duration (in seconds) of the calendar month starting at \"anchor\" (anchor to anchor+1 month)\nfunction secondsOfAnchorMonth(anchor) {\n    const next = new Date(anchor.getTime());\n    next.setMonth(next.getMonth() + 1);\n    return (next.getTime() - anchor.getTime()) / 1000;\n}\n\n/////////////////////////////// Input & Routing ///////////////////////////////\n\nconst p = (msg && msg.payload && msg.payload.parameters) || null;\nif (!p || typeof p !== \"object\") {\n    return fail(\"param_missing\", \"No valid object provided under parameters.\");\n}\n\nconst fn = p.function;\nif (typeof fn !== \"string\") {\n    return fail(\"param_missing\", 'The parameter \"function\" (string) is missing.');\n}\n\n/////////////////////////////// Functions ///////////////////////////////\n\nif (fn === \"duration_between_dates\") {\n    // Required: date, date2\n    const check = validateParams(p, [\"date\", \"date2\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=duration_between_dates: ${check.keys.join(\", \")}`);\n        }\n    }\n\n    const d1 = parseLocalDate(p.date);\n    const d2 = parseLocalDate(p.date2);\n    if (d1.error || d2.error) {\n        return fail(\"invalid_date_format\", \"date or date2 is not a valid date in format 'YYYY-MM-DD HH:mm:SS'.\");\n    }\n\n    // Continuous differences\n    const seconds = diffSeconds(d1.date, d2.date); // may be negative\n    const minutes = seconds / 60;\n    const hours = seconds / 3600;\n    const days = seconds / (3600 * 24);\n    const weeks = seconds / (7 * 24 * 3600);\n\n    // Calendar-based split\n    const seg = diffToCalendarSegments(d1.date, d2.date);\n\n    // Calendar-based floating months/years\n    let monthsFloatAbs = seg.years * 12 + seg.months;\n    if (seg.remainder_seconds > 0) {\n        const monthLen = secondsOfAnchorMonth(seg.anchor);\n        monthsFloatAbs += seg.remainder_seconds / monthLen;\n    }\n    const signFactor = seg.sign === \"-\" ? -1 : 1;\n    const durationInMonths = signFactor * monthsFloatAbs;\n    const durationInYears = durationInMonths / 12;\n\n    return ok({\n        duration_in_s: seconds,\n        duration_in_minutes: minutes,\n        duration_in_hours: hours,\n        duration_in_days: days,\n        duration_in_weeks: weeks,\n        duration_in_months: durationInMonths,\n        duration_in_years: durationInYears,\n        duration_in_segments: {\n            sign: seg.sign,\n            years: seg.years,\n            months: seg.months,\n            weeks: seg.weeks,\n            days: seg.days,\n            hours: seg.hours,\n            minutes: seg.minutes,\n            seconds: seg.seconds\n        }\n    });\n}\n\nif (fn === \"date_by_adding_segments\") {\n    // Required: date, segments\n    const check = validateParams(p, [\"date\", \"segments\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=date_by_adding_segments: ${check.keys.join(\", \")}`);\n        }\n    }\n    const base = parseLocalDate(p.date);\n    if (base.error) {\n        return fail(\"invalid_date_format\", \"date is not a valid date in format 'YYYY-MM-DD HH:mm:SS'.\");\n    }\n    if (typeof p.segments !== \"object\" || p.segments === null) {\n        return fail(\"param_missing\", \"segments must be an object with optional fields: years, months, days, hours, minutes, seconds.\");\n    }\n    const allowedSeg = new Set([\"years\", \"months\", \"days\", \"hours\", \"minutes\", \"seconds\"]);\n    const extraSeg = Object.keys(p.segments).filter(k => !allowedSeg.has(k));\n    if (extraSeg.length) {\n        return fail(\"param_unexpected\", `Unexpected fields in segments: ${extraSeg.join(\", \")}`);\n    }\n\n    const newDate = addSegments(base.date, p.segments);\n    return ok({\n        new_date: formatLocal(newDate),\n        weekday: weekdayNameEn(newDate.getDay()),\n        epoch_time_s: Math.floor(newDate.getTime() / 1000)\n    });\n}\n\nif (fn === \"weekday_for_date\") {\n    // Required: date\n    const check = validateParams(p, [\"date\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=weekday_for_date: ${check.keys.join(\", \")}`);\n        }\n    }\n    const d = parseLocalDate(p.date);\n    if (d.error) {\n        return fail(\"invalid_date_format\", \"date is not a valid date in format 'YYYY-MM-DD HH:mm:SS'.\");\n    }\n    const dow = d.date.getDay();\n    return ok({\n        date: formatLocal(d.date),\n        weekday: weekdayNameEn(dow)\n    });\n}\n\nif (fn === \"date_for_weekday_in_n_weeks\") {\n    // Required: weekday, n\n    const check = validateParams(p, [\"weekday\", \"n\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=date_for_weekday_in_n_weeks: ${check.keys.join(\", \")}`);\n        }\n    }\n    const n = Number(p.n);\n    if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {\n        return fail(\"invalid_n\", \"n must be an integer >= 0.\");\n    }\n    const norm = normalizeWeekdayEn(p.weekday);\n    if (norm.error) {\n        return fail(\"invalid_weekday\", \"weekday must be one of: sunday, monday, tuesday, wednesday, thursday, friday, saturday (case-insensitive).\");\n    }\n    const targetDow = norm.dow;\n\n    const base = todayStart(); // today 00:00 local\n    const baseDow = base.getDay();\n    let delta = (targetDow - baseDow + 7) % 7; // 0..6 (0 = today)\n    delta += n * 7; // n=0 includes today, each +1 adds 7 days\n\n    const target = new Date(base.getFullYear(), base.getMonth(), base.getDate() + delta, 0, 0, 0);\n    const daysFromToday = daysBetweenDates(base, target);\n\n    return ok({\n        date: formatLocal(target),   // \"YYYY-MM-DD 00:00:00\"\n        weekday: weekdayNameEn(target.getDay()),\n        days_from_today: daysFromToday\n    });\n}\n\nif (fn === \"date_for_day_of_month_in_n_months\") {\n    // Required: day_of_month, n\n    const check = validateParams(p, [\"day_of_month\", \"n\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=date_for_day_of_month_in_n_months: ${check.keys.join(\", \")}`);\n        }\n    }\n    const n = Number(p.n);\n    const dom = Number(p.day_of_month);\n    if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {\n        return fail(\"invalid_n\", \"n must be an integer >= 0.\");\n    }\n    if (!Number.isFinite(dom) || dom < 1 || dom > 31 || !Number.isInteger(dom)) {\n        return fail(\"invalid_day_of_month\", \"day_of_month must be an integer between 1 and 31.\");\n    }\n\n    function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); }\n\n    const base = todayStart(); // 00:00\n    let firstY = base.getFullYear();\n    let firstM = base.getMonth();\n\n    let dim = daysInMonth(firstY, firstM);\n    let firstDate;\n\n    if (dom <= dim) {\n        firstDate = new Date(firstY, firstM, dom, 0, 0, 0);\n        if (firstDate < base) {\n            firstM += 1;\n            if (firstM > 11) { firstM = 0; firstY += 1; }\n            dim = daysInMonth(firstY, firstM);\n            if (dom > dim) {\n                return fail(\"invalid_day_of_month\", `day_of_month (${dom}) does not exist in the start month ${firstM + 1}/${firstY}.`);\n            }\n            firstDate = new Date(firstY, firstM, dom, 0, 0, 0);\n        }\n    } else {\n        firstM += 1;\n        if (firstM > 11) { firstM = 0; firstY += 1; }\n        dim = daysInMonth(firstY, firstM);\n        if (dom > dim) {\n            return fail(\"invalid_day_of_month\", `day_of_month (${dom}) does not exist in the start month ${firstM + 1}/${firstY}.`);\n        }\n        firstDate = new Date(firstY, firstM, dom, 0, 0, 0);\n    }\n\n    const target = new Date(firstDate.getFullYear(), firstDate.getMonth() + n, dom, 0, 0, 0);\n    if (target.getDate() !== dom) {\n        return fail(\"invalid_day_of_month\", `day_of_month (${dom}) does not exist in the target month.`);\n    }\n\n    const daysFromToday = daysBetweenDates(base, target);\n    return ok({\n        date: formatLocal(target),   // \"YYYY-MM-DD 00:00:00\"\n        day_of_month: dom,\n        days_from_today: daysFromToday,\n        weekday: weekdayNameEn(target.getDay())\n    });\n}\n\nif (fn === \"list_calendar_days\") {\n    // Required: date, date2\n    const check = validateParams(p, [\"date\", \"date2\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=list_calendar_days: ${check.keys.join(\", \")}`);\n        }\n    }\n\n    // Local helpers\n    function formatLocalYMD(d) {\n        return d.getFullYear() + \"-\" + pad(d.getMonth() + 1) + \"-\" + pad(d.getDate());\n    }\n    // Accepts \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:mm:SS\", returns Date in local time\n    function parseLocalDateFlexible(str) {\n        if (typeof str !== \"string\") return { error: \"invalid_type\" };\n        const m = str.match(/^(\\d{4})-(\\d{2})-(\\d{2})(?: (\\d{2}):(\\d{2}):(\\d{2}))?$/);\n        if (!m) return { error: \"invalid_format\" };\n        const year = +m[1], month = +m[2], day = +m[3];\n        const hour = m[4] ? +m[4] : 0;\n        const minute = m[5] ? +m[5] : 0;\n        const second = m[6] ? +m[6] : 0;\n        if (month < 1 || month > 12) return { error: \"invalid_date\" };\n        if (day < 1 || day > 31) return { error: \"invalid_date\" };\n        if (hour < 0 || hour > 23) return { error: \"invalid_date\" };\n        if (minute < 0 || minute > 59) return { error: \"invalid_date\" };\n        if (second < 0 || second > 59) return { error: \"invalid_date\" };\n        const dObj = new Date(year, month - 1, day, hour, minute, second, 0);\n        if (\n            dObj.getFullYear() !== year ||\n            dObj.getMonth() + 1 !== month ||\n            dObj.getDate() !== day ||\n            dObj.getHours() !== hour ||\n            dObj.getMinutes() !== minute ||\n            dObj.getSeconds() !== second\n        ) {\n            return { error: \"invalid_date\" };\n        }\n        return { date: dObj };\n    }\n    function toLocalMidnight(d) {\n        return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);\n    }\n\n    const s = parseLocalDateFlexible(p.date);\n    const e = parseLocalDateFlexible(p.date2);\n    if (s.error || e.error) {\n        return fail(\"invalid_date_format\", \"date or date2 is not a valid date (accepted: 'YYYY-MM-DD' or 'YYYY-MM-DD HH:mm:SS').\");\n    }\n\n    let start = toLocalMidnight(s.date);\n    let end = toLocalMidnight(e.date);\n    if (start > end) {\n        return fail(\"invalid_range\", \"date must be less than or equal to date2.\");\n    }\n\n    const days = [];\n    while (start.getTime() <= end.getTime()) {\n        days.push({\n            date: formatLocalYMD(start),\n            weekday: DOW_NAMES_EN[start.getDay()]\n        });\n        start = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1, 0, 0, 0, 0);\n    }\n\n    return ok({ days });\n}\n\nif (fn === \"epoch_to_date\") {\n    // Required: epoch_time_s (seconds, NOT ms)\n    const check = validateParams(p, [\"epoch_time_s\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=epoch_to_date: ${check.keys.join(\", \")}`);\n        }\n    }\n    const s = Number(p.epoch_time_s);\n    if (!Number.isFinite(s) || !Number.isInteger(s)) {\n        return fail(\"invalid_epoch_time\", \"epoch_time_s must be an integer number of seconds since 1970-01-01T00:00:00Z.\");\n    }\n    const d = new Date(s * 1000);\n    if (isNaN(d.getTime())) {\n        return fail(\"invalid_epoch_time\", \"epoch_time_s results in an invalid Date.\");\n    }\n    return ok({\n        date: formatLocal(d),\n        weekday: weekdayNameEn(d.getDay()),\n        epoch_time_s: s\n    });\n}\n\nif (fn === \"date_to_epoch\") {\n    // Required: date\n    const check = validateParams(p, [\"date\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=date_to_epoch: ${check.keys.join(\", \")}`);\n        }\n    }\n    const d = parseLocalDate(p.date);\n    if (d.error) {\n        return fail(\"invalid_date_format\", \"date is not a valid date in format 'YYYY-MM-DD HH:mm:SS'.\");\n    }\n    const epoch = Math.floor(d.date.getTime() / 1000);\n    return ok({\n        epoch_time_s: epoch,\n        date: formatLocal(d.date),\n        weekday: weekdayNameEn(d.date.getDay())\n    });\n}\n\nreturn fail(\n    \"unknown_function\",\n    \"Unknown function. Allowed: duration_between_dates, date_by_adding_segments, weekday_for_date, date_for_weekday_in_n_weeks, date_for_day_of_month_in_n_months, list_calendar_days, epoch_to_date, date_to_epoch.\"\n);\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":280,"y":960,"wires":[["c7d58db3c15313f0"]]},{"id":"22430ad99fb5b08b","type":"debug","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Parameters","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":950,"y":900,"wires":[]},{"id":"b42821cc7aa98550","type":"function","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Format Incoming Data","func":"const event = msg.payload.event;\nmsg.channel = event.channel;\nmsg.request_id= event.request_id;\n\nmsg.payload = {\n    parameters: event.payload\n}\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":720,"y":900,"wires":[["e1b80314e9d30514","22430ad99fb5b08b"]]},{"id":"f8173a43e943921c","type":"server-events","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"","server":"ef6aa0b.3fe4a6","version":3,"exposeAsEntityConfig":"","eventType":"nodered_request.trigger","eventData":"","waitForRunning":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"$outputData(\"eventData\").event_type","valueType":"jsonata"}],"x":170,"y":900,"wires":[["f831aee8394153d7"]]},{"id":"f831aee8394153d7","type":"switch","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Are we the receiver for the event?","property":"payload.event.channel","propertyType":"msg","rules":[{"t":"eq","v":"date-calculator","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":460,"y":900,"wires":[["b42821cc7aa98550"]]},{"id":"c7d58db3c15313f0","type":"function","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Format Response","func":"msg = { \n    payload : {\n        data: {\n            channel: msg.channel,\n            request_id: msg.request_id,\n            result: msg.payload.result,\n            error: msg.payload.error\n        } \n    }\n};\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":470,"y":960,"wires":[["305dfadf3c7d393b","2af6a2c1c40a9ca4"]]},{"id":"2af6a2c1c40a9ca4","type":"debug","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Result","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":650,"y":1000,"wires":[]},{"id":"3a63c07faed94d13","type":"comment","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Date Calculator","info":"","x":120,"y":840,"wires":[]},{"id":"ef6aa0b.3fe4a6","type":"server","name":"Home Assistant","addon":true}]
  • The final script follows in the next post, as I reached the max. char size. :smile: