"use strict"; // Day-number kernel: Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms", // https://howardhinnant.github.io/date_algorithms.html (days_from_civil / civil_from_days). // This method does fixed-offset arithmetic ONLY: it never consults a time zone database, the // host clock, or Date.now(). Offsets are exactly what the caller supplies (e.g. "-05:00"). function daysFromCivil(y, m, d) { y = m <= 2 ? y - 1 : y; const era = Math.floor((y >= 0 ? y : y - 399) / 400); const yoe = y - era * 400; const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1; const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy; return era * 146097 + doe - 719468; } function civilFromDays(z) { z += 719468; const era = Math.floor((z >= 0 ? z : z - 146096) / 146097); const doe = z - era * 146097; const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365); const y = yoe + era * 400; const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100)); const mp = Math.floor((5 * doy + 2) / 153); const d = doy - Math.floor((153 * mp + 2) / 5) + 1; const m = mp + (mp < 10 ? 3 : -9); const yy = y + (m <= 2 ? 1 : 0); return { year: yy, month: m, day: d }; } function isLeapGregorian(y) { return (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0); } function daysInMonth(y, m) { const dim = [31, isLeapGregorian(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; return dim[m - 1]; } function parseISODate(s, label) { const m = /^(-?\d{4})-(\d{2})-(\d{2})$/.exec(String(s).trim()); if (!m) throw new Error((label || "date") + " must be an ISO 8601 date YYYY-MM-DD, got: " + s); const year = parseInt(m[1], 10), month = parseInt(m[2], 10), day = parseInt(m[3], 10); if (month < 1 || month > 12) throw new Error((label || "date") + " month out of range 1-12"); if (day < 1 || day > daysInMonth(year, month)) throw new Error((label || "date") + " day out of range for that month"); return { year, month, day }; } function parseTime(s) { const m = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(String(s).trim()); if (!m) throw new Error("time must be HH:MM or HH:MM:SS, got: " + s); const hh = parseInt(m[1], 10), mm = parseInt(m[2], 10), ss = m[3] ? parseInt(m[3], 10) : 0; if (hh > 23 || mm > 59 || ss > 59) throw new Error("time fields out of range: " + s); return hh * 3600 + mm * 60 + ss; } function parseOffset(s) { const m = /^([+-])(\d{2}):(\d{2})$/.exec(String(s).trim()); if (!m) throw new Error("offset must be like -05:00 or +01:00, got: " + s); const sign = m[1] === "-" ? -1 : 1; const hh = parseInt(m[2], 10), mm = parseInt(m[3], 10); if (hh > 23 || mm > 59) throw new Error("offset fields out of range: " + s); return sign * (hh * 3600 + mm * 60); } function pad2(n) { return String(n).padStart(2, "0"); } function toISODate(y, m, d) { const ys = y < 0 ? "-" + String(-y).padStart(4, "0") : String(y).padStart(4, "0"); return ys + "-" + pad2(m) + "-" + pad2(d); } module.exports = { slug: "convert-time-between-fixed-offsets", title: "Convert a time between fixed UTC offsets", deck_category: "timezones", description: "Converts a wall-clock time and date from one fixed UTC offset to another, handling the date rollover if any.", inputs: [ { name: "date", type: "string", format: "date", required: true, description: "ISO 8601 date of the source time, YYYY-MM-DD" }, { name: "time", type: "string", required: true, description: "Source wall-clock time, HH:MM or HH:MM:SS (24-hour)" }, { name: "from_offset", type: "string", required: true, description: "Fixed UTC offset of the source time, e.g. -05:00" }, { name: "to_offset", type: "string", required: true, description: "Fixed UTC offset to convert into, e.g. +01:00" } ], outputs: [ { name: "result_date", format: "date", description: "The resulting date in the target offset" }, { name: "result_time", description: "The resulting wall-clock time in the target offset, HH:MM:SS" }, { name: "day_shift", description: "0 if the result is the same calendar date, +1 if it rolled to the next date, -1 if it rolled to the previous date" } ], steps: [ "Parse date and time into a day-number (Hinnant days_from_civil, cited above) and seconds-since-midnight.", "Parse from_offset and to_offset as signed HH:MM offsets from UTC, in seconds.", "utc_seconds_total = day_number * 86400 + local_seconds - from_offset_seconds.", "target_seconds_total = utc_seconds_total + to_offset_seconds.", "result_day_number = floor(target_seconds_total / 86400); seconds_into_day = target_seconds_total - result_day_number * 86400.", "Convert result_day_number back to a civil date (civil_from_days) and seconds_into_day back to HH:MM:SS.", "day_shift = result_day_number - original_day_number." ], constants: [], sources: [ { kind: "url", value: "https://howardhinnant.github.io/date_algorithms.html", title: "chrono-Compatible Low-Level Date Algorithms", publisher: "Howard Hinnant", retrieved_utc: "2026-09-30T19:41:35Z", note: "day-number kernel used to handle the date rollover; no time zone database is used anywhere in this method" } ], disclaimer: null, questionText(inputs) { return "What is " + inputs.date + " " + inputs.time + " " + inputs.from_offset + " converted to the " + inputs.to_offset + " offset?"; }, run(inputs) { const d = parseISODate(inputs.date, "date"); const localSeconds = parseTime(inputs.time); const fromOff = parseOffset(inputs.from_offset); const toOff = parseOffset(inputs.to_offset); const originalDayNumber = daysFromCivil(d.year, d.month, d.day); const utcTotal = originalDayNumber * 86400 + localSeconds - fromOff; const targetTotal = utcTotal + toOff; const resultDayNumber = Math.floor(targetTotal / 86400); const secondsIntoDay = targetTotal - resultDayNumber * 86400; const r = civilFromDays(resultDayNumber); const hh = Math.floor(secondsIntoDay / 3600); const mm = Math.floor((secondsIntoDay % 3600) / 60); const ss = secondsIntoDay % 60; const resultDate = toISODate(r.year, r.month, r.day); const resultTime = pad2(hh) + ":" + pad2(mm) + ":" + pad2(ss); const dayShift = resultDayNumber - originalDayNumber; const answer = resultDate + " " + resultTime + " (" + (dayShift === 0 ? "same day" : dayShift > 0 ? "+" + dayShift + " day" : dayShift + " day") + ")"; return { answer, values: { result_date: resultDate, result_time: resultTime, day_shift: dayShift } }; }, examples: [ { name: "worked example", inputs: { date: "2026-09-30", time: "23:30:00", from_offset: "-05:00", to_offset: "+01:00" }, expected: { answer: "2026-10-01 05:30:00 (+1 day)" }, check: "the offset difference is +6 hours; 23:30 + 6:00 = 29:30, which is 05:30 the next day" }, { name: "test vector: rolls to the previous day", inputs: { date: "2026-01-01", time: "00:15:00", from_offset: "+09:00", to_offset: "-08:00" }, expected: { answer: "2025-12-31 07:15:00 (-1 day)" }, check: "the offset difference is -17 hours; 00:15 - 17:00 = -16:45, which is 07:15 the previous day" }, { name: "test vector: same day, no rollover", inputs: { date: "2026-06-15", time: "10:00:00", from_offset: "+00:00", to_offset: "+02:00" }, expected: { answer: "2026-06-15 12:00:00 (same day)" }, check: "the offset difference is +2 hours; 10:00 + 2:00 = 12:00, still the same calendar date" } ] };