[{"data":1,"prerenderedAt":2243},["ShallowReactive",2],{"doc:\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-calendar-style-sheet-into-a-table":3,"surround:\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-calendar-style-sheet-into-a-table":2209,"ancestors:\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-calendar-style-sheet-into-a-table":2218,"children:\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-calendar-style-sheet-into-a-table":2223,"related:\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-calendar-style-sheet-into-a-table":2224},{"id":4,"title":5,"body":6,"dateModified":2176,"datePublished":2176,"description":2177,"extension":2178,"faq":2179,"keyword":2188,"kind":2189,"meta":2190,"navigation":107,"path":2198,"seo":2199,"slug":2202,"stem":2203,"topics":2204,"type":2207,"__hash__":2208},"docs\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-calendar-style-sheet-into-a-table\u002Findex.md","Read a Calendar-Style Sheet into a Table",{"type":7,"value":8,"toc":2164},"minimark",[9,19,24,53,59,63,66,315,325,329,332,725,728,953,957,960,1397,1408,1587,1591,1594,1788,1795,1924,1928,1931,2046,2053,2057,2092,2096,2099,2103,2109,2115,2121,2127,2131,2160],[10,11,12,13,18],"p",{},"Calendars are a natural layout for people and an awkward one for analysis. A team's on-call rota, a meeting-room booking sheet or a leave tracker is often built as a printed-calendar grid: the month name at the top, weekday headings across, and for each week a row of day numbers with a row of entries underneath — \"Ana\", \"Ben \u002F Chloé\", \"Holiday\". Two or three months sit one under another on the same sheet. Nobody can filter that by person or count days per person, and pandas reads it as a jumble of numbers and names. The information is all there, though: every entry sits under a day number, inside a month block. This page reads such a sheet with openpyxl, turns each block into dated rows, deals with the empty and padding cells calendars always have, and checks that every day of every month was captured. It belongs to ",[14,15,17],"a",{"href":16},"\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002F","Extracting Data from Messy Excel Sheets",".",[20,21,23],"h2",{"id":22},"prerequisites","Prerequisites",[25,26,31],"pre",{"className":27,"code":28,"language":29,"meta":30,"style":30},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install openpyxl pandas\n","bash","",[32,33,34],"code",{"__ignoreMap":30},[35,36,39,43,47,50],"span",{"class":37,"line":38},"line",1,[35,40,42],{"class":41},"sMTad","pip",[35,44,46],{"class":45},"srMev"," install",[35,48,49],{"class":45}," openpyxl",[35,51,52],{"class":45}," pandas\n",[10,54,55,58],{},[32,56,57],{},"oncall.xlsx"," holds an on-call rota for September and October 2026 on one sheet. Each month block has a bold title such as \"September 2026\", a row of weekday names, then for each week a row of day numbers and, underneath, the person on call — two names separated by \"\u002F\" at weekends. As in printed calendars, the first and last weeks show the neighbouring month's days in grey italics to fill the grid, with no entries under them. Two days have no entry yet, and one has a note in brackets.",[20,60,62],{"id":61},"step-1-find-the-month-blocks","Step 1 — Find the month blocks",[10,64,65],{},"Each block starts with a title cell that parses as a month and year. Scan column A for those, so the code does not depend on how many rows each month takes:",[25,67,71],{"className":68,"code":69,"language":70,"meta":30,"style":30},"language-python shiki shiki-themes github-light github-dark-high-contrast","from datetime import datetime\nfrom openpyxl import load_workbook\n\nwb = load_workbook(\"oncall.xlsx\")\nws = wb[\"On-call\"]\n\nblocks = []\nfor r in range(1, ws.max_row + 1):\n    value = ws.cell(r, 1).value\n    if isinstance(value, str):\n        try:\n            first = datetime.strptime(value.strip(), \"%B %Y\")\n        except ValueError:\n            continue\n        blocks.append({\"title_row\": r, \"year\": first.year, \"month\": first.month})\nfor b in blocks:\n    print(b)\n","python",[32,72,73,89,102,109,127,144,149,160,194,210,227,236,252,263,269,293,306],{"__ignoreMap":30},[35,74,75,79,83,86],{"class":37,"line":38},[35,76,78],{"class":77},"s-kum","from",[35,80,82],{"class":81},"skGVy"," datetime ",[35,84,85],{"class":77},"import",[35,87,88],{"class":81}," datetime\n",[35,90,92,94,97,99],{"class":37,"line":91},2,[35,93,78],{"class":77},[35,95,96],{"class":81}," openpyxl ",[35,98,85],{"class":77},[35,100,101],{"class":81}," load_workbook\n",[35,103,105],{"class":37,"line":104},3,[35,106,108],{"emptyLinePlaceholder":107},true,"\n",[35,110,112,115,118,121,124],{"class":37,"line":111},4,[35,113,114],{"class":81},"wb ",[35,116,117],{"class":77},"=",[35,119,120],{"class":81}," load_workbook(",[35,122,123],{"class":45},"\"oncall.xlsx\"",[35,125,126],{"class":81},")\n",[35,128,130,133,135,138,141],{"class":37,"line":129},5,[35,131,132],{"class":81},"ws ",[35,134,117],{"class":77},[35,136,137],{"class":81}," wb[",[35,139,140],{"class":45},"\"On-call\"",[35,142,143],{"class":81},"]\n",[35,145,147],{"class":37,"line":146},6,[35,148,108],{"emptyLinePlaceholder":107},[35,150,152,155,157],{"class":37,"line":151},7,[35,153,154],{"class":81},"blocks ",[35,156,117],{"class":77},[35,158,159],{"class":81}," []\n",[35,161,163,166,169,172,176,179,182,185,188,191],{"class":37,"line":162},8,[35,164,165],{"class":77},"for",[35,167,168],{"class":81}," r ",[35,170,171],{"class":77},"in",[35,173,175],{"class":174},"sP0c6"," range",[35,177,178],{"class":81},"(",[35,180,181],{"class":174},"1",[35,183,184],{"class":81},", ws.max_row ",[35,186,187],{"class":77},"+",[35,189,190],{"class":174}," 1",[35,192,193],{"class":81},"):\n",[35,195,197,200,202,205,207],{"class":37,"line":196},9,[35,198,199],{"class":81},"    value ",[35,201,117],{"class":77},[35,203,204],{"class":81}," ws.cell(r, ",[35,206,181],{"class":174},[35,208,209],{"class":81},").value\n",[35,211,213,216,219,222,225],{"class":37,"line":212},10,[35,214,215],{"class":77},"    if",[35,217,218],{"class":174}," isinstance",[35,220,221],{"class":81},"(value, ",[35,223,224],{"class":174},"str",[35,226,193],{"class":81},[35,228,230,233],{"class":37,"line":229},11,[35,231,232],{"class":77},"        try",[35,234,235],{"class":81},":\n",[35,237,239,242,244,247,250],{"class":37,"line":238},12,[35,240,241],{"class":81},"            first ",[35,243,117],{"class":77},[35,245,246],{"class":81}," datetime.strptime(value.strip(), ",[35,248,249],{"class":45},"\"%B %Y\"",[35,251,126],{"class":81},[35,253,255,258,261],{"class":37,"line":254},13,[35,256,257],{"class":77},"        except",[35,259,260],{"class":174}," ValueError",[35,262,235],{"class":81},[35,264,266],{"class":37,"line":265},14,[35,267,268],{"class":77},"            continue\n",[35,270,272,275,278,281,284,287,290],{"class":37,"line":271},15,[35,273,274],{"class":81},"        blocks.append({",[35,276,277],{"class":45},"\"title_row\"",[35,279,280],{"class":81},": r, ",[35,282,283],{"class":45},"\"year\"",[35,285,286],{"class":81},": first.year, ",[35,288,289],{"class":45},"\"month\"",[35,291,292],{"class":81},": first.month})\n",[35,294,296,298,301,303],{"class":37,"line":295},16,[35,297,165],{"class":77},[35,299,300],{"class":81}," b ",[35,302,171],{"class":77},[35,304,305],{"class":81}," blocks:\n",[35,307,309,312],{"class":37,"line":308},17,[35,310,311],{"class":174},"    print",[35,313,314],{"class":81},"(b)\n",[10,316,317,320,321,324],{},[32,318,319],{},"strptime"," with ",[32,322,323],{},"%B %Y"," turns \"September 2026\" into a date and rejects anything else, so person names, weekday headings and day numbers are ignored. If titles are written differently — \"Sep-26\", \"2026-09\" — add the formats to try. Locating blocks by their titles rather than by fixed row offsets is what keeps the reader working when a month needs six week rows instead of five, or when someone inserts a note row.",[20,326,328],{"id":327},"step-2-pair-day-numbers-with-entries","Step 2 — Pair day numbers with entries",[10,330,331],{},"Within a block, the row after the title holds weekday names; after that, rows alternate between day numbers and entries. Walk them in pairs until the next title or the end of the sheet:",[25,333,335],{"className":68,"code":334,"language":70,"meta":30,"style":30},"def read_block(ws, block, next_title_row):\n    records = []\n    r = block[\"title_row\"] + 2                          # skip title and weekday row\n    while r \u003C next_title_row:\n        days = [ws.cell(r, c) for c in range(1, 8)]\n        if not any(isinstance(d.value, int) for d in days):\n            r += 1\n            continue\n        entries = [ws.cell(r + 1, c).value for c in range(1, 8)]\n        for cell, entry in zip(days, entries):\n            records.append({\"year\": block[\"year\"], \"month\": block[\"month\"], \"row\": r,\n                            \"day\": cell.value, \"padding\": bool(cell.font and cell.font.italic), \"entry\": entry})\n        r += 2\n    return records\n\nbounds = [b[\"title_row\"] for b in blocks[1:]] + [ws.max_row + 1]\nraw = [rec for b, nxt in zip(blocks, bounds) for rec in read_block(ws, b, nxt)]\nprint(len(raw), \"day cells read;\", sum(rec[\"padding\"] for rec in raw), \"are padding from neighbouring months\")\n",[32,336,337,349,358,382,395,427,462,473,477,512,528,557,589,599,607,611,650,682],{"__ignoreMap":30},[35,338,339,342,346],{"class":37,"line":38},[35,340,341],{"class":77},"def",[35,343,345],{"class":344},"s_Opv"," read_block",[35,347,348],{"class":81},"(ws, block, next_title_row):\n",[35,350,351,354,356],{"class":37,"line":91},[35,352,353],{"class":81},"    records ",[35,355,117],{"class":77},[35,357,159],{"class":81},[35,359,360,363,365,368,370,373,375,378],{"class":37,"line":104},[35,361,362],{"class":81},"    r ",[35,364,117],{"class":77},[35,366,367],{"class":81}," block[",[35,369,277],{"class":45},[35,371,372],{"class":81},"] ",[35,374,187],{"class":77},[35,376,377],{"class":174}," 2",[35,379,381],{"class":380},"s-wDw","                          # skip title and weekday row\n",[35,383,384,387,389,392],{"class":37,"line":111},[35,385,386],{"class":77},"    while",[35,388,168],{"class":81},[35,390,391],{"class":77},"\u003C",[35,393,394],{"class":81}," next_title_row:\n",[35,396,397,400,402,405,407,410,412,414,416,418,421,424],{"class":37,"line":129},[35,398,399],{"class":81},"        days ",[35,401,117],{"class":77},[35,403,404],{"class":81}," [ws.cell(r, c) ",[35,406,165],{"class":77},[35,408,409],{"class":81}," c ",[35,411,171],{"class":77},[35,413,175],{"class":174},[35,415,178],{"class":81},[35,417,181],{"class":174},[35,419,420],{"class":81},", ",[35,422,423],{"class":174},"8",[35,425,426],{"class":81},")]\n",[35,428,429,432,435,438,440,443,446,449,452,454,457,459],{"class":37,"line":146},[35,430,431],{"class":77},"        if",[35,433,434],{"class":77}," not",[35,436,437],{"class":174}," any",[35,439,178],{"class":81},[35,441,442],{"class":174},"isinstance",[35,444,445],{"class":81},"(d.value, ",[35,447,448],{"class":174},"int",[35,450,451],{"class":81},") ",[35,453,165],{"class":77},[35,455,456],{"class":81}," d ",[35,458,171],{"class":77},[35,460,461],{"class":81}," days):\n",[35,463,464,467,470],{"class":37,"line":151},[35,465,466],{"class":81},"            r ",[35,468,469],{"class":77},"+=",[35,471,472],{"class":174}," 1\n",[35,474,475],{"class":37,"line":162},[35,476,268],{"class":77},[35,478,479,482,484,487,489,491,494,496,498,500,502,504,506,508,510],{"class":37,"line":196},[35,480,481],{"class":81},"        entries ",[35,483,117],{"class":77},[35,485,486],{"class":81}," [ws.cell(r ",[35,488,187],{"class":77},[35,490,190],{"class":174},[35,492,493],{"class":81},", c).value ",[35,495,165],{"class":77},[35,497,409],{"class":81},[35,499,171],{"class":77},[35,501,175],{"class":174},[35,503,178],{"class":81},[35,505,181],{"class":174},[35,507,420],{"class":81},[35,509,423],{"class":174},[35,511,426],{"class":81},[35,513,514,517,520,522,525],{"class":37,"line":212},[35,515,516],{"class":77},"        for",[35,518,519],{"class":81}," cell, entry ",[35,521,171],{"class":77},[35,523,524],{"class":174}," zip",[35,526,527],{"class":81},"(days, entries):\n",[35,529,530,533,535,538,540,543,545,547,549,551,554],{"class":37,"line":229},[35,531,532],{"class":81},"            records.append({",[35,534,283],{"class":45},[35,536,537],{"class":81},": block[",[35,539,283],{"class":45},[35,541,542],{"class":81},"], ",[35,544,289],{"class":45},[35,546,537],{"class":81},[35,548,289],{"class":45},[35,550,542],{"class":81},[35,552,553],{"class":45},"\"row\"",[35,555,556],{"class":81},": r,\n",[35,558,559,562,565,568,571,574,577,580,583,586],{"class":37,"line":238},[35,560,561],{"class":45},"                            \"day\"",[35,563,564],{"class":81},": cell.value, ",[35,566,567],{"class":45},"\"padding\"",[35,569,570],{"class":81},": ",[35,572,573],{"class":174},"bool",[35,575,576],{"class":81},"(cell.font ",[35,578,579],{"class":77},"and",[35,581,582],{"class":81}," cell.font.italic), ",[35,584,585],{"class":45},"\"entry\"",[35,587,588],{"class":81},": entry})\n",[35,590,591,594,596],{"class":37,"line":254},[35,592,593],{"class":81},"        r ",[35,595,469],{"class":77},[35,597,598],{"class":174}," 2\n",[35,600,601,604],{"class":37,"line":265},[35,602,603],{"class":77},"    return",[35,605,606],{"class":81}," records\n",[35,608,609],{"class":37,"line":271},[35,610,108],{"emptyLinePlaceholder":107},[35,612,613,616,618,621,623,625,627,629,631,634,636,639,641,644,646,648],{"class":37,"line":295},[35,614,615],{"class":81},"bounds ",[35,617,117],{"class":77},[35,619,620],{"class":81}," [b[",[35,622,277],{"class":45},[35,624,372],{"class":81},[35,626,165],{"class":77},[35,628,300],{"class":81},[35,630,171],{"class":77},[35,632,633],{"class":81}," blocks[",[35,635,181],{"class":174},[35,637,638],{"class":81},":]] ",[35,640,187],{"class":77},[35,642,643],{"class":81}," [ws.max_row ",[35,645,187],{"class":77},[35,647,190],{"class":174},[35,649,143],{"class":81},[35,651,652,655,657,660,662,665,667,669,672,674,677,679],{"class":37,"line":308},[35,653,654],{"class":81},"raw ",[35,656,117],{"class":77},[35,658,659],{"class":81}," [rec ",[35,661,165],{"class":77},[35,663,664],{"class":81}," b, nxt ",[35,666,171],{"class":77},[35,668,524],{"class":174},[35,670,671],{"class":81},"(blocks, bounds) ",[35,673,165],{"class":77},[35,675,676],{"class":81}," rec ",[35,678,171],{"class":77},[35,680,681],{"class":81}," read_block(ws, b, nxt)]\n",[35,683,685,688,690,693,696,699,701,704,707,709,711,713,715,717,720,723],{"class":37,"line":684},18,[35,686,687],{"class":174},"print",[35,689,178],{"class":81},[35,691,692],{"class":174},"len",[35,694,695],{"class":81},"(raw), ",[35,697,698],{"class":45},"\"day cells read;\"",[35,700,420],{"class":81},[35,702,703],{"class":174},"sum",[35,705,706],{"class":81},"(rec[",[35,708,567],{"class":45},[35,710,372],{"class":81},[35,712,165],{"class":77},[35,714,676],{"class":81},[35,716,171],{"class":77},[35,718,719],{"class":81}," raw), ",[35,721,722],{"class":45},"\"are padding from neighbouring months\"",[35,724,126],{"class":81},[10,726,727],{},"A row counts as a day-number row when it contains integers; the row directly below holds the entries for those days. Blank spacer rows are skipped. Each record keeps the block's month and year, the day number and — importantly — whether the day number was formatted as padding. That formatting is the only thing distinguishing \"1\" for 1 October from \"1\" for the next month shown at the end of September's grid. If your calendar has no such formatting, use position instead: in the first week, day numbers larger than 7 belong to the previous month; in the last weeks, small numbers belong to the next.",[729,730,738,739,738,743,738,747,738,755,738,764,738,768,738,775,738,779,738,783,738,787,738,791,738,795,738,799,738,803,738,807,738,811,738,815,738,819,738,823,738,827,738,831,738,836,738,840,738,842,738,845,738,847,738,850,738,852,738,855,738,857,738,860,738,862,738,865,738,867,738,870,738,873,738,877,738,879,738,883,738,885,738,888,738,890,738,892,738,894,738,897,738,899,738,902,738,904,738,907,738,909,738,912,738,915,738,918,738,920,738,924,738,928,738,930,738,933,738,935,738,938,738,940,738,943,738,945,738,948,738,950],"svg",{"viewBox":731,"role":732,"ariaLabelledBy":733,"xmlns":736,"style":737},"145 0 469 140","img",[734,735],"cal-grid-t","cal-grid-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:469px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[740,741,742],"title",{"id":734},"A week in the calendar layout",[744,745,746],"desc",{"id":735},"The first week of the September block shows Monday 31, a grey padding day from August, then Tuesday 1 to Sunday 6. Below each September day is the person on call; weekend days show two names. The padding day has no entry.",[748,749],"rect",{"x":750,"y":751,"width":752,"height":753,"fill":754},"145","0","469","140","#ffffff",[748,756],{"x":757,"y":758,"width":759,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"161.9","16.0","32.0","24.0","#e7ebef","var(--line,#cdd5e6)","stroke-width:1px",[748,765],{"x":766,"y":758,"width":767,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"193.9","47.9",[769,770,774],"text",{"x":771,"y":772,"style":773},"217.9","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[748,776],{"x":777,"y":758,"width":778,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"241.8","45.5",[769,780,782],{"x":781,"y":772,"style":773},"264.6","B",[748,784],{"x":785,"y":758,"width":786,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"287.3","49.0",[769,788,790],{"x":789,"y":772,"style":773},"311.8","C",[748,792],{"x":793,"y":758,"width":794,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"336.3","55.3",[769,796,798],{"x":797,"y":772,"style":773},"363.9","D",[748,800],{"x":801,"y":758,"width":802,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"391.6","45.1",[769,804,806],{"x":805,"y":772,"style":773},"414.2","E",[748,808],{"x":809,"y":758,"width":810,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"436.8","75.8",[769,812,814],{"x":813,"y":772,"style":773},"474.6","F",[748,816],{"x":817,"y":758,"width":818,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"512.5","85.6",[769,820,822],{"x":821,"y":772,"style":773},"555.3","G",[748,824],{"x":757,"y":825,"width":759,"height":826,"rx":751,"fill":761,"stroke":762,"style":763},"40.0","28.0",[769,828,181],{"x":829,"y":830,"style":773},"177.9","59.0",[748,832],{"x":766,"y":825,"width":767,"height":826,"rx":751,"fill":833,"stroke":834,"style":835},"#f0f4ff","var(--brand,#5b5cf0)","stroke-width:1.5px",[769,837,839],{"x":771,"y":830,"style":838},"font-size:10.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Mon",[748,841],{"x":777,"y":825,"width":778,"height":826,"rx":751,"fill":833,"stroke":834,"style":835},[769,843,844],{"x":781,"y":830,"style":838},"Tue",[748,846],{"x":785,"y":825,"width":786,"height":826,"rx":751,"fill":833,"stroke":834,"style":835},[769,848,849],{"x":789,"y":830,"style":838},"Wed",[748,851],{"x":793,"y":825,"width":794,"height":826,"rx":751,"fill":833,"stroke":834,"style":835},[769,853,854],{"x":797,"y":830,"style":838},"Thu",[748,856],{"x":801,"y":825,"width":802,"height":826,"rx":751,"fill":833,"stroke":834,"style":835},[769,858,859],{"x":805,"y":830,"style":838},"Fri",[748,861],{"x":809,"y":825,"width":810,"height":826,"rx":751,"fill":833,"stroke":834,"style":835},[769,863,864],{"x":813,"y":830,"style":838},"Sat",[748,866],{"x":817,"y":825,"width":818,"height":826,"rx":751,"fill":833,"stroke":834,"style":835},[769,868,869],{"x":821,"y":830,"style":838},"Sun",[748,871],{"x":757,"y":872,"width":759,"height":826,"rx":751,"fill":761,"stroke":762,"style":763},"68.0",[769,874,876],{"x":829,"y":875,"style":773},"87.0","2",[748,878],{"x":766,"y":872,"width":767,"height":826,"rx":751,"fill":761,"stroke":762,"style":835},[769,880,882],{"x":771,"y":875,"style":881},"font-size:10.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","31",[748,884],{"x":777,"y":872,"width":778,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,886,181],{"x":781,"y":875,"style":887},"font-size:10.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle",[748,889],{"x":785,"y":872,"width":786,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,891,876],{"x":789,"y":875,"style":887},[748,893],{"x":793,"y":872,"width":794,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,895,896],{"x":797,"y":875,"style":887},"3",[748,898],{"x":801,"y":872,"width":802,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,900,901],{"x":805,"y":875,"style":887},"4",[748,903],{"x":809,"y":872,"width":810,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,905,906],{"x":813,"y":875,"style":887},"5",[748,908],{"x":817,"y":872,"width":818,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,910,911],{"x":821,"y":875,"style":887},"6",[748,913],{"x":757,"y":914,"width":759,"height":826,"rx":751,"fill":761,"stroke":762,"style":763},"96.0",[769,916,896],{"x":829,"y":917,"style":773},"115.0",[748,919],{"x":766,"y":914,"width":767,"height":826,"rx":751,"fill":761,"stroke":762,"style":835},[748,921],{"x":777,"y":914,"width":778,"height":826,"rx":751,"fill":922,"stroke":923,"style":835},"#d9f4f1","var(--teal,#0f9488)",[769,925,927],{"x":781,"y":917,"style":926},"font-size:10.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Ana",[748,929],{"x":785,"y":914,"width":786,"height":826,"rx":751,"fill":922,"stroke":923,"style":835},[769,931,932],{"x":789,"y":917,"style":926},"Ben",[748,934],{"x":793,"y":914,"width":794,"height":826,"rx":751,"fill":922,"stroke":923,"style":835},[769,936,937],{"x":797,"y":917,"style":926},"Chloé",[748,939],{"x":801,"y":914,"width":802,"height":826,"rx":751,"fill":922,"stroke":923,"style":835},[769,941,942],{"x":805,"y":917,"style":926},"Dev",[748,944],{"x":809,"y":914,"width":810,"height":826,"rx":751,"fill":922,"stroke":923,"style":835},[769,946,947],{"x":813,"y":917,"style":926},"Ana \u002F Ben",[748,949],{"x":817,"y":914,"width":818,"height":826,"rx":751,"fill":922,"stroke":923,"style":835},[769,951,952],{"x":821,"y":917,"style":926},"Ben \u002F Chloé",[20,954,956],{"id":955},"step-3-build-real-dates-and-tidy-the-entries","Step 3 — Build real dates and tidy the entries",[10,958,959],{},"Drop the padding days, build a date for each remaining day, and split entries with several names into one row per person:",[25,961,963],{"className":68,"code":962,"language":70,"meta":30,"style":30},"import pandas as pd\nfrom datetime import date\n\ndays = pd.DataFrame([r for r in raw if not r[\"padding\"]])\ndays[\"date\"] = [date(y, m, d) for y, m, d in zip(days[\"year\"], days[\"month\"], days[\"day\"])]\ndays[\"entry\"] = days[\"entry\"].fillna(\"\").astype(str).str.strip()\ndays[\"note\"] = days[\"entry\"].str.extract(r\"\\(([^)]*)\\)\", expand=False)\ndays[\"people\"] = days[\"entry\"].str.replace(r\"\\s*\\([^)]*\\)\", \"\", regex=True).str.split(r\"\\s*\u002F\\s*\")\n\ntidy = days.explode(\"people\").rename(columns={\"people\": \"person\"})\ntidy[\"person\"] = tidy[\"person\"].replace(\"\", None)\ntidy = tidy[[\"date\", \"person\", \"note\"]].sort_values([\"date\", \"person\"], na_position=\"last\").reset_index(drop=True)\nprint(tidy.head(8).to_string(index=False))\nprint(tidy[tidy[\"note\"].notna()].to_string(index=False))\n",[32,964,965,978,989,993,1025,1067,1096,1160,1240,1244,1277,1305,1355,1377],{"__ignoreMap":30},[35,966,967,969,972,975],{"class":37,"line":38},[35,968,85],{"class":77},[35,970,971],{"class":81}," pandas ",[35,973,974],{"class":77},"as",[35,976,977],{"class":81}," pd\n",[35,979,980,982,984,986],{"class":37,"line":91},[35,981,78],{"class":77},[35,983,82],{"class":81},[35,985,85],{"class":77},[35,987,988],{"class":81}," date\n",[35,990,991],{"class":37,"line":104},[35,992,108],{"emptyLinePlaceholder":107},[35,994,995,998,1000,1003,1005,1007,1009,1012,1015,1017,1020,1022],{"class":37,"line":111},[35,996,997],{"class":81},"days ",[35,999,117],{"class":77},[35,1001,1002],{"class":81}," pd.DataFrame([r ",[35,1004,165],{"class":77},[35,1006,168],{"class":81},[35,1008,171],{"class":77},[35,1010,1011],{"class":81}," raw ",[35,1013,1014],{"class":77},"if",[35,1016,434],{"class":77},[35,1018,1019],{"class":81}," r[",[35,1021,567],{"class":45},[35,1023,1024],{"class":81},"]])\n",[35,1026,1027,1030,1033,1035,1037,1040,1042,1045,1047,1049,1052,1054,1057,1059,1061,1064],{"class":37,"line":129},[35,1028,1029],{"class":81},"days[",[35,1031,1032],{"class":45},"\"date\"",[35,1034,372],{"class":81},[35,1036,117],{"class":77},[35,1038,1039],{"class":81}," [date(y, m, d) ",[35,1041,165],{"class":77},[35,1043,1044],{"class":81}," y, m, d ",[35,1046,171],{"class":77},[35,1048,524],{"class":174},[35,1050,1051],{"class":81},"(days[",[35,1053,283],{"class":45},[35,1055,1056],{"class":81},"], days[",[35,1058,289],{"class":45},[35,1060,1056],{"class":81},[35,1062,1063],{"class":45},"\"day\"",[35,1065,1066],{"class":81},"])]\n",[35,1068,1069,1071,1073,1075,1077,1080,1082,1085,1088,1091,1093],{"class":37,"line":146},[35,1070,1029],{"class":81},[35,1072,585],{"class":45},[35,1074,372],{"class":81},[35,1076,117],{"class":77},[35,1078,1079],{"class":81}," days[",[35,1081,585],{"class":45},[35,1083,1084],{"class":81},"].fillna(",[35,1086,1087],{"class":45},"\"\"",[35,1089,1090],{"class":81},").astype(",[35,1092,224],{"class":174},[35,1094,1095],{"class":81},").str.strip()\n",[35,1097,1098,1100,1103,1105,1107,1109,1111,1114,1117,1120,1124,1127,1130,1134,1137,1140,1142,1145,1147,1149,1153,1155,1158],{"class":37,"line":151},[35,1099,1029],{"class":81},[35,1101,1102],{"class":45},"\"note\"",[35,1104,372],{"class":81},[35,1106,117],{"class":77},[35,1108,1079],{"class":81},[35,1110,585],{"class":45},[35,1112,1113],{"class":81},"].str.extract(",[35,1115,1116],{"class":77},"r",[35,1118,1119],{"class":45},"\"",[35,1121,1123],{"class":1122},"s_b0D","\\(",[35,1125,1126],{"class":174},"([",[35,1128,1129],{"class":77},"^",[35,1131,1133],{"class":1132},"sSjpA",")",[35,1135,1136],{"class":174},"]",[35,1138,1139],{"class":77},"*",[35,1141,1133],{"class":174},[35,1143,1144],{"class":1122},"\\)",[35,1146,1119],{"class":45},[35,1148,420],{"class":81},[35,1150,1152],{"class":1151},"sa561","expand",[35,1154,117],{"class":77},[35,1156,1157],{"class":174},"False",[35,1159,126],{"class":81},[35,1161,1162,1164,1167,1169,1171,1173,1175,1178,1180,1182,1185,1187,1189,1192,1194,1196,1198,1200,1202,1204,1206,1208,1210,1213,1215,1218,1221,1223,1225,1227,1229,1232,1234,1236,1238],{"class":37,"line":162},[35,1163,1029],{"class":81},[35,1165,1166],{"class":45},"\"people\"",[35,1168,372],{"class":81},[35,1170,117],{"class":77},[35,1172,1079],{"class":81},[35,1174,585],{"class":45},[35,1176,1177],{"class":81},"].str.replace(",[35,1179,1116],{"class":77},[35,1181,1119],{"class":45},[35,1183,1184],{"class":174},"\\s",[35,1186,1139],{"class":77},[35,1188,1123],{"class":1122},[35,1190,1191],{"class":174},"[",[35,1193,1129],{"class":77},[35,1195,1133],{"class":1132},[35,1197,1136],{"class":174},[35,1199,1139],{"class":77},[35,1201,1144],{"class":1122},[35,1203,1119],{"class":45},[35,1205,420],{"class":81},[35,1207,1087],{"class":45},[35,1209,420],{"class":81},[35,1211,1212],{"class":1151},"regex",[35,1214,117],{"class":77},[35,1216,1217],{"class":174},"True",[35,1219,1220],{"class":81},").str.split(",[35,1222,1116],{"class":77},[35,1224,1119],{"class":45},[35,1226,1184],{"class":174},[35,1228,1139],{"class":77},[35,1230,1231],{"class":45},"\u002F",[35,1233,1184],{"class":174},[35,1235,1139],{"class":77},[35,1237,1119],{"class":45},[35,1239,126],{"class":81},[35,1241,1242],{"class":37,"line":196},[35,1243,108],{"emptyLinePlaceholder":107},[35,1245,1246,1249,1251,1254,1256,1259,1262,1264,1267,1269,1271,1274],{"class":37,"line":212},[35,1247,1248],{"class":81},"tidy ",[35,1250,117],{"class":77},[35,1252,1253],{"class":81}," days.explode(",[35,1255,1166],{"class":45},[35,1257,1258],{"class":81},").rename(",[35,1260,1261],{"class":1151},"columns",[35,1263,117],{"class":77},[35,1265,1266],{"class":81},"{",[35,1268,1166],{"class":45},[35,1270,570],{"class":81},[35,1272,1273],{"class":45},"\"person\"",[35,1275,1276],{"class":81},"})\n",[35,1278,1279,1282,1284,1286,1288,1291,1293,1296,1298,1300,1303],{"class":37,"line":229},[35,1280,1281],{"class":81},"tidy[",[35,1283,1273],{"class":45},[35,1285,372],{"class":81},[35,1287,117],{"class":77},[35,1289,1290],{"class":81}," tidy[",[35,1292,1273],{"class":45},[35,1294,1295],{"class":81},"].replace(",[35,1297,1087],{"class":45},[35,1299,420],{"class":81},[35,1301,1302],{"class":174},"None",[35,1304,126],{"class":81},[35,1306,1307,1309,1311,1314,1316,1318,1320,1322,1324,1327,1329,1331,1333,1335,1338,1340,1343,1346,1349,1351,1353],{"class":37,"line":238},[35,1308,1248],{"class":81},[35,1310,117],{"class":77},[35,1312,1313],{"class":81}," tidy[[",[35,1315,1032],{"class":45},[35,1317,420],{"class":81},[35,1319,1273],{"class":45},[35,1321,420],{"class":81},[35,1323,1102],{"class":45},[35,1325,1326],{"class":81},"]].sort_values([",[35,1328,1032],{"class":45},[35,1330,420],{"class":81},[35,1332,1273],{"class":45},[35,1334,542],{"class":81},[35,1336,1337],{"class":1151},"na_position",[35,1339,117],{"class":77},[35,1341,1342],{"class":45},"\"last\"",[35,1344,1345],{"class":81},").reset_index(",[35,1347,1348],{"class":1151},"drop",[35,1350,117],{"class":77},[35,1352,1217],{"class":174},[35,1354,126],{"class":81},[35,1356,1357,1359,1362,1364,1367,1370,1372,1374],{"class":37,"line":254},[35,1358,687],{"class":174},[35,1360,1361],{"class":81},"(tidy.head(",[35,1363,423],{"class":174},[35,1365,1366],{"class":81},").to_string(",[35,1368,1369],{"class":1151},"index",[35,1371,117],{"class":77},[35,1373,1157],{"class":174},[35,1375,1376],{"class":81},"))\n",[35,1378,1379,1381,1384,1386,1389,1391,1393,1395],{"class":37,"line":265},[35,1380,687],{"class":174},[35,1382,1383],{"class":81},"(tidy[tidy[",[35,1385,1102],{"class":45},[35,1387,1388],{"class":81},"].notna()].to_string(",[35,1390,1369],{"class":1151},[35,1392,117],{"class":77},[35,1394,1157],{"class":174},[35,1396,1376],{"class":81},[10,1398,1399,1400,1403,1404,18],{},"Each date is built from the block's year and month and the cell's day number, so it is a real date that sorts, filters and groups properly. Notes in brackets — \"(swap with Ana)\" — are extracted into their own column and removed from the name; weekend entries with two names separated by \"\u002F\" become two rows with the same date via ",[32,1401,1402],{},"explode",". Days with no entry keep a row with an empty person, so the gap is visible rather than missing. Splitting delimited cells into rows is covered in ",[14,1405,1407],{"href":1406},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fsplit-delimited-cell-values-into-rows-with-pandas\u002F","Split Delimited Cell Values into Rows with pandas",[729,1409,738,1415,738,1418,738,1421,738,1425,738,1428,738,1432,738,1436,738,1440,738,1443,738,1446,738,1450,738,1452,738,1455,738,1457,738,1460,738,1463,738,1466,738,1468,738,1471,738,1473,738,1475,738,1478,738,1481,738,1483,738,1486,738,1488,738,1491,738,1495,738,1499,738,1503,738,1507,738,1511,738,1515,738,1517,738,1520,738,1522,738,1524,738,1526,738,1528,738,1530,738,1532,738,1534,738,1536,738,1539,738,1543,738,1545,738,1547,738,1549,738,1552,738,1558,738,1562,738,1568,738,1573,738,1581],{"viewBox":1410,"role":732,"ariaLabelledBy":1411,"xmlns":736,"style":1414},"4 0 752 168",[1412,1413],"cal-tidy-t","cal-tidy-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[740,1416,1417],{"id":1412},"Calendar cells become one row per person per date",[744,1419,1420],{"id":1413},"The Saturday cell for 5 September contains Ana and Ben separated by a slash, and 21 October contains Ben with a note in brackets. They become dated rows with one person each and the note in its own column.",[748,1422],{"x":901,"y":751,"width":1423,"height":1424,"fill":754},"752","168",[748,1426],{"x":1427,"y":825,"width":759,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"20.0",[748,1429],{"x":1430,"y":825,"width":1431,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"52.0","73.1",[769,1433,774],{"x":1434,"y":1435,"style":773},"88.5","57.0",[748,1437],{"x":1438,"y":825,"width":1439,"height":760,"rx":751,"fill":761,"stroke":762,"style":763},"125.1","115.8",[769,1441,782],{"x":1442,"y":1435,"style":773},"183.0",[748,1444],{"x":1427,"y":1445,"width":759,"height":826,"rx":751,"fill":761,"stroke":762,"style":763},"64.0",[769,1447,181],{"x":1448,"y":1449,"style":773},"36.0","83.0",[748,1451],{"x":1430,"y":1445,"width":1431,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,1453,1454],{"x":1434,"y":1449,"style":881},"day",[748,1456],{"x":1438,"y":1445,"width":1439,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,1458,1459],{"x":1442,"y":1449,"style":881},"entry",[748,1461],{"x":1427,"y":1462,"width":759,"height":826,"rx":751,"fill":761,"stroke":762,"style":763},"92.0",[769,1464,876],{"x":1448,"y":1465,"style":773},"111.0",[748,1467],{"x":1430,"y":1462,"width":1431,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,1469,1470],{"x":1434,"y":1465,"style":887},"Sat 5 Sep",[748,1472],{"x":1438,"y":1462,"width":1439,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,1474,947],{"x":1442,"y":1465,"style":887},[748,1476],{"x":1427,"y":1477,"width":759,"height":826,"rx":751,"fill":761,"stroke":762,"style":763},"120.0",[769,1479,896],{"x":1448,"y":1480,"style":773},"139.0",[748,1482],{"x":1430,"y":1477,"width":1431,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,1484,1485],{"x":1434,"y":1480,"style":887},"Wed 21 Oct",[748,1487],{"x":1438,"y":1477,"width":1439,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,1489,1490],{"x":1442,"y":1480,"style":887},"Ben (swap with Ana)",[748,1492],{"x":1493,"y":825,"width":1494,"height":826,"rx":751,"fill":761,"stroke":762,"style":835},"514.8","80.5",[769,1496,1498],{"x":1497,"y":830,"style":881},"555.1","date",[748,1500],{"x":1501,"y":825,"width":1502,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},"595.3","54.3",[769,1504,1506],{"x":1505,"y":830,"style":881},"622.5","person",[748,1508],{"x":1509,"y":825,"width":1510,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},"649.6","90.4",[769,1512,1514],{"x":1513,"y":830,"style":881},"694.8","note",[748,1516],{"x":1493,"y":872,"width":1494,"height":826,"rx":751,"fill":922,"stroke":923,"style":835},[769,1518,1519],{"x":1497,"y":875,"style":926},"2026-09-05",[748,1521],{"x":1501,"y":872,"width":1502,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,1523,927],{"x":1505,"y":875,"style":887},[748,1525],{"x":1509,"y":872,"width":1510,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[748,1527],{"x":1493,"y":914,"width":1494,"height":826,"rx":751,"fill":922,"stroke":923,"style":835},[769,1529,1519],{"x":1497,"y":917,"style":926},[748,1531],{"x":1501,"y":914,"width":1502,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,1533,932],{"x":1505,"y":917,"style":887},[748,1535],{"x":1509,"y":914,"width":1510,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[748,1537],{"x":1493,"y":1538,"width":1494,"height":826,"rx":751,"fill":922,"stroke":923,"style":835},"124.0",[769,1540,1542],{"x":1497,"y":1541,"style":926},"143.0","2026-10-21",[748,1544],{"x":1501,"y":1538,"width":1502,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,1546,932],{"x":1505,"y":1541,"style":887},[748,1548],{"x":1509,"y":1538,"width":1510,"height":826,"rx":751,"fill":754,"stroke":762,"style":763},[769,1550,1551],{"x":1513,"y":1541,"style":887},"swap with Ana",[769,1553,1557],{"x":1554,"y":1555,"style":1556},"130.4","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","calendar cells",[769,1559,1561],{"x":1560,"y":1555,"style":1556},"627.4","tidy rows",[37,1563],{"x1":1564,"y1":1565,"x2":1566,"y2":1565,"stroke":834,"style":1567},"250.8","94.0","500.8","stroke-width:2px",[1569,1570],"polygon",{"points":1571,"fill":1572},"504.8,94.0 495.8,99.0 495.8,89.0","#5b5cf0",[748,1574],{"x":1575,"y":1576,"width":1577,"height":1578,"rx":1579,"fill":754,"stroke":762,"style":1580},"284.2","61.0","187.2","22.0","11","stroke-width:1.2px",[769,1582,1586],{"x":1583,"y":1584,"style":1585},"377.8","76.0","font-size:11.5px;font-weight:400;fill:var(--brand-strong,#4338ca);text-anchor:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace","dates + split + explode",[20,1588,1590],{"id":1589},"step-4-check-every-day-was-read","Step 4 — Check every day was read",[10,1592,1593],{},"A calendar reader is easy to get subtly wrong — a skipped week, a padding day taken for a real one. Compare the dates read with the full set of dates in each month:",[25,1595,1597],{"className":68,"code":1596,"language":70,"meta":30,"style":30},"import calendar\n\nexpected = {date(b[\"year\"], b[\"month\"], d) for b in blocks\n            for d in range(1, calendar.monthrange(b[\"year\"], b[\"month\"])[1] + 1)}\nfound = set(days[\"date\"])\nprint(\"missing days:\", sorted(expected - found) or \"none\", \"| unexpected days:\", sorted(found - expected) or \"none\")\nprint(\"days with no one on call:\", sorted(d.isoformat() for d in days.loc[days[\"entry\"] == \"\", \"date\"]))\n",[32,1598,1599,1606,1610,1639,1677,1694,1746],{"__ignoreMap":30},[35,1600,1601,1603],{"class":37,"line":38},[35,1602,85],{"class":77},[35,1604,1605],{"class":81}," calendar\n",[35,1607,1608],{"class":37,"line":91},[35,1609,108],{"emptyLinePlaceholder":107},[35,1611,1612,1615,1617,1620,1622,1625,1627,1630,1632,1634,1636],{"class":37,"line":104},[35,1613,1614],{"class":81},"expected ",[35,1616,117],{"class":77},[35,1618,1619],{"class":81}," {date(b[",[35,1621,283],{"class":45},[35,1623,1624],{"class":81},"], b[",[35,1626,289],{"class":45},[35,1628,1629],{"class":81},"], d) ",[35,1631,165],{"class":77},[35,1633,300],{"class":81},[35,1635,171],{"class":77},[35,1637,1638],{"class":81}," blocks\n",[35,1640,1641,1644,1646,1648,1650,1652,1654,1657,1659,1661,1663,1666,1668,1670,1672,1674],{"class":37,"line":111},[35,1642,1643],{"class":77},"            for",[35,1645,456],{"class":81},[35,1647,171],{"class":77},[35,1649,175],{"class":174},[35,1651,178],{"class":81},[35,1653,181],{"class":174},[35,1655,1656],{"class":81},", calendar.monthrange(b[",[35,1658,283],{"class":45},[35,1660,1624],{"class":81},[35,1662,289],{"class":45},[35,1664,1665],{"class":81},"])[",[35,1667,181],{"class":174},[35,1669,372],{"class":81},[35,1671,187],{"class":77},[35,1673,190],{"class":174},[35,1675,1676],{"class":81},")}\n",[35,1678,1679,1682,1684,1687,1689,1691],{"class":37,"line":129},[35,1680,1681],{"class":81},"found ",[35,1683,117],{"class":77},[35,1685,1686],{"class":174}," set",[35,1688,1051],{"class":81},[35,1690,1032],{"class":45},[35,1692,1693],{"class":81},"])\n",[35,1695,1696,1698,1700,1703,1705,1708,1711,1714,1717,1720,1723,1725,1728,1730,1732,1735,1737,1740,1742,1744],{"class":37,"line":146},[35,1697,687],{"class":174},[35,1699,178],{"class":81},[35,1701,1702],{"class":45},"\"missing days:\"",[35,1704,420],{"class":81},[35,1706,1707],{"class":174},"sorted",[35,1709,1710],{"class":81},"(expected ",[35,1712,1713],{"class":77},"-",[35,1715,1716],{"class":81}," found) ",[35,1718,1719],{"class":77},"or",[35,1721,1722],{"class":45}," \"none\"",[35,1724,420],{"class":81},[35,1726,1727],{"class":45},"\"| unexpected days:\"",[35,1729,420],{"class":81},[35,1731,1707],{"class":174},[35,1733,1734],{"class":81},"(found ",[35,1736,1713],{"class":77},[35,1738,1739],{"class":81}," expected) ",[35,1741,1719],{"class":77},[35,1743,1722],{"class":45},[35,1745,126],{"class":81},[35,1747,1748,1750,1752,1755,1757,1759,1762,1764,1766,1768,1771,1773,1775,1778,1781,1783,1785],{"class":37,"line":151},[35,1749,687],{"class":174},[35,1751,178],{"class":81},[35,1753,1754],{"class":45},"\"days with no one on call:\"",[35,1756,420],{"class":81},[35,1758,1707],{"class":174},[35,1760,1761],{"class":81},"(d.isoformat() ",[35,1763,165],{"class":77},[35,1765,456],{"class":81},[35,1767,171],{"class":77},[35,1769,1770],{"class":81}," days.loc[days[",[35,1772,585],{"class":45},[35,1774,372],{"class":81},[35,1776,1777],{"class":77},"==",[35,1779,1780],{"class":45}," \"\"",[35,1782,420],{"class":81},[35,1784,1032],{"class":45},[35,1786,1787],{"class":81},"]))\n",[10,1789,1790,1791,1794],{},"Every day of September and October was read exactly once, and no padding day slipped in. The second line is the useful operational output: two days have nobody assigned, which the rota owner needs to fix. ",[32,1792,1793],{},"calendar.monthrange"," gives the number of days in each month, including February in leap years.",[729,1796,738,1801,738,1804,738,1807,738,1810,738,1815,738,1826,738,1832,738,1837,738,1842,738,1845,738,1848,738,1855,738,1860,738,1863,738,1867,738,1870,738,1873,738,1880,738,1886,738,1890,738,1894,738,1898,738,1901,738,1906,738,1913,738,1918,738,1921],{"viewBox":1797,"role":732,"ariaLabelledBy":1798,"xmlns":736,"style":1414},"4 0 752 142",[1799,1800],"cal-flow-t","cal-flow-d",[740,1802,1803],{"id":1799},"Reading a calendar sheet",[744,1805,1806],{"id":1800},"Month titles are found by parsing column A, each block's day-number rows are paired with the entry rows below, padding days are dropped using their formatting, real dates are built and multi-name entries split, and the dates read are checked against every day of each month.",[748,1808],{"x":901,"y":751,"width":1423,"height":1809,"fill":754},"142",[748,1811],{"x":1427,"y":758,"width":1812,"height":1813,"rx":1814,"fill":761,"stroke":762,"style":1567},"153.0","110.5","12",[1816,1817,1819],"g",{"transform":1818},"translate(84.5,37.8) scale(1.000)",[1820,1821],"path",{"d":1822,"fill":1823,"stroke":1824,"style":1825},"M4 5h16v15H4z M4 10h16 M8 3v4 M16 3v4","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[769,1827,1831],{"x":1828,"y":1829,"style":1830},"96.5","84.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Month titles",[769,1833,1836],{"x":1828,"y":1834,"style":1835},"103.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","strptime '%B %Y'",[37,1838],{"x1":1839,"y1":1840,"x2":1841,"y2":1840,"stroke":834,"style":1567},"178.0","71.2","200.0",[1569,1843],{"points":1844,"fill":1572},"204.0,71.2 195.0,76.2 195.0,66.2",[748,1846],{"x":1847,"y":758,"width":1812,"height":1813,"rx":1814,"fill":833,"stroke":834,"style":1567},"209.0",[1816,1849,1851],{"transform":1850},"translate(273.5,37.8) scale(1.000)",[1820,1852],{"d":1853,"fill":1823,"stroke":1854,"style":1825},"M12 3l9 5-9 5-9-5z M3 13l9 5 9-5 M3 17l9 5 9-5","var(--brand-strong,#4338ca)",[769,1856,1859],{"x":1857,"y":1829,"style":1858},"285.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Pair rows",[769,1861,1862],{"x":1857,"y":1834,"style":1835},"day numbers + entries",[37,1864],{"x1":1865,"y1":1840,"x2":1866,"y2":1840,"stroke":834,"style":1567},"367.0","389.0",[1569,1868],{"points":1869,"fill":1572},"393.0,71.2 384.0,76.2 384.0,66.2",[748,1871],{"x":1872,"y":758,"width":1812,"height":1813,"rx":1814,"fill":922,"stroke":923,"style":1567},"398.0",[1816,1874,1876],{"transform":1875},"translate(462.5,29.0) scale(1.000)",[1820,1877],{"d":1878,"fill":1823,"stroke":1879,"style":1825},"M3 5h18v14H3z M3 10h18 M3 15h18 M9 5v14","var(--teal-ink,#0b6157)",[769,1881,1885],{"x":1882,"y":1883,"style":1884},"474.5","75.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Dates",[769,1887,1889],{"x":1882,"y":1888,"style":1835},"95.0","drop padding, split",[769,1891,1893],{"x":1882,"y":1892,"style":1835},"112.5","names",[37,1895],{"x1":1896,"y1":1840,"x2":1897,"y2":1840,"stroke":834,"style":1567},"556.0","578.0",[1569,1899],{"points":1900,"fill":1572},"582.0,71.2 573.0,76.2 573.0,66.2",[748,1902],{"x":1903,"y":758,"width":1812,"height":1813,"rx":1814,"fill":1904,"stroke":1905,"style":1567},"587.0","#fdefd8","var(--gold,#b4740a)",[1816,1907,1909],{"transform":1908},"translate(651.5,29.0) scale(1.000)",[1820,1910],{"d":1911,"fill":1823,"stroke":1912,"style":1825},"M5 12.5l4.5 4.5L19 7","var(--gold-ink,#7a4e06)",[769,1914,1917],{"x":1915,"y":1883,"style":1916},"663.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Completeness",[769,1919,1920],{"x":1915,"y":1888,"style":1835},"every day of each",[769,1922,1923],{"x":1915,"y":1892,"style":1835},"month",[20,1925,1927],{"id":1926},"step-5-use-it-like-any-table","Step 5 — Use it like any table",[10,1929,1930],{},"Once the calendar is a table, the questions people actually ask are one-liners:",[25,1932,1934],{"className":68,"code":1933,"language":70,"meta":30,"style":30},"tidy[\"weekend\"] = pd.to_datetime(tidy[\"date\"]).dt.dayofweek >= 5\nsummary = tidy.dropna(subset=[\"person\"]).groupby(\"person\").agg(days=(\"date\", \"nunique\"), weekend_days=(\"weekend\", \"sum\"))\nprint(summary.to_string())\ntidy.to_excel(\"oncall_table.xlsx\", index=False)\n",[32,1935,1936,1961,2021,2028],{"__ignoreMap":30},[35,1937,1938,1940,1943,1945,1947,1950,1952,1955,1958],{"class":37,"line":38},[35,1939,1281],{"class":81},[35,1941,1942],{"class":45},"\"weekend\"",[35,1944,372],{"class":81},[35,1946,117],{"class":77},[35,1948,1949],{"class":81}," pd.to_datetime(tidy[",[35,1951,1032],{"class":45},[35,1953,1954],{"class":81},"]).dt.dayofweek ",[35,1956,1957],{"class":77},">=",[35,1959,1960],{"class":174}," 5\n",[35,1962,1963,1966,1968,1971,1974,1976,1978,1980,1983,1985,1988,1991,1993,1995,1997,1999,2002,2005,2008,2010,2012,2014,2016,2019],{"class":37,"line":91},[35,1964,1965],{"class":81},"summary ",[35,1967,117],{"class":77},[35,1969,1970],{"class":81}," tidy.dropna(",[35,1972,1973],{"class":1151},"subset",[35,1975,117],{"class":77},[35,1977,1191],{"class":81},[35,1979,1273],{"class":45},[35,1981,1982],{"class":81},"]).groupby(",[35,1984,1273],{"class":45},[35,1986,1987],{"class":81},").agg(",[35,1989,1990],{"class":1151},"days",[35,1992,117],{"class":77},[35,1994,178],{"class":81},[35,1996,1032],{"class":45},[35,1998,420],{"class":81},[35,2000,2001],{"class":45},"\"nunique\"",[35,2003,2004],{"class":81},"), ",[35,2006,2007],{"class":1151},"weekend_days",[35,2009,117],{"class":77},[35,2011,178],{"class":81},[35,2013,1942],{"class":45},[35,2015,420],{"class":81},[35,2017,2018],{"class":45},"\"sum\"",[35,2020,1376],{"class":81},[35,2022,2023,2025],{"class":37,"line":104},[35,2024,687],{"class":174},[35,2026,2027],{"class":81},"(summary.to_string())\n",[35,2029,2030,2033,2036,2038,2040,2042,2044],{"class":37,"line":111},[35,2031,2032],{"class":81},"tidy.to_excel(",[35,2034,2035],{"class":45},"\"oncall_table.xlsx\"",[35,2037,420],{"class":81},[35,2039,1369],{"class":1151},[35,2041,117],{"class":77},[35,2043,1157],{"class":174},[35,2045,126],{"class":81},[10,2047,2048,2049,18],{},"How many days each person is on call, and how many of them fall at weekends, is a group-by — the question that is tedious to answer by counting cells in a calendar grid, and the basis for checking that a rota is fair. The tidy table is written to its own workbook, ready for filtering or loading into another system. If you need to go the other way — producing the calendar from a table — see ",[14,2050,2052],{"href":2051},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fbuild-a-calendar-sheet-in-excel-with-python\u002F","Build a Calendar Sheet in Excel with Python",[20,2054,2056],{"id":2055},"common-pitfalls","Common pitfalls",[2058,2059,2060,2068,2074,2080,2086],"ul",{},[2061,2062,2063,2067],"li",{},[2064,2065,2066],"strong",{},"Fixed row offsets per month."," Months need five or six week rows; locate blocks by their titles.",[2061,2069,2070,2073],{},[2064,2071,2072],{},"Padding days read as real days."," Use their formatting, or their position in the first and last weeks.",[2061,2075,2076,2079],{},[2064,2077,2078],{},"Entries with several names in one row."," Split them so each person can be counted.",[2061,2081,2082,2085],{},[2064,2083,2084],{},"Losing empty days."," Keep a row with no person so gaps are visible.",[2061,2087,2088,2091],{},[2064,2089,2090],{},"No completeness check."," Compare the dates read with every date in each month.",[20,2093,2095],{"id":2094},"conclusion","Conclusion",[10,2097,2098],{},"A calendar-style sheet is a set of month blocks, each with rows of day numbers and rows of entries beneath them. Find the blocks by parsing their titles, walk each block's rows in pairs to match entries to day numbers, skip padding days from neighbouring months using their formatting or position, and build real dates from the block's month and year. Split multi-name entries into one row per person, keep notes in their own column, and check that every date of every month was read before using the table.",[20,2100,2102],{"id":2101},"frequently-asked-questions","Frequently asked questions",[10,2104,2105,2108],{},[2064,2106,2107],{},"How do I convert a calendar layout in Excel to a list of dates?","\nLocate each month block by its title, then read the rows of day numbers and the entry row beneath each, building a date from the block's year and month and each day number.",[10,2110,2111,2114],{},[2064,2112,2113],{},"How do I skip the days from the previous and next month in a calendar grid?","\nUse their formatting, such as grey or italic text, or their position: large day numbers in the first week and small ones in the last week belong to neighbouring months.",[10,2116,2117,2120],{},[2064,2118,2119],{},"How do I handle several names in one calendar cell?","\nSplit the text on its separator, such as a slash, and use pandas explode so each name becomes its own row with the same date.",[10,2122,2123,2126],{},[2064,2124,2125],{},"How can I check that every day was read from a calendar sheet?","\nBuild the set of all dates in each month with calendar.monthrange and compare it with the dates you read.",[20,2128,2130],{"id":2129},"related","Related",[2058,2132,2133,2139,2146,2153],{},[2061,2134,2135,2136,2138],{},"Up one level: ",[14,2137,17],{"href":16}," — getting data out of awkward layouts.",[2061,2140,2141,2145],{},[14,2142,2144],{"href":2143},"\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-sheet-where-records-run-across-columns\u002F","Read a Sheet Where Records Run Across Columns"," — another sideways layout.",[2061,2147,2148,2152],{},[14,2149,2151],{"href":2150},"\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-cell-colours-and-font-styles-as-data-with-openpyxl\u002F","Read Cell Colours and Font Styles as Data with openpyxl"," — formatting as information, as with the padding days.",[2061,2154,2155,2159],{},[14,2156,2158],{"href":2157},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fgenerate-a-staff-rota-in-excel-with-python\u002F","Generate a Staff Rota in Excel with Python"," — creating a rota rather than reading one.",[2161,2162,2163],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .s_b0D, html code.shiki .s_b0D{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#72F088;--shiki-dark-font-weight:bold}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}",{"title":30,"searchDepth":91,"depth":91,"links":2165},[2166,2167,2168,2169,2170,2171,2172,2173,2174,2175],{"id":22,"depth":91,"text":23},{"id":61,"depth":91,"text":62},{"id":327,"depth":91,"text":328},{"id":955,"depth":91,"text":956},{"id":1589,"depth":91,"text":1590},{"id":1926,"depth":91,"text":1927},{"id":2055,"depth":91,"text":2056},{"id":2094,"depth":91,"text":2095},{"id":2101,"depth":91,"text":2102},{"id":2129,"depth":91,"text":2130},"2026-10-02","Rota planners, booking sheets and leave trackers are often laid out as calendars: a month title, weekday columns, a row of day numbers and a row of entries under each. Turn such grids into one row per date with openpyxl and pandas: find each month block, pair day numbers with their entries, build real dates, skip the padding days from neighbouring months, and validate that every day of every month was read.","md",[2180,2182,2184,2186],{"q":2107,"a":2181},"Locate each month block by its title, then read the rows of day numbers and the entry row beneath each, building a date from the block's year and month and each day number.",{"q":2113,"a":2183},"Use their formatting, such as grey or italic text, or their position: large day numbers in the first week and small ones in the last week belong to neighbouring months.",{"q":2119,"a":2185},"Split the text on its separator, such as a slash, and use pandas explode so each name becomes its own row with the same date.",{"q":2125,"a":2187},"Build the set of all dates in each month with calendar.monthrange and compare it with the dates you read.","read a calendar-style sheet into a table",null,{"breadcrumb":2191},[2192,2194,2197],{"name":2193,"item":1231},"Home",{"name":2195,"item":2196},"Advanced Data Transformation and Cleaning for Excel Automation","\u002Fadvanced-data-transformation-and-cleaning\u002F",{"name":17,"item":16},"\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-calendar-style-sheet-into-a-table",{"title":2200,"description":2201},"Read a Calendar-Style Excel Sheet into a Table with Python","Convert calendar-layout Excel sheets to one row per date: locate month titles, pair day-number rows with entry rows, build dates, skip padding days, handle several months per sheet and check completeness.","read-a-calendar-style-sheet-into-a-table","advanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-calendar-style-sheet-into-a-table\u002Findex",[2205,2206],"messy-sheets","dates-and-times","how-to","2_doDLTd_O4Y0JyLypFanLOARwF1k-SUxq3luTsr0NM",[2210,2214],{"title":2211,"path":2212,"stem":2213,"children":-1},"Parse Records That Span Multiple Rows in Excel with pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fparse-records-that-span-multiple-rows-in-excel","advanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fparse-records-that-span-multiple-rows-in-excel\u002Findex",{"title":2215,"path":2216,"stem":2217,"children":-1},"Read an Excel Sheet Where Records Run Across Columns","\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-sheet-where-records-run-across-columns","advanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fread-a-sheet-where-records-run-across-columns\u002Findex",[2219,2221],{"path":2220,"title":17},"\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets",{"path":2222,"title":2195},"\u002Fadvanced-data-transformation-and-cleaning",[],[2225,2228,2231,2234,2237,2240],{"path":2226,"title":2227,"kind":2207},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Ffreeze-header-row-in-excel-with-openpyxl","Freeze the Header Row in Excel with openpyxl",{"path":2229,"title":2230,"kind":2207},"\u002Fgetting-started-with-python-excel-automation\u002Freading-excel-files-with-pandas\u002Fhandle-merged-cells-when-reading-excel-with-pandas","Handle Merged Cells When Reading Excel with pandas",{"path":2232,"title":2233,"kind":2207},"\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Funmerge-cells-and-fill-values-with-openpyxl","Unmerge Cells and Fill the Values with openpyxl",{"path":2235,"title":2236,"kind":2207},"\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fcombine-sheets-with-different-layouts-into-one-table","Combine Sheets with Different Layouts into One Table",{"path":2238,"title":2239,"kind":2207},"\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fextract-hyperlink-urls-from-excel-cells","Extract Hyperlink URLs from Excel Cells with openpyxl",{"path":2241,"title":2242,"kind":2207},"\u002Fadvanced-data-transformation-and-cleaning\u002Fextracting-data-from-messy-excel-sheets\u002Fextract-report-header-fields-from-an-excel-sheet","Extract Report Header Fields from an Excel Sheet with Python",1790972090956]