{
  "id": "P087",
  "slug": "weeks-and-months",
  "key": "P087-weeks-and-months",
  "title": "Weeks and months",
  "summary": "Dates as a day count: a date's ISO week date against the US and simple week numbers, adding months by the clamp and overflow rules, stepping a month at a time against adding all at once, working days, and the years with 53 ISO weeks.",
  "entry": "main.eml",
  "ui": "terminal",
  "readme": "# P087 - Weeks and months\n\nDates as a day count, so that weeks and months can be reckoned exactly.\nThe program gives a date's ISO week date next to two other week\nnumbers. It adds months by the clamp and the overflow rules, compares\nstepping a month at a time with adding all the months at once, counts\nworking days, and lists the years with 53 ISO weeks.\n\n- `main.eml` - the menu, reading dates and numbers, and the reports\n- `weeks.eml` - leap years, day numbers and back, weekdays, the three\n  week numberings, adding months, and working days\n\nHow each part works:\n\n- A date becomes the number of days since 1 January 1600, a Saturday:\n  365 per year plus the leap days (every 4th year, not every 100th, but\n  every 400th). Weekdays and differences are then plain arithmetic.\n- Week numbers, as in the corpus case `week-numbering-conventions`, are at\n  least three different things:\n  - ISO: weeks start on Monday, and a week belongs to the year holding its\n    Thursday. So 1 January can be in the last week of the year before -\n    2027-01-01 is in week 53 of 2026 - and 30 December 2024 is in week 1\n    of 2025.\n  - US: weeks start on Sunday, and week 1 is the one holding 1 January.\n  - Simple: week 1 is 1 to 7 January, whatever days they are.\n- A year has 53 ISO weeks when it starts on a Thursday, or is a leap year\n  starting on a Wednesday.\n- Adding months, as in the corpus case `month-end-arithmetic`: 31 January\n  plus one month has no answer. Clamping gives the month's last day,\n  28 February; overflow runs on into March, 3 March. Clamping cannot be\n  undone: 31 January + 1 - 1 is 28 January. Stepping a month at a time\n  drifts to the 28th and stays there, while adding all the months at once\n  from the start does not.\n- Working days are Monday to Friday, counted by whole weeks and then day by\n  day.\n\nWhat is checked: menu choices 1 to 7; dates as YYYY-MM-DD, real ones from\n1601-01-01 to 9999-12-31 (1601 is the first year whose weeks all start on\nor after the day count's first day), and results inside that range; months\nfrom -1200 to 1200, steps from 1 to 24, working days from 1 to 10,000,\nyears from 1600 to 9999; a second date no earlier than the first. An empty\nanswer cancels.\n\nSessions: `sessions/basic.in` gives:\n- 2026-10-15 (week 42), 2027-01-01 (2026-W53) and 2024-12-30 (2025-W01);\n- 31 January 2026 and 2024 plus one month by both rules, and back again;\n  15 March minus two months, where the rules agree;\n- twelve monthly steps from 31 January (2027-01-28 against 2027-01-31),\n  and six from the 15th (no difference);\n- 22 working days in October 2026; 10 working days after 2026-10-15;\n- the five 53-week years from 2000 to 2030.\n\n`sessions/bad-input.in` gives:\n- menu choices 0 and x;\n- 2026-02-30, slashes, 1600-12-31 and day-first order, then a cancel;\n- 1201 and x months; 1200 months back from 1601, which lands outside;\n- an end date before the start; 0 working days; year 1599; an end year\n  before the start; and 2026 alone, which has 53.\n\nBuilt on the verified corpus cases `week-numbering-conventions` (three\nweek numberings that disagree at the ends of the year) and\n`month-end-arithmetic` (clamp against overflow, and what clamping loses).\n",
  "modules": [
    {
      "name": "main.eml",
      "eml": "# P087 weeks and months: a date's ISO week and two other week numbers,\n# adding months by the clamp and overflow rules, stepping a month at a time\n# against adding all at once, working days, and years with 53 ISO weeks.\nimport weeks\n\ndef trim(s):\n    0 => i\n    len(s) => j\n    while i < j and s[i] == \" \":\n        i + 1 => i\n    while j > i and s[j - 1] == \" \":\n        j - 1 => j\n    return s[i:j]\n\ndef digits(s):\n    if len(s) == 0:\n        return -1\n    0 => n\n    for c in s:\n        if not (c >= \"0\" and c <= \"9\"):\n            return -1\n        n * 10 + int(c) => n\n    return n\n\ndef two(n):\n    if n < 10:\n        return \"0\" + str(n)\n    return str(n)\n\ndef text(ymd):\n    return str(ymd[0]) + \"-\" + two(ymd[1]) + \"-\" + two(ymd[2])\n\n[\"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\", \"Sunday\"] => NAMES\n\ndef parse(s):\n    # \"2026-10-15\" as [y, m, d] (1601-01-01 to 9999-12-31), or []. 1601 is the\n    # first year whose ISO weeks all start on or after the day count's\n    # first day, 1 January 1600.\n    if len(s) != 10 or s[4] != \"-\" or s[7] != \"-\":\n        return []\n    digits(s[0:4]) => y\n    digits(s[5:7]) => m\n    digits(s[8:10]) => d\n    if y < 1601 or m < 1 or m > 12 or d < 1:\n        return []\n    if d > weeks.days_in(y, m):\n        return []\n    return [y, m, d]\n\ndef ask_date(prompt):\n    while True:\n        trim(input(prompt + \" (YYYY-MM-DD)> \")) => s\n        if s == \"\":\n            return []\n        parse(s) => ymd\n        if len(ymd) == 3:\n            return ymd\n        \"Type a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.\" ^0\n\ndef ask(prompt, low, high):\n    while True:\n        trim(input(prompt + \" (\" + str(low) + \" to \" + str(high) + \")> \")) => a\n        if a == \"\":\n            return -100000\n        a => t\n        1 => sg\n        if len(t) > 1 and t[0] == \"-\":\n            t[1:len(t)] => t\n            -1 => sg\n        digits(t) => n\n        if n >= 0 and len(t) <= 5 and sg * n >= low and sg * n <= high:\n            return sg * n\n        (\"Type a whole number from \" + str(low) + \" to \" + str(high) + \".\") ^0\n\ndef about():\n    ask_date(\"date\") => ymd\n    if len(ymd) == 0:\n        \"Cancelled.\" ^0\n        return\n    weeks.day_number(ymd[0], ymd[1], ymd[2]) => n\n    weeks.iso_week(n) => w\n    (text(ymd) + \" is a \" + NAMES[weeks.weekday(n)] + \".\") ^0\n    (\"ISO: week \" + str(w[1]) + \" of \" + str(w[0]) + \", day \" + str(w[2]) + \" - written \" + str(w[0]) + \"-W\" + two(w[1]) + \"-\" + str(w[2]) + \".\") ^0\n    if w[0] != ymd[0]:\n        (\"  The ISO year is \" + str(w[0]) + \", not \" + str(ymd[0]) + \": this week's Thursday falls in \" + str(w[0]) + \".\") ^0\n    (\"US (weeks from Sunday, week 1 holds 1 January): week \" + str(weeks.us_week(n)) + \".\") ^0\n    (\"Simple (week 1 is 1 to 7 January): week \" + str(weeks.simple_week(n)) + \".\") ^0\n\ndef months():\n    ask_date(\"date\") => ymd\n    if len(ymd) == 0:\n        \"Cancelled.\" ^0\n        return\n    ask(\"months to add, minus to go back\", -1200, 1200) => k\n    if k == -100000:\n        \"Cancelled.\" ^0\n        return\n    weeks.add_months(ymd[0], ymd[1], ymd[2], k, True) => c\n    if c[0] < 1601 or c[0] > 9999:\n        \"That lands outside 1601 to 9999.\" ^0\n        return\n    weeks.add_months(ymd[0], ymd[1], ymd[2], k, False) => o\n    (\"clamp:    \" + text(c) + \"   (a day past the month's end becomes its last day)\") ^0\n    (\"overflow: \" + text(o) + \"   (the extra days run on into the next month)\") ^0\n    if c == o:\n        \"Both rules agree: the day exists in that month.\" ^0\n    else:\n        weeks.add_months(c[0], c[1], c[2], 0 - k, True) => back\n        (\"And back again with clamp: \" + text(back) + \" - \" + [\"the same day.\", \"not where it started: clamping is not reversible.\"][back != ymd]) ^0\n\ndef stepping():\n    ask_date(\"date\") => ymd\n    if len(ymd) == 0:\n        \"Cancelled.\" ^0\n        return\n    ask(\"months\", 1, 24) => k\n    if k == -100000:\n        \"Cancelled.\" ^0\n        return\n    weeks.add_months(ymd[0], ymd[1], ymd[2], k, True) => once\n    if once[0] > 9999:\n        \"That lands after 9999.\" ^0\n        return\n    ymd => cur\n    \"\" => line\n    for i in [1:k]:\n        weeks.add_months(cur[0], cur[1], cur[2], 1, True) => cur\n        if i <= 12:\n            if line != \"\":\n                line + \", \" => line\n            line + text(cur) => line\n    if k > 12:\n        line + \", ...\" => line\n    (\"One month at a time: \" + line) ^0\n    (\"After \" + str(k) + \" steps: \" + text(cur) + \"; all \" + str(k) + \" months at once: \" + text(once) + \".\") ^0\n    if cur != once:\n        \"Stepping lost days at a short month and never got them back: count from the start date instead.\" ^0\n    else:\n        \"The same: no step had to clamp.\" ^0\n\ndef work():\n    ask_date(\"from\") => a\n    if len(a) == 0:\n        \"Cancelled.\" ^0\n        return\n    while True:\n        ask_date(\"to\") => b\n        if len(b) == 0:\n            \"Cancelled.\" ^0\n            return\n        weeks.day_number(a[0], a[1], a[2]) => na\n        weeks.day_number(b[0], b[1], b[2]) => nb\n        if nb >= na:\n            weeks.working_days(na, nb) => w\n            (\"From \" + text(a) + \" up to \" + text(b) + \": \" + str(nb - na) + \" days, \" + str(w) + \" of them Monday to Friday.\") ^0\n            return\n        \"The second date has to be the same or later.\" ^0\n\ndef add_work():\n    ask_date(\"start\") => a\n    if len(a) == 0:\n        \"Cancelled.\" ^0\n        return\n    ask(\"working days to add\", 1, 10000) => k\n    if k == -100000:\n        \"Cancelled.\" ^0\n        return\n    weeks.add_working(weeks.day_number(a[0], a[1], a[2]), k) => n\n    (str(k) + \" working days after \" + text(a) + \" is \" + text(weeks.date_of(n)) + \", a \" + NAMES[weeks.weekday(n)] + \".\") ^0\n\ndef long_years():\n    ask(\"from year\", 1600, 9999) => y1\n    if y1 == -100000:\n        \"Cancelled.\" ^0\n        return\n    ask(\"to year\", y1, 9999) => y2\n    if y2 == -100000:\n        \"Cancelled.\" ^0\n        return\n    if y1 == y2:\n        (str(y1) + \" has \" + str(weeks.iso_weeks_in(y1)) + \" ISO weeks.\") ^0\n        \"A year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday.\" ^0\n        return\n    \"\" => line\n    0 => count\n    for y in [y1:y2]:\n        if weeks.iso_weeks_in(y) == 53:\n            if count < 20:\n                if line != \"\":\n                    line + \" \" => line\n                line + str(y) => line\n            count + 1 => count\n    if count == 0:\n        (\"No year from \" + str(y1) + \" to \" + str(y2) + \" has 53 ISO weeks.\") ^0\n        return\n    if count > 20:\n        line + \" ...\" => line\n    (str(count) + \" of the \" + str(y2 - y1 + 1) + \" years have 53 ISO weeks: \" + line) ^0\n    \"A year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday.\" ^0\n\n\"== Weeks and months ==\" ^0\n\"Week numbers three ways, adding months two ways, and working days.\" ^0\nTrue => running\nwhile running:\n    \"\" ^0\n    \"1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\" ^0\n    trim(input(\"choice> \")) => choice\n    if choice == \"1\":\n        about()\n    elif choice == \"2\":\n        months()\n    elif choice == \"3\":\n        stepping()\n    elif choice == \"4\":\n        work()\n    elif choice == \"5\":\n        add_work()\n    elif choice == \"6\":\n        long_years()\n    elif choice == \"7\":\n        False => running\n    else:\n        \"Pick a number from 1 to 7.\" ^0\n\"Bye.\" ^0\n",
      "python": "import weeks\n\ndef trim(s):\n    i = 0\n    j = len(s)\n    while i < j and s[i] == \" \":\n        i = i + 1\n    while j > i and s[j - 1] == \" \":\n        j = j - 1\n    return s[i:j]\n\ndef digits(s):\n    if len(s) == 0:\n        return -1\n    n = 0\n    for c in s:\n        if not (c >= \"0\" and c <= \"9\"):\n            return -1\n        n = n * 10 + int(c)\n    return n\n\ndef two(n):\n    if n < 10:\n        return \"0\" + str(n)\n    return str(n)\n\ndef text(ymd):\n    return str(ymd[0]) + \"-\" + two(ymd[1]) + \"-\" + two(ymd[2])\n\nNAMES = [\"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\", \"Sunday\"]\n\ndef parse(s):\n    if len(s) != 10 or s[4] != \"-\" or s[7] != \"-\":\n        return []\n    y = digits(s[0:4])\n    m = digits(s[5:7])\n    d = digits(s[8:10])\n    if y < 1601 or m < 1 or m > 12 or d < 1:\n        return []\n    if d > weeks.days_in(y, m):\n        return []\n    return [y, m, d]\n\ndef ask_date(prompt):\n    while True:\n        s = trim(input(prompt + \" (YYYY-MM-DD)> \"))\n        if s == \"\":\n            return []\n        ymd = parse(s)\n        if len(ymd) == 3:\n            return ymd\n        print(\"Type a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.\")\n\ndef ask(prompt, low, high):\n    while True:\n        a = trim(input(prompt + \" (\" + str(low) + \" to \" + str(high) + \")> \"))\n        if a == \"\":\n            return -100000\n        t = a\n        sg = 1\n        if len(t) > 1 and t[0] == \"-\":\n            t = t[1:len(t)]\n            sg = -1\n        n = digits(t)\n        if n >= 0 and len(t) <= 5 and sg * n >= low and sg * n <= high:\n            return sg * n\n        print(\"Type a whole number from \" + str(low) + \" to \" + str(high) + \".\")\n\ndef about():\n    ymd = ask_date(\"date\")\n    if len(ymd) == 0:\n        print(\"Cancelled.\")\n        return\n    n = weeks.day_number(ymd[0], ymd[1], ymd[2])\n    w = weeks.iso_week(n)\n    print(text(ymd) + \" is a \" + NAMES[weeks.weekday(n)] + \".\")\n    print(\"ISO: week \" + str(w[1]) + \" of \" + str(w[0]) + \", day \" + str(w[2]) + \" - written \" + str(w[0]) + \"-W\" + two(w[1]) + \"-\" + str(w[2]) + \".\")\n    if w[0] != ymd[0]:\n        print(\"  The ISO year is \" + str(w[0]) + \", not \" + str(ymd[0]) + \": this week's Thursday falls in \" + str(w[0]) + \".\")\n    print(\"US (weeks from Sunday, week 1 holds 1 January): week \" + str(weeks.us_week(n)) + \".\")\n    print(\"Simple (week 1 is 1 to 7 January): week \" + str(weeks.simple_week(n)) + \".\")\n\ndef months():\n    ymd = ask_date(\"date\")\n    if len(ymd) == 0:\n        print(\"Cancelled.\")\n        return\n    k = ask(\"months to add, minus to go back\", -1200, 1200)\n    if k == -100000:\n        print(\"Cancelled.\")\n        return\n    c = weeks.add_months(ymd[0], ymd[1], ymd[2], k, True)\n    if c[0] < 1601 or c[0] > 9999:\n        print(\"That lands outside 1601 to 9999.\")\n        return\n    o = weeks.add_months(ymd[0], ymd[1], ymd[2], k, False)\n    print(\"clamp:    \" + text(c) + \"   (a day past the month's end becomes its last day)\")\n    print(\"overflow: \" + text(o) + \"   (the extra days run on into the next month)\")\n    if c == o:\n        print(\"Both rules agree: the day exists in that month.\")\n    else:\n        back = weeks.add_months(c[0], c[1], c[2], 0 - k, True)\n        print(\"And back again with clamp: \" + text(back) + \" - \" + [\"the same day.\", \"not where it started: clamping is not reversible.\"][back != ymd])\n\ndef stepping():\n    ymd = ask_date(\"date\")\n    if len(ymd) == 0:\n        print(\"Cancelled.\")\n        return\n    k = ask(\"months\", 1, 24)\n    if k == -100000:\n        print(\"Cancelled.\")\n        return\n    once = weeks.add_months(ymd[0], ymd[1], ymd[2], k, True)\n    if once[0] > 9999:\n        print(\"That lands after 9999.\")\n        return\n    cur = ymd\n    line = \"\"\n    for i in range(1, k+1):\n        cur = weeks.add_months(cur[0], cur[1], cur[2], 1, True)\n        if i <= 12:\n            if line != \"\":\n                line = line + \", \"\n            line = line + text(cur)\n    if k > 12:\n        line = line + \", ...\"\n    print(\"One month at a time: \" + line)\n    print(\"After \" + str(k) + \" steps: \" + text(cur) + \"; all \" + str(k) + \" months at once: \" + text(once) + \".\")\n    if cur != once:\n        print(\"Stepping lost days at a short month and never got them back: count from the start date instead.\")\n    else:\n        print(\"The same: no step had to clamp.\")\n\ndef work():\n    a = ask_date(\"from\")\n    if len(a) == 0:\n        print(\"Cancelled.\")\n        return\n    while True:\n        b = ask_date(\"to\")\n        if len(b) == 0:\n            print(\"Cancelled.\")\n            return\n        na = weeks.day_number(a[0], a[1], a[2])\n        nb = weeks.day_number(b[0], b[1], b[2])\n        if nb >= na:\n            w = weeks.working_days(na, nb)\n            print(\"From \" + text(a) + \" up to \" + text(b) + \": \" + str(nb - na) + \" days, \" + str(w) + \" of them Monday to Friday.\")\n            return\n        print(\"The second date has to be the same or later.\")\n\ndef add_work():\n    a = ask_date(\"start\")\n    if len(a) == 0:\n        print(\"Cancelled.\")\n        return\n    k = ask(\"working days to add\", 1, 10000)\n    if k == -100000:\n        print(\"Cancelled.\")\n        return\n    n = weeks.add_working(weeks.day_number(a[0], a[1], a[2]), k)\n    print(str(k) + \" working days after \" + text(a) + \" is \" + text(weeks.date_of(n)) + \", a \" + NAMES[weeks.weekday(n)] + \".\")\n\ndef long_years():\n    y1 = ask(\"from year\", 1600, 9999)\n    if y1 == -100000:\n        print(\"Cancelled.\")\n        return\n    y2 = ask(\"to year\", y1, 9999)\n    if y2 == -100000:\n        print(\"Cancelled.\")\n        return\n    if y1 == y2:\n        print(str(y1) + \" has \" + str(weeks.iso_weeks_in(y1)) + \" ISO weeks.\")\n        print(\"A year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday.\")\n        return\n    line = \"\"\n    count = 0\n    for y in range(y1, y2+1):\n        if weeks.iso_weeks_in(y) == 53:\n            if count < 20:\n                if line != \"\":\n                    line = line + \" \"\n                line = line + str(y)\n            count = count + 1\n    if count == 0:\n        print(\"No year from \" + str(y1) + \" to \" + str(y2) + \" has 53 ISO weeks.\")\n        return\n    if count > 20:\n        line = line + \" ...\"\n    print(str(count) + \" of the \" + str(y2 - y1 + 1) + \" years have 53 ISO weeks: \" + line)\n    print(\"A year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday.\")\n\nprint(\"== Weeks and months ==\")\nprint(\"Week numbers three ways, adding months two ways, and working days.\")\nrunning = True\nwhile running:\n    print(\"\")\n    print(\"1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\")\n    choice = trim(input(\"choice> \"))\n    if choice == \"1\":\n        about()\n    elif choice == \"2\":\n        months()\n    elif choice == \"3\":\n        stepping()\n    elif choice == \"4\":\n        work()\n    elif choice == \"5\":\n        add_work()\n    elif choice == \"6\":\n        long_years()\n    elif choice == \"7\":\n        running = False\n    else:\n        print(\"Pick a number from 1 to 7.\")\nprint(\"Bye.\")\n"
    },
    {
      "name": "weeks.eml",
      "eml": "# P087 weeks and months - dates as a day count, so weeks and months can be\n# reckoned exactly: ISO week numbers and two other conventions, adding\n# months by two rules, and working days. Days are counted from 1 January\n# 1600; dates from 1601 on (whose weeks all start on or after that day) to\n# 9999, in the Gregorian calendar throughout.\n\n[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] => DAYS\n\ndef is_leap(y):\n    if y % 400 == 0:\n        return True\n    if y % 100 == 0:\n        return False\n    return y % 4 == 0\n\ndef days_in(y, m):\n    if m == 2 and is_leap(y):\n        return 29\n    return DAYS[m - 1]\n\ndef quotient(a, b):\n    # a // b for a >= 0, b > 0. The numbers here stay far below 2^53, so the\n    # float division is exact; a % b is exact at any size.\n    return int((a - a % b) / b)\n\ndef day_number(y, m, d):\n    # Days since 1 January 1600, which was a Saturday.\n    y - 1600 => years\n    365 * years + quotient(years + 3, 4) - quotient(years + 99, 100) + quotient(years + 399, 400) => n\n    for k in [1:m - 1]:\n        n + days_in(y, k) => n\n    return n + d - 1\n\ndef date_of(n):\n    # The [y, m, d] of day number n (0 = 1 January 1600).\n    1600 => y\n    # whole 400-year cycles of 146097 days first, then year by year\n    while n >= 146097:\n        n - 146097 => n\n        y + 400 => y\n    while True:\n        365 => len_y\n        if is_leap(y):\n            366 => len_y\n        if n < len_y:\n            1 => m\n            while n >= days_in(y, m):\n                n - days_in(y, m) => n\n                m + 1 => m\n            return [y, m, n + 1]\n        n - len_y => n\n        y + 1 => y\n\ndef weekday(n):\n    # 0 Monday ... 6 Sunday; day 0 was a Saturday.\n    return (n + 5) % 7\n\ndef iso_week(n):\n    # The ISO week date [ISO year, week, weekday 1-7]: weeks start Monday,\n    # and a week belongs to the year that holds its Thursday - so week 1\n    # holds the first Thursday, and 1 January can be in the last week of the\n    # year before.\n    n - weekday(n) + 3 => th\n    date_of(th)[0] => y\n    th - day_number(y, 1, 1) => doy\n    return [y, quotient(doy, 7) + 1, weekday(n) + 1]\n\ndef us_week(n):\n    # Weeks start Sunday, and week 1 is the one holding 1 January.\n    date_of(n)[0] => y\n    day_number(y, 1, 1) => jan1\n    (weekday(jan1) + 1) % 7 => lead\n    return quotient(n - jan1 + lead, 7) + 1\n\ndef simple_week(n):\n    # Week 1 is 1 to 7 January, whatever day they fall on.\n    date_of(n)[0] => y\n    return quotient(n - day_number(y, 1, 1), 7) + 1\n\ndef iso_weeks_in(y):\n    # 53 when the year starts on a Thursday, or is a leap year starting on\n    # a Wednesday; otherwise 52. Read off 28 December, always in the last week.\n    return iso_week(day_number(y, 12, 28))[1]\n\ndef add_months(y, m, d, k, clamp):\n    # k months later (k may be negative). With clamp, a day past the end of\n    # the month becomes its last day (31 January + 1 = 28 or 29 February);\n    # without, the extra days run on into the next month (3 or 2 March).\n    m - 1 + k => t\n    y + quotient(t - t % 12, 12) => ny\n    t % 12 + 1 => nm\n    if d <= days_in(ny, nm):\n        return [ny, nm, d]\n    if clamp:\n        return [ny, nm, days_in(ny, nm)]\n    return date_of(day_number(ny, nm, 1) + d - 1)\n\ndef working_days(a, b):\n    # Monday to Friday from day a up to but not including day b (a <= b),\n    # by whole weeks and then day by day.\n    b - a => total\n    quotient(total, 7) * 5 => count\n    a + quotient(total, 7) * 7 => n\n    while n < b:\n        if weekday(n) < 5:\n            count + 1 => count\n        n + 1 => n\n    return count\n\ndef add_working(n, k):\n    # The day k working days after day n (k >= 1).\n    n => d\n    k => left\n    while left > 0:\n        d + 1 => d\n        if weekday(d) < 5:\n            left - 1 => left\n    return d\n",
      "python": "DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]\n\ndef is_leap(y):\n    if y % 400 == 0:\n        return True\n    if y % 100 == 0:\n        return False\n    return y % 4 == 0\n\ndef days_in(y, m):\n    if m == 2 and is_leap(y):\n        return 29\n    return DAYS[m - 1]\n\ndef quotient(a, b):\n    return int((a - a % b) / b)\n\ndef day_number(y, m, d):\n    years = y - 1600\n    n = 365 * years + quotient(years + 3, 4) - quotient(years + 99, 100) + quotient(years + 399, 400)\n    for k in range(1, m):\n        n = n + days_in(y, k)\n    return n + d - 1\n\ndef date_of(n):\n    y = 1600\n    while n >= 146097:\n        n = n - 146097\n        y = y + 400\n    while True:\n        len_y = 365\n        if is_leap(y):\n            len_y = 366\n        if n < len_y:\n            m = 1\n            while n >= days_in(y, m):\n                n = n - days_in(y, m)\n                m = m + 1\n            return [y, m, n + 1]\n        n = n - len_y\n        y = y + 1\n\ndef weekday(n):\n    return (n + 5) % 7\n\ndef iso_week(n):\n    th = n - weekday(n) + 3\n    y = date_of(th)[0]\n    doy = th - day_number(y, 1, 1)\n    return [y, quotient(doy, 7) + 1, weekday(n) + 1]\n\ndef us_week(n):\n    y = date_of(n)[0]\n    jan1 = day_number(y, 1, 1)\n    lead = (weekday(jan1) + 1) % 7\n    return quotient(n - jan1 + lead, 7) + 1\n\ndef simple_week(n):\n    y = date_of(n)[0]\n    return quotient(n - day_number(y, 1, 1), 7) + 1\n\ndef iso_weeks_in(y):\n    return iso_week(day_number(y, 12, 28))[1]\n\ndef add_months(y, m, d, k, clamp):\n    t = m - 1 + k\n    ny = y + quotient(t - t % 12, 12)\n    nm = t % 12 + 1\n    if d <= days_in(ny, nm):\n        return [ny, nm, d]\n    if clamp:\n        return [ny, nm, days_in(ny, nm)]\n    return date_of(day_number(ny, nm, 1) + d - 1)\n\ndef working_days(a, b):\n    total = b - a\n    count = quotient(total, 7) * 5\n    n = a + quotient(total, 7) * 7\n    while n < b:\n        if weekday(n) < 5:\n            count = count + 1\n        n = n + 1\n    return count\n\ndef add_working(n, k):\n    d = n\n    left = k\n    while left > 0:\n        d = d + 1\n        if weekday(d) < 5:\n            left = left - 1\n    return d\n"
    }
  ],
  "sessions": [
    {
      "name": "bad-input",
      "input": "0\nx\n1\n2026-02-30\n2026/10/15\n1600-12-31\n15-10-2026\n\n2\n2026-01-31\n1201\nx\n\n2\n1601-03-01\n-1200\n4\n2026-10-15\n2026-10-01\n\n5\n2026-10-15\n0\n\n6\n1599\n2026\n2025\n2026\n7\n",
      "screen": "== Weeks and months ==\nWeek numbers three ways, adding months two ways, and working days.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 0\nPick a number from 1 to 7.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> x\nPick a number from 1 to 7.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2026-02-30\nType a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.\ndate (YYYY-MM-DD)> 2026/10/15\nType a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.\ndate (YYYY-MM-DD)> 1600-12-31\nType a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.\ndate (YYYY-MM-DD)> 15-10-2026\nType a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.\ndate (YYYY-MM-DD)> \nCancelled.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 2\ndate (YYYY-MM-DD)> 2026-01-31\nmonths to add, minus to go back (-1200 to 1200)> 1201\nType a whole number from -1200 to 1200.\nmonths to add, minus to go back (-1200 to 1200)> x\nType a whole number from -1200 to 1200.\nmonths to add, minus to go back (-1200 to 1200)> \nCancelled.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 2\ndate (YYYY-MM-DD)> 1601-03-01\nmonths to add, minus to go back (-1200 to 1200)> -1200\nThat lands outside 1601 to 9999.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 4\nfrom (YYYY-MM-DD)> 2026-10-15\nto (YYYY-MM-DD)> 2026-10-01\nThe second date has to be the same or later.\nto (YYYY-MM-DD)> \nCancelled.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 5\nstart (YYYY-MM-DD)> 2026-10-15\nworking days to add (1 to 10000)> 0\nType a whole number from 1 to 10000.\nworking days to add (1 to 10000)> \nCancelled.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 6\nfrom year (1600 to 9999)> 1599\nType a whole number from 1600 to 9999.\nfrom year (1600 to 9999)> 2026\nto year (2026 to 9999)> 2025\nType a whole number from 2026 to 9999.\nto year (2026 to 9999)> 2026\n2026 has 53 ISO weeks.\nA year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 7\nBye.\n",
      "interpreter": "equal"
    },
    {
      "name": "basic",
      "input": "1\n2026-10-15\n1\n2027-01-01\n1\n2024-12-30\n2\n2026-01-31\n1\n2\n2024-01-31\n1\n2\n2026-03-15\n-2\n3\n2026-01-31\n12\n3\n2026-03-15\n6\n4\n2026-10-01\n2026-11-01\n5\n2026-10-15\n10\n6\n2000\n2030\n7\n",
      "screen": "== Weeks and months ==\nWeek numbers three ways, adding months two ways, and working days.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2026-10-15\n2026-10-15 is a Thursday.\nISO: week 42 of 2026, day 4 - written 2026-W42-4.\nUS (weeks from Sunday, week 1 holds 1 January): week 42.\nSimple (week 1 is 1 to 7 January): week 42.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2027-01-01\n2027-01-01 is a Friday.\nISO: week 53 of 2026, day 5 - written 2026-W53-5.\n  The ISO year is 2026, not 2027: this week's Thursday falls in 2026.\nUS (weeks from Sunday, week 1 holds 1 January): week 1.\nSimple (week 1 is 1 to 7 January): week 1.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2024-12-30\n2024-12-30 is a Monday.\nISO: week 1 of 2025, day 1 - written 2025-W01-1.\n  The ISO year is 2025, not 2024: this week's Thursday falls in 2025.\nUS (weeks from Sunday, week 1 holds 1 January): week 53.\nSimple (week 1 is 1 to 7 January): week 53.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 2\ndate (YYYY-MM-DD)> 2026-01-31\nmonths to add, minus to go back (-1200 to 1200)> 1\nclamp:    2026-02-28   (a day past the month's end becomes its last day)\noverflow: 2026-03-03   (the extra days run on into the next month)\nAnd back again with clamp: 2026-01-28 - not where it started: clamping is not reversible.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 2\ndate (YYYY-MM-DD)> 2024-01-31\nmonths to add, minus to go back (-1200 to 1200)> 1\nclamp:    2024-02-29   (a day past the month's end becomes its last day)\noverflow: 2024-03-02   (the extra days run on into the next month)\nAnd back again with clamp: 2024-01-29 - not where it started: clamping is not reversible.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 2\ndate (YYYY-MM-DD)> 2026-03-15\nmonths to add, minus to go back (-1200 to 1200)> -2\nclamp:    2026-01-15   (a day past the month's end becomes its last day)\noverflow: 2026-01-15   (the extra days run on into the next month)\nBoth rules agree: the day exists in that month.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 3\ndate (YYYY-MM-DD)> 2026-01-31\nmonths (1 to 24)> 12\nOne month at a time: 2026-02-28, 2026-03-28, 2026-04-28, 2026-05-28, 2026-06-28, 2026-07-28, 2026-08-28, 2026-09-28, 2026-10-28, 2026-11-28, 2026-12-28, 2027-01-28\nAfter 12 steps: 2027-01-28; all 12 months at once: 2027-01-31.\nStepping lost days at a short month and never got them back: count from the start date instead.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 3\ndate (YYYY-MM-DD)> 2026-03-15\nmonths (1 to 24)> 6\nOne month at a time: 2026-04-15, 2026-05-15, 2026-06-15, 2026-07-15, 2026-08-15, 2026-09-15\nAfter 6 steps: 2026-09-15; all 6 months at once: 2026-09-15.\nThe same: no step had to clamp.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 4\nfrom (YYYY-MM-DD)> 2026-10-01\nto (YYYY-MM-DD)> 2026-11-01\nFrom 2026-10-01 up to 2026-11-01: 31 days, 22 of them Monday to Friday.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 5\nstart (YYYY-MM-DD)> 2026-10-15\nworking days to add (1 to 10000)> 10\n10 working days after 2026-10-15 is 2026-10-29, a Thursday.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 6\nfrom year (1600 to 9999)> 2000\nto year (2000 to 9999)> 2030\n5 of the 31 years have 53 ISO weeks: 2004 2009 2015 2020 2026\nA year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday.\n\n1) about a date  2) add months  3) a month at a time  4) working days between  5) add working days  6) 53-week years  7) quit\nchoice> 7\nBye.\n",
      "interpreter": "equal"
    }
  ],
  "builtOn": [
    {
      "slug": "week-numbering-conventions",
      "caseId": "260-week-numbering-conventions",
      "title": "Week numbering conventions — \"week 1\" is at least three weeks"
    },
    {
      "slug": "month-end-arithmetic",
      "caseId": "254-month-end-arithmetic",
      "title": "Month-end arithmetic — two correct answers to \"renew monthly\""
    }
  ],
  "updated": "2026-10-15"
}
