<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P087-weeks-and-months/ | updated: 2026-10-15 -->

# P087 Weeks and months

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.

EML-P project `projects/weeks-and-months` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/weeks-and-months` runs it and `eml project verify projects/weeks-and-months` replays every session under CPython (two hash seeds) and in the interpreter; the site build replays every session in the interpreter again and publishes a session only if its screen matches.

Built on verified corpus cases: week-numbering-conventions (https://efficientnewlanguage.org/cases/260-week-numbering-conventions/), month-end-arithmetic (https://efficientnewlanguage.org/cases/254-month-end-arithmetic/).

## Sessions

### bad-input - interpreter: byte-equal to the golden

Input:

```text
0
x
1
2026-02-30
2026/10/15
1600-12-31
15-10-2026

2
2026-01-31
1201
x

2
1601-03-01
-1200
4
2026-10-15
2026-10-01

5
2026-10-15
0

6
1599
2026
2025
2026
7
```

Screen:

```text
== Weeks and months ==
Week numbers three ways, adding months two ways, and working days.

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
choice> 0
Pick a number from 1 to 7.

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
choice> x
Pick a number from 1 to 7.

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
choice> 1
date (YYYY-MM-DD)> 2026-02-30
Type a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.
date (YYYY-MM-DD)> 2026/10/15
Type a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.
date (YYYY-MM-DD)> 1600-12-31
Type a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.
date (YYYY-MM-DD)> 15-10-2026
Type a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.
date (YYYY-MM-DD)> 
Cancelled.

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
choice> 2
date (YYYY-MM-DD)> 2026-01-31
months to add, minus to go back (-1200 to 1200)> 1201
Type a whole number from -1200 to 1200.
months to add, minus to go back (-1200 to 1200)> x
Type a whole number from -1200 to 1200.
months to add, minus to go back (-1200 to 1200)> 
Cancelled.

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
choice> 2
date (YYYY-MM-DD)> 1601-03-01
months to add, minus to go back (-1200 to 1200)> -1200
That lands outside 1601 to 9999.

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
choice> 4
from (YYYY-MM-DD)> 2026-10-15
to (YYYY-MM-DD)> 2026-10-01
The second date has to be the same or later.
to (YYYY-MM-DD)> 
Cancelled.

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
choice> 5
start (YYYY-MM-DD)> 2026-10-15
working days to add (1 to 10000)> 0
Type a whole number from 1 to 10000.
working days to add (1 to 10000)> 
Cancelled.

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
choice> 6
from year (1600 to 9999)> 1599
Type a whole number from 1600 to 9999.
from year (1600 to 9999)> 2026
to year (2026 to 9999)> 2025
Type a whole number from 2026 to 9999.
to year (2026 to 9999)> 2026
2026 has 53 ISO weeks.
A year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday.

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
choice> 7
Bye.
```

### basic - interpreter: byte-equal to the golden

Input:

```text
1
2026-10-15
1
2027-01-01
1
2024-12-30
2
2026-01-31
1
2
2024-01-31
1
2
2026-03-15
-2
3
2026-01-31
12
3
2026-03-15
6
4
2026-10-01
2026-11-01
5
2026-10-15
10
6
2000
2030
7
```

Screen:

```text
== Weeks and months ==
Week numbers three ways, adding months two ways, and working days.

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
choice> 1
date (YYYY-MM-DD)> 2026-10-15
2026-10-15 is a Thursday.
ISO: week 42 of 2026, day 4 - written 2026-W42-4.
US (weeks from Sunday, week 1 holds 1 January): week 42.
Simple (week 1 is 1 to 7 January): week 42.

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
choice> 1
date (YYYY-MM-DD)> 2027-01-01
2027-01-01 is a Friday.
ISO: week 53 of 2026, day 5 - written 2026-W53-5.
  The ISO year is 2026, not 2027: this week's Thursday falls in 2026.
US (weeks from Sunday, week 1 holds 1 January): week 1.
Simple (week 1 is 1 to 7 January): week 1.

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
choice> 1
date (YYYY-MM-DD)> 2024-12-30
2024-12-30 is a Monday.
ISO: week 1 of 2025, day 1 - written 2025-W01-1.
  The ISO year is 2025, not 2024: this week's Thursday falls in 2025.
US (weeks from Sunday, week 1 holds 1 January): week 53.
Simple (week 1 is 1 to 7 January): week 53.

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
choice> 2
date (YYYY-MM-DD)> 2026-01-31
months to add, minus to go back (-1200 to 1200)> 1
clamp:    2026-02-28   (a day past the month's end becomes its last day)
overflow: 2026-03-03   (the extra days run on into the next month)
And back again with clamp: 2026-01-28 - not where it started: clamping is not reversible.

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
choice> 2
date (YYYY-MM-DD)> 2024-01-31
months to add, minus to go back (-1200 to 1200)> 1
clamp:    2024-02-29   (a day past the month's end becomes its last day)
overflow: 2024-03-02   (the extra days run on into the next month)
And back again with clamp: 2024-01-29 - not where it started: clamping is not reversible.

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
choice> 2
date (YYYY-MM-DD)> 2026-03-15
months to add, minus to go back (-1200 to 1200)> -2
clamp:    2026-01-15   (a day past the month's end becomes its last day)
overflow: 2026-01-15   (the extra days run on into the next month)
Both rules agree: the day exists in that month.

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
choice> 3
date (YYYY-MM-DD)> 2026-01-31
months (1 to 24)> 12
One 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
After 12 steps: 2027-01-28; all 12 months at once: 2027-01-31.
Stepping lost days at a short month and never got them back: count from the start date instead.

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
choice> 3
date (YYYY-MM-DD)> 2026-03-15
months (1 to 24)> 6
One month at a time: 2026-04-15, 2026-05-15, 2026-06-15, 2026-07-15, 2026-08-15, 2026-09-15
After 6 steps: 2026-09-15; all 6 months at once: 2026-09-15.
The same: no step had to clamp.

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
choice> 4
from (YYYY-MM-DD)> 2026-10-01
to (YYYY-MM-DD)> 2026-11-01
From 2026-10-01 up to 2026-11-01: 31 days, 22 of them Monday to Friday.

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
choice> 5
start (YYYY-MM-DD)> 2026-10-15
working days to add (1 to 10000)> 10
10 working days after 2026-10-15 is 2026-10-29, a Thursday.

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
choice> 6
from year (1600 to 9999)> 2000
to year (2000 to 9999)> 2030
5 of the 31 years have 53 ISO weeks: 2004 2009 2015 2020 2026
A year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday.

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
choice> 7
Bye.
```

## Modules

### main.eml

```eml
# P087 weeks and months: a date's ISO week and two other week numbers,
# adding months by the clamp and overflow rules, stepping a month at a time
# against adding all at once, working days, and years with 53 ISO weeks.
import weeks

def trim(s):
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def digits(s):
    if len(s) == 0:
        return -1
    0 => n
    for c in s:
        if not (c >= "0" and c <= "9"):
            return -1
        n * 10 + int(c) => n
    return n

def two(n):
    if n < 10:
        return "0" + str(n)
    return str(n)

def text(ymd):
    return str(ymd[0]) + "-" + two(ymd[1]) + "-" + two(ymd[2])

["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] => NAMES

def parse(s):
    # "2026-10-15" as [y, m, d] (1601-01-01 to 9999-12-31), or []. 1601 is the
    # first year whose ISO weeks all start on or after the day count's
    # first day, 1 January 1600.
    if len(s) != 10 or s[4] != "-" or s[7] != "-":
        return []
    digits(s[0:4]) => y
    digits(s[5:7]) => m
    digits(s[8:10]) => d
    if y < 1601 or m < 1 or m > 12 or d < 1:
        return []
    if d > weeks.days_in(y, m):
        return []
    return [y, m, d]

def ask_date(prompt):
    while True:
        trim(input(prompt + " (YYYY-MM-DD)> ")) => s
        if s == "":
            return []
        parse(s) => ymd
        if len(ymd) == 3:
            return ymd
        "Type a real date from 1601-01-01 to 9999-12-31, like 2026-10-15." ^0

def ask(prompt, low, high):
    while True:
        trim(input(prompt + " (" + str(low) + " to " + str(high) + ")> ")) => a
        if a == "":
            return -100000
        a => t
        1 => sg
        if len(t) > 1 and t[0] == "-":
            t[1:len(t)] => t
            -1 => sg
        digits(t) => n
        if n >= 0 and len(t) <= 5 and sg * n >= low and sg * n <= high:
            return sg * n
        ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0

def about():
    ask_date("date") => ymd
    if len(ymd) == 0:
        "Cancelled." ^0
        return
    weeks.day_number(ymd[0], ymd[1], ymd[2]) => n
    weeks.iso_week(n) => w
    (text(ymd) + " is a " + NAMES[weeks.weekday(n)] + ".") ^0
    ("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
    if w[0] != ymd[0]:
        ("  The ISO year is " + str(w[0]) + ", not " + str(ymd[0]) + ": this week's Thursday falls in " + str(w[0]) + ".") ^0
    ("US (weeks from Sunday, week 1 holds 1 January): week " + str(weeks.us_week(n)) + ".") ^0
    ("Simple (week 1 is 1 to 7 January): week " + str(weeks.simple_week(n)) + ".") ^0

def months():
    ask_date("date") => ymd
    if len(ymd) == 0:
        "Cancelled." ^0
        return
    ask("months to add, minus to go back", -1200, 1200) => k
    if k == -100000:
        "Cancelled." ^0
        return
    weeks.add_months(ymd[0], ymd[1], ymd[2], k, True) => c
    if c[0] < 1601 or c[0] > 9999:
        "That lands outside 1601 to 9999." ^0
        return
    weeks.add_months(ymd[0], ymd[1], ymd[2], k, False) => o
    ("clamp:    " + text(c) + "   (a day past the month's end becomes its last day)") ^0
    ("overflow: " + text(o) + "   (the extra days run on into the next month)") ^0
    if c == o:
        "Both rules agree: the day exists in that month." ^0
    else:
        weeks.add_months(c[0], c[1], c[2], 0 - k, True) => back
        ("And back again with clamp: " + text(back) + " - " + ["the same day.", "not where it started: clamping is not reversible."][back != ymd]) ^0

def stepping():
    ask_date("date") => ymd
    if len(ymd) == 0:
        "Cancelled." ^0
        return
    ask("months", 1, 24) => k
    if k == -100000:
        "Cancelled." ^0
        return
    weeks.add_months(ymd[0], ymd[1], ymd[2], k, True) => once
    if once[0] > 9999:
        "That lands after 9999." ^0
        return
    ymd => cur
    "" => line
    for i in [1:k]:
        weeks.add_months(cur[0], cur[1], cur[2], 1, True) => cur
        if i <= 12:
            if line != "":
                line + ", " => line
            line + text(cur) => line
    if k > 12:
        line + ", ..." => line
    ("One month at a time: " + line) ^0
    ("After " + str(k) + " steps: " + text(cur) + "; all " + str(k) + " months at once: " + text(once) + ".") ^0
    if cur != once:
        "Stepping lost days at a short month and never got them back: count from the start date instead." ^0
    else:
        "The same: no step had to clamp." ^0

def work():
    ask_date("from") => a
    if len(a) == 0:
        "Cancelled." ^0
        return
    while True:
        ask_date("to") => b
        if len(b) == 0:
            "Cancelled." ^0
            return
        weeks.day_number(a[0], a[1], a[2]) => na
        weeks.day_number(b[0], b[1], b[2]) => nb
        if nb >= na:
            weeks.working_days(na, nb) => w
            ("From " + text(a) + " up to " + text(b) + ": " + str(nb - na) + " days, " + str(w) + " of them Monday to Friday.") ^0
            return
        "The second date has to be the same or later." ^0

def add_work():
    ask_date("start") => a
    if len(a) == 0:
        "Cancelled." ^0
        return
    ask("working days to add", 1, 10000) => k
    if k == -100000:
        "Cancelled." ^0
        return
    weeks.add_working(weeks.day_number(a[0], a[1], a[2]), k) => n
    (str(k) + " working days after " + text(a) + " is " + text(weeks.date_of(n)) + ", a " + NAMES[weeks.weekday(n)] + ".") ^0

def long_years():
    ask("from year", 1600, 9999) => y1
    if y1 == -100000:
        "Cancelled." ^0
        return
    ask("to year", y1, 9999) => y2
    if y2 == -100000:
        "Cancelled." ^0
        return
    if y1 == y2:
        (str(y1) + " has " + str(weeks.iso_weeks_in(y1)) + " ISO weeks.") ^0
        "A year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday." ^0
        return
    "" => line
    0 => count
    for y in [y1:y2]:
        if weeks.iso_weeks_in(y) == 53:
            if count < 20:
                if line != "":
                    line + " " => line
                line + str(y) => line
            count + 1 => count
    if count == 0:
        ("No year from " + str(y1) + " to " + str(y2) + " has 53 ISO weeks.") ^0
        return
    if count > 20:
        line + " ..." => line
    (str(count) + " of the " + str(y2 - y1 + 1) + " years have 53 ISO weeks: " + line) ^0
    "A year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday." ^0

"== Weeks and months ==" ^0
"Week numbers three ways, adding months two ways, and working days." ^0
True => running
while running:
    "" ^0
    "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
    trim(input("choice> ")) => choice
    if choice == "1":
        about()
    elif choice == "2":
        months()
    elif choice == "3":
        stepping()
    elif choice == "4":
        work()
    elif choice == "5":
        add_work()
    elif choice == "6":
        long_years()
    elif choice == "7":
        False => running
    else:
        "Pick a number from 1 to 7." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import weeks

def trim(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def digits(s):
    if len(s) == 0:
        return -1
    n = 0
    for c in s:
        if not (c >= "0" and c <= "9"):
            return -1
        n = n * 10 + int(c)
    return n

def two(n):
    if n < 10:
        return "0" + str(n)
    return str(n)

def text(ymd):
    return str(ymd[0]) + "-" + two(ymd[1]) + "-" + two(ymd[2])

NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]

def parse(s):
    if len(s) != 10 or s[4] != "-" or s[7] != "-":
        return []
    y = digits(s[0:4])
    m = digits(s[5:7])
    d = digits(s[8:10])
    if y < 1601 or m < 1 or m > 12 or d < 1:
        return []
    if d > weeks.days_in(y, m):
        return []
    return [y, m, d]

def ask_date(prompt):
    while True:
        s = trim(input(prompt + " (YYYY-MM-DD)> "))
        if s == "":
            return []
        ymd = parse(s)
        if len(ymd) == 3:
            return ymd
        print("Type a real date from 1601-01-01 to 9999-12-31, like 2026-10-15.")

def ask(prompt, low, high):
    while True:
        a = trim(input(prompt + " (" + str(low) + " to " + str(high) + ")> "))
        if a == "":
            return -100000
        t = a
        sg = 1
        if len(t) > 1 and t[0] == "-":
            t = t[1:len(t)]
            sg = -1
        n = digits(t)
        if n >= 0 and len(t) <= 5 and sg * n >= low and sg * n <= high:
            return sg * n
        print("Type a whole number from " + str(low) + " to " + str(high) + ".")

def about():
    ymd = ask_date("date")
    if len(ymd) == 0:
        print("Cancelled.")
        return
    n = weeks.day_number(ymd[0], ymd[1], ymd[2])
    w = weeks.iso_week(n)
    print(text(ymd) + " is a " + NAMES[weeks.weekday(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]) + ".")
    if w[0] != ymd[0]:
        print("  The ISO year is " + str(w[0]) + ", not " + str(ymd[0]) + ": this week's Thursday falls in " + str(w[0]) + ".")
    print("US (weeks from Sunday, week 1 holds 1 January): week " + str(weeks.us_week(n)) + ".")
    print("Simple (week 1 is 1 to 7 January): week " + str(weeks.simple_week(n)) + ".")

def months():
    ymd = ask_date("date")
    if len(ymd) == 0:
        print("Cancelled.")
        return
    k = ask("months to add, minus to go back", -1200, 1200)
    if k == -100000:
        print("Cancelled.")
        return
    c = weeks.add_months(ymd[0], ymd[1], ymd[2], k, True)
    if c[0] < 1601 or c[0] > 9999:
        print("That lands outside 1601 to 9999.")
        return
    o = weeks.add_months(ymd[0], ymd[1], ymd[2], k, False)
    print("clamp:    " + text(c) + "   (a day past the month's end becomes its last day)")
    print("overflow: " + text(o) + "   (the extra days run on into the next month)")
    if c == o:
        print("Both rules agree: the day exists in that month.")
    else:
        back = weeks.add_months(c[0], c[1], c[2], 0 - k, True)
        print("And back again with clamp: " + text(back) + " - " + ["the same day.", "not where it started: clamping is not reversible."][back != ymd])

def stepping():
    ymd = ask_date("date")
    if len(ymd) == 0:
        print("Cancelled.")
        return
    k = ask("months", 1, 24)
    if k == -100000:
        print("Cancelled.")
        return
    once = weeks.add_months(ymd[0], ymd[1], ymd[2], k, True)
    if once[0] > 9999:
        print("That lands after 9999.")
        return
    cur = ymd
    line = ""
    for i in range(1, k+1):
        cur = weeks.add_months(cur[0], cur[1], cur[2], 1, True)
        if i <= 12:
            if line != "":
                line = line + ", "
            line = line + text(cur)
    if k > 12:
        line = line + ", ..."
    print("One month at a time: " + line)
    print("After " + str(k) + " steps: " + text(cur) + "; all " + str(k) + " months at once: " + text(once) + ".")
    if cur != once:
        print("Stepping lost days at a short month and never got them back: count from the start date instead.")
    else:
        print("The same: no step had to clamp.")

def work():
    a = ask_date("from")
    if len(a) == 0:
        print("Cancelled.")
        return
    while True:
        b = ask_date("to")
        if len(b) == 0:
            print("Cancelled.")
            return
        na = weeks.day_number(a[0], a[1], a[2])
        nb = weeks.day_number(b[0], b[1], b[2])
        if nb >= na:
            w = weeks.working_days(na, nb)
            print("From " + text(a) + " up to " + text(b) + ": " + str(nb - na) + " days, " + str(w) + " of them Monday to Friday.")
            return
        print("The second date has to be the same or later.")

def add_work():
    a = ask_date("start")
    if len(a) == 0:
        print("Cancelled.")
        return
    k = ask("working days to add", 1, 10000)
    if k == -100000:
        print("Cancelled.")
        return
    n = weeks.add_working(weeks.day_number(a[0], a[1], a[2]), k)
    print(str(k) + " working days after " + text(a) + " is " + text(weeks.date_of(n)) + ", a " + NAMES[weeks.weekday(n)] + ".")

def long_years():
    y1 = ask("from year", 1600, 9999)
    if y1 == -100000:
        print("Cancelled.")
        return
    y2 = ask("to year", y1, 9999)
    if y2 == -100000:
        print("Cancelled.")
        return
    if y1 == y2:
        print(str(y1) + " has " + str(weeks.iso_weeks_in(y1)) + " ISO weeks.")
        print("A year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday.")
        return
    line = ""
    count = 0
    for y in range(y1, y2+1):
        if weeks.iso_weeks_in(y) == 53:
            if count < 20:
                if line != "":
                    line = line + " "
                line = line + str(y)
            count = count + 1
    if count == 0:
        print("No year from " + str(y1) + " to " + str(y2) + " has 53 ISO weeks.")
        return
    if count > 20:
        line = line + " ..."
    print(str(count) + " of the " + str(y2 - y1 + 1) + " years have 53 ISO weeks: " + line)
    print("A year has 53 when it starts on a Thursday, or is a leap year starting on a Wednesday.")

print("== Weeks and months ==")
print("Week numbers three ways, adding months two ways, and working days.")
running = True
while running:
    print("")
    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")
    choice = trim(input("choice> "))
    if choice == "1":
        about()
    elif choice == "2":
        months()
    elif choice == "3":
        stepping()
    elif choice == "4":
        work()
    elif choice == "5":
        add_work()
    elif choice == "6":
        long_years()
    elif choice == "7":
        running = False
    else:
        print("Pick a number from 1 to 7.")
print("Bye.")
```

### weeks.eml

```eml
# P087 weeks and months - dates as a day count, so weeks and months can be
# reckoned exactly: ISO week numbers and two other conventions, adding
# months by two rules, and working days. Days are counted from 1 January
# 1600; dates from 1601 on (whose weeks all start on or after that day) to
# 9999, in the Gregorian calendar throughout.

[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] => DAYS

def is_leap(y):
    if y % 400 == 0:
        return True
    if y % 100 == 0:
        return False
    return y % 4 == 0

def days_in(y, m):
    if m == 2 and is_leap(y):
        return 29
    return DAYS[m - 1]

def quotient(a, b):
    # a // b for a >= 0, b > 0. The numbers here stay far below 2^53, so the
    # float division is exact; a % b is exact at any size.
    return int((a - a % b) / b)

def day_number(y, m, d):
    # Days since 1 January 1600, which was a Saturday.
    y - 1600 => years
    365 * years + quotient(years + 3, 4) - quotient(years + 99, 100) + quotient(years + 399, 400) => n
    for k in [1:m - 1]:
        n + days_in(y, k) => n
    return n + d - 1

def date_of(n):
    # The [y, m, d] of day number n (0 = 1 January 1600).
    1600 => y
    # whole 400-year cycles of 146097 days first, then year by year
    while n >= 146097:
        n - 146097 => n
        y + 400 => y
    while True:
        365 => len_y
        if is_leap(y):
            366 => len_y
        if n < len_y:
            1 => m
            while n >= days_in(y, m):
                n - days_in(y, m) => n
                m + 1 => m
            return [y, m, n + 1]
        n - len_y => n
        y + 1 => y

def weekday(n):
    # 0 Monday ... 6 Sunday; day 0 was a Saturday.
    return (n + 5) % 7

def iso_week(n):
    # The ISO week date [ISO year, week, weekday 1-7]: weeks start Monday,
    # and a week belongs to the year that holds its Thursday - so week 1
    # holds the first Thursday, and 1 January can be in the last week of the
    # year before.
    n - weekday(n) + 3 => th
    date_of(th)[0] => y
    th - day_number(y, 1, 1) => doy
    return [y, quotient(doy, 7) + 1, weekday(n) + 1]

def us_week(n):
    # Weeks start Sunday, and week 1 is the one holding 1 January.
    date_of(n)[0] => y
    day_number(y, 1, 1) => jan1
    (weekday(jan1) + 1) % 7 => lead
    return quotient(n - jan1 + lead, 7) + 1

def simple_week(n):
    # Week 1 is 1 to 7 January, whatever day they fall on.
    date_of(n)[0] => y
    return quotient(n - day_number(y, 1, 1), 7) + 1

def iso_weeks_in(y):
    # 53 when the year starts on a Thursday, or is a leap year starting on
    # a Wednesday; otherwise 52. Read off 28 December, always in the last week.
    return iso_week(day_number(y, 12, 28))[1]

def add_months(y, m, d, k, clamp):
    # k months later (k may be negative). With clamp, a day past the end of
    # the month becomes its last day (31 January + 1 = 28 or 29 February);
    # without, the extra days run on into the next month (3 or 2 March).
    m - 1 + k => t
    y + quotient(t - t % 12, 12) => ny
    t % 12 + 1 => nm
    if d <= days_in(ny, nm):
        return [ny, nm, d]
    if clamp:
        return [ny, nm, days_in(ny, nm)]
    return date_of(day_number(ny, nm, 1) + d - 1)

def working_days(a, b):
    # Monday to Friday from day a up to but not including day b (a <= b),
    # by whole weeks and then day by day.
    b - a => total
    quotient(total, 7) * 5 => count
    a + quotient(total, 7) * 7 => n
    while n < b:
        if weekday(n) < 5:
            count + 1 => count
        n + 1 => n
    return count

def add_working(n, k):
    # The day k working days after day n (k >= 1).
    n => d
    k => left
    while left > 0:
        d + 1 => d
        if weekday(d) < 5:
            left - 1 => left
    return d
```

Python projection of weeks.eml:

```python
DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

def is_leap(y):
    if y % 400 == 0:
        return True
    if y % 100 == 0:
        return False
    return y % 4 == 0

def days_in(y, m):
    if m == 2 and is_leap(y):
        return 29
    return DAYS[m - 1]

def quotient(a, b):
    return int((a - a % b) / b)

def day_number(y, m, d):
    years = y - 1600
    n = 365 * years + quotient(years + 3, 4) - quotient(years + 99, 100) + quotient(years + 399, 400)
    for k in range(1, m):
        n = n + days_in(y, k)
    return n + d - 1

def date_of(n):
    y = 1600
    while n >= 146097:
        n = n - 146097
        y = y + 400
    while True:
        len_y = 365
        if is_leap(y):
            len_y = 366
        if n < len_y:
            m = 1
            while n >= days_in(y, m):
                n = n - days_in(y, m)
                m = m + 1
            return [y, m, n + 1]
        n = n - len_y
        y = y + 1

def weekday(n):
    return (n + 5) % 7

def iso_week(n):
    th = n - weekday(n) + 3
    y = date_of(th)[0]
    doy = th - day_number(y, 1, 1)
    return [y, quotient(doy, 7) + 1, weekday(n) + 1]

def us_week(n):
    y = date_of(n)[0]
    jan1 = day_number(y, 1, 1)
    lead = (weekday(jan1) + 1) % 7
    return quotient(n - jan1 + lead, 7) + 1

def simple_week(n):
    y = date_of(n)[0]
    return quotient(n - day_number(y, 1, 1), 7) + 1

def iso_weeks_in(y):
    return iso_week(day_number(y, 12, 28))[1]

def add_months(y, m, d, k, clamp):
    t = m - 1 + k
    ny = y + quotient(t - t % 12, 12)
    nm = t % 12 + 1
    if d <= days_in(ny, nm):
        return [ny, nm, d]
    if clamp:
        return [ny, nm, days_in(ny, nm)]
    return date_of(day_number(ny, nm, 1) + d - 1)

def working_days(a, b):
    total = b - a
    count = quotient(total, 7) * 5
    n = a + quotient(total, 7) * 7
    while n < b:
        if weekday(n) < 5:
            count = count + 1
        n = n + 1
    return count

def add_working(n, k):
    d = n
    left = k
    while left > 0:
        d = d + 1
        if weekday(d) < 5:
            left = left - 1
    return d
```

## README

# P087 - Weeks and months

Dates as a day count, so that weeks and months can be reckoned exactly.
The program gives a date's ISO week date next to two other week
numbers. It adds months by the clamp and the overflow rules, compares
stepping a month at a time with adding all the months at once, counts
working days, and lists the years with 53 ISO weeks.

- `main.eml` - the menu, reading dates and numbers, and the reports
- `weeks.eml` - leap years, day numbers and back, weekdays, the three
  week numberings, adding months, and working days

How each part works:

- A date becomes the number of days since 1 January 1600, a Saturday:
  365 per year plus the leap days (every 4th year, not every 100th, but
  every 400th). Weekdays and differences are then plain arithmetic.
- Week numbers, as in the corpus case `week-numbering-conventions`, are at
  least three different things:
  - ISO: weeks start on Monday, and a week belongs to the year holding its
    Thursday. So 1 January can be in the last week of the year before -
    2027-01-01 is in week 53 of 2026 - and 30 December 2024 is in week 1
    of 2025.
  - US: weeks start on Sunday, and week 1 is the one holding 1 January.
  - Simple: week 1 is 1 to 7 January, whatever days they are.
- A year has 53 ISO weeks when it starts on a Thursday, or is a leap year
  starting on a Wednesday.
- Adding months, as in the corpus case `month-end-arithmetic`: 31 January
  plus one month has no answer. Clamping gives the month's last day,
  28 February; overflow runs on into March, 3 March. Clamping cannot be
  undone: 31 January + 1 - 1 is 28 January. Stepping a month at a time
  drifts to the 28th and stays there, while adding all the months at once
  from the start does not.
- Working days are Monday to Friday, counted by whole weeks and then day by
  day.

What is checked: menu choices 1 to 7; dates as YYYY-MM-DD, real ones from
1601-01-01 to 9999-12-31 (1601 is the first year whose weeks all start on
or after the day count's first day), and results inside that range; months
from -1200 to 1200, steps from 1 to 24, working days from 1 to 10,000,
years from 1600 to 9999; a second date no earlier than the first. An empty
answer cancels.

Sessions: `sessions/basic.in` gives:
- 2026-10-15 (week 42), 2027-01-01 (2026-W53) and 2024-12-30 (2025-W01);
- 31 January 2026 and 2024 plus one month by both rules, and back again;
  15 March minus two months, where the rules agree;
- twelve monthly steps from 31 January (2027-01-28 against 2027-01-31),
  and six from the 15th (no difference);
- 22 working days in October 2026; 10 working days after 2026-10-15;
- the five 53-week years from 2000 to 2030.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- 2026-02-30, slashes, 1600-12-31 and day-first order, then a cancel;
- 1201 and x months; 1200 months back from 1601, which lands outside;
- an end date before the start; 0 working days; year 1599; an end year
  before the start; and 2026 alone, which has 53.

Built on the verified corpus cases `week-numbering-conventions` (three
week numberings that disagree at the ends of the year) and
`month-end-arithmetic` (clamp against overflow, and what clamping loses).
