[{"data":1,"prerenderedAt":2314},["ShallowReactive",2],{"doc:\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-working-hours-between-two-timestamps":3,"surround:\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-working-hours-between-two-timestamps":2280,"ancestors:\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-working-hours-between-two-timestamps":2289,"children:\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-working-hours-between-two-timestamps":2294,"related:\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-working-hours-between-two-timestamps":2295},{"id":4,"title":5,"body":6,"dateModified":2247,"datePublished":2247,"description":2248,"extension":2249,"faq":2250,"keyword":2259,"kind":2260,"meta":2261,"navigation":262,"path":2269,"seo":2270,"slug":2273,"stem":2274,"topics":2275,"type":2278,"__hash__":2279},"docs\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-working-hours-between-two-timestamps\u002Findex.md","Calculate Working Hours Between Two Timestamps",{"type":7,"value":8,"toc":2235},"minimark",[9,24,29,63,69,214,218,221,733,743,747,754,1129,1143,1235,1239,1242,1498,1513,1517,1847,2099,2103,2153,2157,2160,2164,2170,2174,2180,2186,2192,2198,2202,2231],[10,11,12,13,17,18,23],"p",{},"\"Resolved within 8 working hours\" is how most service levels are written, and it is not the same as \"resolved within 8 hours\". A ticket opened at 16:45 on a Friday and resolved at 10:15 on Monday took 65 and a half hours on the clock, but only 2 hours of business time if the office is open from 09:00 to 17:30. Excel has ",[14,15,16],"code",{},"NETWORKDAYS"," for whole days and nothing built in for hours, which is why SLA spreadsheets are full of long nested formulas that nobody wants to check. In Python the rule can be written once, readably, and then applied to every ticket. This page is part of ",[19,20,22],"a",{"href":21},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002F","Working with Dates and Times in Excel Data",".",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas numpy openpyxl xlsxwriter\n","bash","",[14,37,38],{"__ignoreMap":35},[39,40,43,47,51,54,57,60],"span",{"class":41,"line":42},"line",1,[39,44,46],{"class":45},"sMTad","pip",[39,48,50],{"class":49},"srMev"," install",[39,52,53],{"class":49}," pandas",[39,55,56],{"class":49}," numpy",[39,58,59],{"class":49}," openpyxl",[39,61,62],{"class":49}," xlsxwriter\n",[10,64,65,68],{},[14,66,67],{},"tickets.xlsx"," holds eight support tickets with the time each was opened and resolved and a priority. They are chosen to hit the awkward cases: a ticket raised late on a Friday, one raised in the middle of the night, one raised and closed on a Saturday, one resolved before the office opened, one that runs across a bank holiday, and one still open. The office works 09:00–17:30, Monday to Friday, and Monday 31 August 2026 is a holiday.",[70,71,79,80,79,84,79,88,79,96,79,103,79,113,79,124,79,131,79,136,79,140,79,144,79,148,79,154,79,160,79,164,79,166,79,168,79,171,79,176,79,180,79,182,79,184,79,189,79,196,79,202,79,206,79,210,79,212],"svg",{"viewBox":72,"role":73,"ariaLabelledBy":74,"xmlns":77,"style":78},"4 0 752 313","img",[75,76],"wh-friday-t","wh-friday-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[81,82,83],"title",{"id":75},"Friday 16:45 to Tuesday 10:15 — two business hours",[85,86,87],"desc",{"id":76},"A timeline for one ticket. It is opened on Friday at 16:45, which leaves 45 minutes of business time that day; Saturday, Sunday and the Monday holiday count for nothing; on Tuesday it is resolved at 10:15, adding 1 hour 15 minutes.",[89,90],"rect",{"x":91,"y":92,"width":93,"height":94,"fill":95},"4","0","752","313","#ffffff",[41,97],{"x1":98,"y1":99,"x2":100,"y2":99,"stroke":101,"style":102},"94.0","156.5","666.0","var(--muted,#5b6780)","stroke-width:2.5px",[89,104],{"x":105,"y":106,"width":107,"height":108,"rx":109,"fill":110,"stroke":111,"style":112},"20.0","16.0","172.0","108.5","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[114,115,117],"g",{"transform":116},"translate(94.0,29.0) scale(1.000)",[118,119],"path",{"d":120,"fill":121,"stroke":122,"style":123},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[125,126,130],"text",{"x":127,"y":128,"style":129},"106.0","74.5","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Fri 16:45",[125,132,135],{"x":127,"y":133,"style":134},"93.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","opened — 0.75 h until",[125,137,139],{"x":127,"y":138,"style":134},"110.0","17:30",[41,141],{"x1":127,"y1":142,"x2":127,"y2":99,"stroke":101,"style":143},"124.5","stroke-width:1.5px",[145,146],"circle",{"cx":127,"cy":99,"r":147,"fill":111,"stroke":95,"style":112},"7.0",[89,149],{"x":150,"y":151,"width":107,"height":108,"rx":109,"fill":152,"stroke":153,"style":112},"202.7","188.5","#e7ebef","var(--line,#cdd5e6)",[125,155,159],{"x":156,"y":157,"style":158},"288.7","238.5","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Sat and Sun",[125,161,163],{"x":156,"y":162,"style":134},"257.0","weekend — 0 h",[41,165],{"x1":156,"y1":99,"x2":156,"y2":151,"stroke":101,"style":143},[145,167],{"cx":156,"cy":99,"r":147,"fill":101,"stroke":95,"style":112},[89,169],{"x":170,"y":106,"width":107,"height":108,"rx":109,"fill":152,"stroke":153,"style":112},"385.3",[125,172,175],{"x":173,"y":174,"style":158},"471.3","66.0","Mon 31 Aug",[125,177,179],{"x":173,"y":178,"style":134},"84.5","holiday — 0 h",[41,181],{"x1":173,"y1":142,"x2":173,"y2":99,"stroke":101,"style":143},[145,183],{"cx":173,"cy":99,"r":147,"fill":101,"stroke":95,"style":112},[89,185],{"x":186,"y":151,"width":107,"height":108,"rx":109,"fill":187,"stroke":188,"style":112},"568.0","#d9f4f1","var(--teal,#0f9488)",[114,190,192],{"transform":191},"translate(642.0,201.5) scale(1.000)",[118,193],{"d":194,"fill":121,"stroke":195,"style":123},"M5 12.5l4.5 4.5L19 7","var(--teal-ink,#0b6157)",[125,197,201],{"x":198,"y":199,"style":200},"654.0","247.0","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Tue 10:15",[125,203,205],{"x":198,"y":204,"style":134},"265.5","resolved — 1.25 h since",[125,207,209],{"x":198,"y":208,"style":134},"282.5","09:00",[41,211],{"x1":198,"y1":99,"x2":198,"y2":151,"stroke":101,"style":143},[145,213],{"cx":198,"cy":99,"r":147,"fill":188,"stroke":95,"style":112},[25,215,217],{"id":216},"step-1-a-clear-reference-version","Step 1 — A clear reference version",[10,219,220],{},"Write the rule in the most obvious way first: walk day by day from the opening date to the closing date, and on each working day add the part of the interval that falls inside office hours:",[30,222,226],{"className":223,"code":224,"language":225,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","import numpy as np\nimport pandas as pd\n\nOPEN, CLOSE = 9.0, 17.5                                   # 09:00 to 17:30\nHOLIDAYS = np.array([\"2026-08-31\"], dtype=\"datetime64[D]\")\n\ndef business_hours_loop(start, end):\n    if pd.isna(start) or pd.isna(end) or end \u003C= start:\n        return np.nan if pd.isna(end) else 0.0\n    total, day = 0.0, start.normalize()\n    while day \u003C= end.normalize():\n        if np.is_busday(np.datetime64(day.date()), holidays=HOLIDAYS):\n            a = max(start, day + pd.Timedelta(hours=OPEN))\n            b = min(end, day + pd.Timedelta(hours=CLOSE))\n            if b > a:\n                total += (b - a).total_seconds() \u002F 3600\n        day += pd.Timedelta(days=1)\n    return total\n\nt = pd.read_excel(\"tickets.xlsx\", parse_dates=[\"Opened\", \"Resolved\"])\nt[\"Clock hours\"] = ((t[\"Resolved\"] - t[\"Opened\"]).dt.total_seconds() \u002F 3600).round(2)\nt[\"Business hours\"] = [round(business_hours_loop(s, e), 2) for s, e in zip(t[\"Opened\"], t[\"Resolved\"])]\nprint(t[[\"Ticket\", \"Opened\", \"Resolved\", \"Clock hours\", \"Business hours\"]].to_string(index=False))\n","python",[14,227,228,244,257,264,292,322,327,340,366,386,400,414,433,463,489,504,528,548,557,562,597,641,692],{"__ignoreMap":35},[39,229,230,234,238,241],{"class":41,"line":42},[39,231,233],{"class":232},"s-kum","import",[39,235,237],{"class":236},"skGVy"," numpy ",[39,239,240],{"class":232},"as",[39,242,243],{"class":236}," np\n",[39,245,247,249,252,254],{"class":41,"line":246},2,[39,248,233],{"class":232},[39,250,251],{"class":236}," pandas ",[39,253,240],{"class":232},[39,255,256],{"class":236}," pd\n",[39,258,260],{"class":41,"line":259},3,[39,261,263],{"emptyLinePlaceholder":262},true,"\n",[39,265,267,271,274,277,280,283,285,288],{"class":41,"line":266},4,[39,268,270],{"class":269},"sP0c6","OPEN",[39,272,273],{"class":236},", ",[39,275,276],{"class":269},"CLOSE",[39,278,279],{"class":232}," =",[39,281,282],{"class":269}," 9.0",[39,284,273],{"class":236},[39,286,287],{"class":269},"17.5",[39,289,291],{"class":290},"s-wDw","                                   # 09:00 to 17:30\n",[39,293,295,298,300,303,306,309,313,316,319],{"class":41,"line":294},5,[39,296,297],{"class":269},"HOLIDAYS",[39,299,279],{"class":232},[39,301,302],{"class":236}," np.array([",[39,304,305],{"class":49},"\"2026-08-31\"",[39,307,308],{"class":236},"], ",[39,310,312],{"class":311},"sa561","dtype",[39,314,315],{"class":232},"=",[39,317,318],{"class":49},"\"datetime64[D]\"",[39,320,321],{"class":236},")\n",[39,323,325],{"class":41,"line":324},6,[39,326,263],{"emptyLinePlaceholder":262},[39,328,330,333,337],{"class":41,"line":329},7,[39,331,332],{"class":232},"def",[39,334,336],{"class":335},"s_Opv"," business_hours_loop",[39,338,339],{"class":236},"(start, end):\n",[39,341,343,346,349,352,355,357,360,363],{"class":41,"line":342},8,[39,344,345],{"class":232},"    if",[39,347,348],{"class":236}," pd.isna(start) ",[39,350,351],{"class":232},"or",[39,353,354],{"class":236}," pd.isna(end) ",[39,356,351],{"class":232},[39,358,359],{"class":236}," end ",[39,361,362],{"class":232},"\u003C=",[39,364,365],{"class":236}," start:\n",[39,367,369,372,375,378,380,383],{"class":41,"line":368},9,[39,370,371],{"class":232},"        return",[39,373,374],{"class":236}," np.nan ",[39,376,377],{"class":232},"if",[39,379,354],{"class":236},[39,381,382],{"class":232},"else",[39,384,385],{"class":269}," 0.0\n",[39,387,389,392,394,397],{"class":41,"line":388},10,[39,390,391],{"class":236},"    total, day ",[39,393,315],{"class":232},[39,395,396],{"class":269}," 0.0",[39,398,399],{"class":236},", start.normalize()\n",[39,401,403,406,409,411],{"class":41,"line":402},11,[39,404,405],{"class":232},"    while",[39,407,408],{"class":236}," day ",[39,410,362],{"class":232},[39,412,413],{"class":236}," end.normalize():\n",[39,415,417,420,423,426,428,430],{"class":41,"line":416},12,[39,418,419],{"class":232},"        if",[39,421,422],{"class":236}," np.is_busday(np.datetime64(day.date()), ",[39,424,425],{"class":311},"holidays",[39,427,315],{"class":232},[39,429,297],{"class":269},[39,431,432],{"class":236},"):\n",[39,434,436,439,441,444,447,450,453,456,458,460],{"class":41,"line":435},13,[39,437,438],{"class":236},"            a ",[39,440,315],{"class":232},[39,442,443],{"class":269}," max",[39,445,446],{"class":236},"(start, day ",[39,448,449],{"class":232},"+",[39,451,452],{"class":236}," pd.Timedelta(",[39,454,455],{"class":311},"hours",[39,457,315],{"class":232},[39,459,270],{"class":269},[39,461,462],{"class":236},"))\n",[39,464,466,469,471,474,477,479,481,483,485,487],{"class":41,"line":465},14,[39,467,468],{"class":236},"            b ",[39,470,315],{"class":232},[39,472,473],{"class":269}," min",[39,475,476],{"class":236},"(end, day ",[39,478,449],{"class":232},[39,480,452],{"class":236},[39,482,455],{"class":311},[39,484,315],{"class":232},[39,486,276],{"class":269},[39,488,462],{"class":236},[39,490,492,495,498,501],{"class":41,"line":491},15,[39,493,494],{"class":232},"            if",[39,496,497],{"class":236}," b ",[39,499,500],{"class":232},">",[39,502,503],{"class":236}," a:\n",[39,505,507,510,513,516,519,522,525],{"class":41,"line":506},16,[39,508,509],{"class":236},"                total ",[39,511,512],{"class":232},"+=",[39,514,515],{"class":236}," (b ",[39,517,518],{"class":232},"-",[39,520,521],{"class":236}," a).total_seconds() ",[39,523,524],{"class":232},"\u002F",[39,526,527],{"class":269}," 3600\n",[39,529,531,534,536,538,541,543,546],{"class":41,"line":530},17,[39,532,533],{"class":236},"        day ",[39,535,512],{"class":232},[39,537,452],{"class":236},[39,539,540],{"class":311},"days",[39,542,315],{"class":232},[39,544,545],{"class":269},"1",[39,547,321],{"class":236},[39,549,551,554],{"class":41,"line":550},18,[39,552,553],{"class":232},"    return",[39,555,556],{"class":236}," total\n",[39,558,560],{"class":41,"line":559},19,[39,561,263],{"emptyLinePlaceholder":262},[39,563,565,568,570,573,576,578,581,583,586,589,591,594],{"class":41,"line":564},20,[39,566,567],{"class":236},"t ",[39,569,315],{"class":232},[39,571,572],{"class":236}," pd.read_excel(",[39,574,575],{"class":49},"\"tickets.xlsx\"",[39,577,273],{"class":236},[39,579,580],{"class":311},"parse_dates",[39,582,315],{"class":232},[39,584,585],{"class":236},"[",[39,587,588],{"class":49},"\"Opened\"",[39,590,273],{"class":236},[39,592,593],{"class":49},"\"Resolved\"",[39,595,596],{"class":236},"])\n",[39,598,600,603,606,609,611,614,616,618,620,623,625,628,630,633,636,639],{"class":41,"line":599},21,[39,601,602],{"class":236},"t[",[39,604,605],{"class":49},"\"Clock hours\"",[39,607,608],{"class":236},"] ",[39,610,315],{"class":232},[39,612,613],{"class":236}," ((t[",[39,615,593],{"class":49},[39,617,608],{"class":236},[39,619,518],{"class":232},[39,621,622],{"class":236}," t[",[39,624,588],{"class":49},[39,626,627],{"class":236},"]).dt.total_seconds() ",[39,629,524],{"class":232},[39,631,632],{"class":269}," 3600",[39,634,635],{"class":236},").round(",[39,637,638],{"class":269},"2",[39,640,321],{"class":236},[39,642,644,646,649,651,653,656,659,662,664,667,670,673,676,679,682,684,687,689],{"class":41,"line":643},22,[39,645,602],{"class":236},[39,647,648],{"class":49},"\"Business hours\"",[39,650,608],{"class":236},[39,652,315],{"class":232},[39,654,655],{"class":236}," [",[39,657,658],{"class":269},"round",[39,660,661],{"class":236},"(business_hours_loop(s, e), ",[39,663,638],{"class":269},[39,665,666],{"class":236},") ",[39,668,669],{"class":232},"for",[39,671,672],{"class":236}," s, e ",[39,674,675],{"class":232},"in",[39,677,678],{"class":269}," zip",[39,680,681],{"class":236},"(t[",[39,683,588],{"class":49},[39,685,686],{"class":236},"], t[",[39,688,593],{"class":49},[39,690,691],{"class":236},"])]\n",[39,693,695,698,701,704,706,708,710,712,714,716,718,720,723,726,728,731],{"class":41,"line":694},23,[39,696,697],{"class":269},"print",[39,699,700],{"class":236},"(t[[",[39,702,703],{"class":49},"\"Ticket\"",[39,705,273],{"class":236},[39,707,588],{"class":49},[39,709,273],{"class":236},[39,711,593],{"class":49},[39,713,273],{"class":236},[39,715,605],{"class":49},[39,717,273],{"class":236},[39,719,648],{"class":49},[39,721,722],{"class":236},"]].to_string(",[39,724,725],{"class":311},"index",[39,727,315],{"class":232},[39,729,730],{"class":269},"False",[39,732,462],{"class":236},[10,734,735,738,739,742],{},[14,736,737],{},"np.is_busday"," decides whether a date is a working day, using the weekend mask — Monday to Friday by default — and the holiday list. On each such day, the interval is clipped to the office hours: the later of the ticket's start and 09:00, the earlier of its end and 17:30. Anything outside that window adds nothing. The results show the difference: T-5002 is 89.5 clock hours — it spans a weekend and the holiday — but 2 business hours; T-5003, raised on Friday night, starts counting only on Tuesday morning; T-5004, raised and closed on a Saturday, has zero business hours; T-5006, open for most of a week, is 32 business hours. The open ticket gets ",[14,740,741],{},"NaN"," rather than a number — it has not finished.",[25,744,746],{"id":745},"step-2-a-vectorised-version-for-large-sheets","Step 2 — A vectorised version for large sheets",[10,748,749,750,753],{},"The loop is easy to check and slow for hundreds of thousands of tickets. The vectorised version counts full working days between the first and last day with ",[14,751,752],{},"np.busday_count",", and adds the partial first and last days:",[30,755,757],{"className":223,"code":756,"language":225,"meta":35,"style":35},"def business_hours(start, end):\n    s, e_raw = pd.to_datetime(start), pd.to_datetime(end)\n    e = e_raw.fillna(s)                                   # open items: measure nothing, mask later\n    sd, ed = s.dt.normalize(), e.dt.normalize()\n    hour_of = lambda ts, d: ((ts - d).dt.total_seconds() \u002F 3600).clip(OPEN, CLOSE)\n    sh, eh = hour_of(s, sd), hour_of(e, ed)\n    as_days = lambda x: x.values.astype(\"datetime64[D]\")\n    s_bus = np.is_busday(as_days(sd), holidays=HOLIDAYS)\n    e_bus = np.is_busday(as_days(ed), holidays=HOLIDAYS)\n    full_days = np.busday_count(as_days(sd + pd.Timedelta(days=1)), as_days(ed), holidays=HOLIDAYS)\n    same_day = np.where(s_bus, (eh - sh).clip(lower=0), 0.0)\n    multi_day = (np.where(s_bus, CLOSE - sh, 0.0) + np.where(e_bus, eh - OPEN, 0.0)\n                 + np.clip(full_days, 0, None) * (CLOSE - OPEN))\n    hours = np.where(sd == ed, same_day, multi_day)\n    return pd.Series(np.where(e > s, hours, 0.0), index=s.index).where(e_raw.notna())\n\nt[\"Business hours (fast)\"] = business_hours(t[\"Opened\"], t[\"Resolved\"]).round(2)\nprint((t[\"Business hours\"].fillna(-1) == t[\"Business hours (fast)\"].fillna(-1)).all())\n",[14,758,759,768,778,791,801,834,844,860,878,896,927,957,995,1026,1042,1065,1069,1096],{"__ignoreMap":35},[39,760,761,763,766],{"class":41,"line":42},[39,762,332],{"class":232},[39,764,765],{"class":335}," business_hours",[39,767,339],{"class":236},[39,769,770,773,775],{"class":41,"line":246},[39,771,772],{"class":236},"    s, e_raw ",[39,774,315],{"class":232},[39,776,777],{"class":236}," pd.to_datetime(start), pd.to_datetime(end)\n",[39,779,780,783,785,788],{"class":41,"line":259},[39,781,782],{"class":236},"    e ",[39,784,315],{"class":232},[39,786,787],{"class":236}," e_raw.fillna(s)                                   ",[39,789,790],{"class":290},"# open items: measure nothing, mask later\n",[39,792,793,796,798],{"class":41,"line":266},[39,794,795],{"class":236},"    sd, ed ",[39,797,315],{"class":232},[39,799,800],{"class":236}," s.dt.normalize(), e.dt.normalize()\n",[39,802,803,806,808,811,814,816,819,821,823,826,828,830,832],{"class":41,"line":294},[39,804,805],{"class":236},"    hour_of ",[39,807,315],{"class":232},[39,809,810],{"class":232}," lambda",[39,812,813],{"class":236}," ts, d: ((ts ",[39,815,518],{"class":232},[39,817,818],{"class":236}," d).dt.total_seconds() ",[39,820,524],{"class":232},[39,822,632],{"class":269},[39,824,825],{"class":236},").clip(",[39,827,270],{"class":269},[39,829,273],{"class":236},[39,831,276],{"class":269},[39,833,321],{"class":236},[39,835,836,839,841],{"class":41,"line":324},[39,837,838],{"class":236},"    sh, eh ",[39,840,315],{"class":232},[39,842,843],{"class":236}," hour_of(s, sd), hour_of(e, ed)\n",[39,845,846,849,851,853,856,858],{"class":41,"line":329},[39,847,848],{"class":236},"    as_days ",[39,850,315],{"class":232},[39,852,810],{"class":232},[39,854,855],{"class":236}," x: x.values.astype(",[39,857,318],{"class":49},[39,859,321],{"class":236},[39,861,862,865,867,870,872,874,876],{"class":41,"line":342},[39,863,864],{"class":236},"    s_bus ",[39,866,315],{"class":232},[39,868,869],{"class":236}," np.is_busday(as_days(sd), ",[39,871,425],{"class":311},[39,873,315],{"class":232},[39,875,297],{"class":269},[39,877,321],{"class":236},[39,879,880,883,885,888,890,892,894],{"class":41,"line":368},[39,881,882],{"class":236},"    e_bus ",[39,884,315],{"class":232},[39,886,887],{"class":236}," np.is_busday(as_days(ed), ",[39,889,425],{"class":311},[39,891,315],{"class":232},[39,893,297],{"class":269},[39,895,321],{"class":236},[39,897,898,901,903,906,908,910,912,914,916,919,921,923,925],{"class":41,"line":388},[39,899,900],{"class":236},"    full_days ",[39,902,315],{"class":232},[39,904,905],{"class":236}," np.busday_count(as_days(sd ",[39,907,449],{"class":232},[39,909,452],{"class":236},[39,911,540],{"class":311},[39,913,315],{"class":232},[39,915,545],{"class":269},[39,917,918],{"class":236},")), as_days(ed), ",[39,920,425],{"class":311},[39,922,315],{"class":232},[39,924,297],{"class":269},[39,926,321],{"class":236},[39,928,929,932,934,937,939,942,945,947,949,952,955],{"class":41,"line":402},[39,930,931],{"class":236},"    same_day ",[39,933,315],{"class":232},[39,935,936],{"class":236}," np.where(s_bus, (eh ",[39,938,518],{"class":232},[39,940,941],{"class":236}," sh).clip(",[39,943,944],{"class":311},"lower",[39,946,315],{"class":232},[39,948,92],{"class":269},[39,950,951],{"class":236},"), ",[39,953,954],{"class":269},"0.0",[39,956,321],{"class":236},[39,958,959,962,964,967,969,972,975,977,979,981,984,986,989,991,993],{"class":41,"line":416},[39,960,961],{"class":236},"    multi_day ",[39,963,315],{"class":232},[39,965,966],{"class":236}," (np.where(s_bus, ",[39,968,276],{"class":269},[39,970,971],{"class":232}," -",[39,973,974],{"class":236}," sh, ",[39,976,954],{"class":269},[39,978,666],{"class":236},[39,980,449],{"class":232},[39,982,983],{"class":236}," np.where(e_bus, eh ",[39,985,518],{"class":232},[39,987,988],{"class":269}," OPEN",[39,990,273],{"class":236},[39,992,954],{"class":269},[39,994,321],{"class":236},[39,996,997,1000,1003,1005,1007,1010,1012,1015,1018,1020,1022,1024],{"class":41,"line":435},[39,998,999],{"class":232},"                 +",[39,1001,1002],{"class":236}," np.clip(full_days, ",[39,1004,92],{"class":269},[39,1006,273],{"class":236},[39,1008,1009],{"class":269},"None",[39,1011,666],{"class":236},[39,1013,1014],{"class":232},"*",[39,1016,1017],{"class":236}," (",[39,1019,276],{"class":269},[39,1021,971],{"class":232},[39,1023,988],{"class":269},[39,1025,462],{"class":236},[39,1027,1028,1031,1033,1036,1039],{"class":41,"line":465},[39,1029,1030],{"class":236},"    hours ",[39,1032,315],{"class":232},[39,1034,1035],{"class":236}," np.where(sd ",[39,1037,1038],{"class":232},"==",[39,1040,1041],{"class":236}," ed, same_day, multi_day)\n",[39,1043,1044,1046,1049,1051,1054,1056,1058,1060,1062],{"class":41,"line":491},[39,1045,553],{"class":232},[39,1047,1048],{"class":236}," pd.Series(np.where(e ",[39,1050,500],{"class":232},[39,1052,1053],{"class":236}," s, hours, ",[39,1055,954],{"class":269},[39,1057,951],{"class":236},[39,1059,725],{"class":311},[39,1061,315],{"class":232},[39,1063,1064],{"class":236},"s.index).where(e_raw.notna())\n",[39,1066,1067],{"class":41,"line":506},[39,1068,263],{"emptyLinePlaceholder":262},[39,1070,1071,1073,1076,1078,1080,1083,1085,1087,1089,1092,1094],{"class":41,"line":530},[39,1072,602],{"class":236},[39,1074,1075],{"class":49},"\"Business hours (fast)\"",[39,1077,608],{"class":236},[39,1079,315],{"class":232},[39,1081,1082],{"class":236}," business_hours(t[",[39,1084,588],{"class":49},[39,1086,686],{"class":236},[39,1088,593],{"class":49},[39,1090,1091],{"class":236},"]).round(",[39,1093,638],{"class":269},[39,1095,321],{"class":236},[39,1097,1098,1100,1103,1105,1108,1110,1112,1114,1116,1118,1120,1122,1124,1126],{"class":41,"line":550},[39,1099,697],{"class":269},[39,1101,1102],{"class":236},"((t[",[39,1104,648],{"class":49},[39,1106,1107],{"class":236},"].fillna(",[39,1109,518],{"class":232},[39,1111,545],{"class":269},[39,1113,666],{"class":236},[39,1115,1038],{"class":232},[39,1117,622],{"class":236},[39,1119,1075],{"class":49},[39,1121,1107],{"class":236},[39,1123,518],{"class":232},[39,1125,545],{"class":269},[39,1127,1128],{"class":236},")).all())\n",[10,1130,1131,1132,1135,1136,1138,1139,1142],{},"Clipping each timestamp's hour of day to the 09:00–17:30 window handles tickets raised overnight: a ticket opened at 21:10 is treated as opened at 17:30 on that day, which contributes nothing, and one resolved at 08:45 as resolved at 09:00. ",[14,1133,1134],{},"busday_count(a, b)"," counts working days from ",[14,1137,19],{}," up to but not including ",[14,1140,1141],{},"b"," — exactly the full days strictly between the first and last day. The last line checks the fast version against the loop on the sample; before relying on it, run the same comparison on a few thousand random intervals, which is how this version was tested.",[70,1144,79,1150,79,1153,79,1156,79,1161,79,1165,79,1170,79,1176,79,1181,79,1185,79,1190,79,1193,79,1199,79,1204,79,1208,79,1212,79,1215,79,1220,79,1226,79,1231],{"viewBox":1145,"role":73,"ariaLabelledBy":1146,"xmlns":77,"style":1149},"64 0 632 383",[1147,1148],"wh-parts-t","wh-parts-d","width:100%;max-width:632px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[81,1151,1152],{"id":1147},"How the fast version adds up a multi-day ticket",[85,1154,1155],{"id":1148},"Three layers. The first day contributes from the clipped start time to 17:30 if it is a working day, the full working days in between contribute 8.5 hours each, and the last day contributes from 09:00 to the clipped end time if it is a working day.",[89,1157],{"x":1158,"y":92,"width":1159,"height":1160,"fill":95},"64","632","383",[89,1162],{"x":1163,"y":106,"width":1164,"height":133,"rx":109,"fill":110,"stroke":111,"style":112},"80.0","600.0",[114,1166,1168],{"transform":1167},"translate(368.0,29.0) scale(1.000)",[118,1169],{"d":120,"fill":121,"stroke":122,"style":123},[125,1171,1175],{"x":1172,"y":1173,"style":1174},"380.0","75.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","First day",[125,1177,1180],{"x":1172,"y":1178,"style":1179},"95.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","17:30 − clipped start, if a working day",[41,1182],{"x1":1172,"y1":1183,"x2":1172,"y2":1184,"stroke":111,"style":112},"114.0","136.0",[1186,1187],"polygon",{"points":1188,"fill":1189},"380.0,140.0 375.0,131.0 385.0,131.0","#5b5cf0",[89,1191],{"x":1163,"y":1192,"width":1164,"height":133,"rx":109,"fill":187,"stroke":188,"style":112},"145.0",[114,1194,1196],{"transform":1195},"translate(368.0,158.0) scale(1.000)",[118,1197],{"d":1198,"fill":121,"stroke":195,"style":123},"M4 5h16v15H4z M4 10h16 M8 3v4 M16 3v4",[125,1200,1203],{"x":1172,"y":1201,"style":1202},"204.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Days in between",[125,1205,1207],{"x":1172,"y":1206,"style":1179},"224.0","busday_count × 8.5 h",[41,1209],{"x1":1172,"y1":1210,"x2":1172,"y2":1211,"stroke":111,"style":112},"243.0","265.0",[1186,1213],{"points":1214,"fill":1189},"380.0,269.0 375.0,260.0 385.0,260.0",[89,1216],{"x":1163,"y":1217,"width":1164,"height":133,"rx":109,"fill":1218,"stroke":1219,"style":112},"274.0","#fdefd8","var(--gold,#b4740a)",[114,1221,1223],{"transform":1222},"translate(368.0,287.0) scale(1.000)",[118,1224],{"d":120,"fill":121,"stroke":1225,"style":123},"var(--gold-ink,#7a4e06)",[125,1227,1230],{"x":1172,"y":1228,"style":1229},"333.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Last day",[125,1232,1234],{"x":1172,"y":1233,"style":1179},"353.0","clipped end − 09:00, if a working day",[25,1236,1238],{"id":1237},"step-3-flag-sla-breaches","Step 3 — Flag SLA breaches",[10,1240,1241],{},"Service levels usually depend on priority. Map each priority to its target and compare; tickets still open are measured up to now, so a ticket can breach before it is closed:",[30,1243,1245],{"className":223,"code":1244,"language":225,"meta":35,"style":35},"SLA = {\"High\": 4, \"Normal\": 8, \"Low\": 24}\nnow = pd.Timestamp(\"2026-09-01 16:00\")\nt[\"Measured to\"] = t[\"Resolved\"].fillna(now)\nt[\"Hours used\"] = business_hours(t[\"Opened\"], t[\"Measured to\"]).round(2)\nt[\"Target\"] = t[\"Priority\"].map(SLA)\nt[\"Breach\"] = t[\"Hours used\"] > t[\"Target\"]\nt[\"Status\"] = np.where(t[\"Resolved\"].isna(), \"open\", \"closed\")\nprint(t[[\"Ticket\", \"Priority\", \"Status\", \"Hours used\", \"Target\", \"Breach\"]].to_string(index=False))\nprint(f\"breach rate: {t['Breach'].mean():.0%}\")\n",[14,1246,1247,1288,1303,1321,1346,1369,1395,1424,1462],{"__ignoreMap":35},[39,1248,1249,1252,1254,1257,1260,1263,1265,1267,1270,1272,1275,1277,1280,1282,1285],{"class":41,"line":42},[39,1250,1251],{"class":269},"SLA",[39,1253,279],{"class":232},[39,1255,1256],{"class":236}," {",[39,1258,1259],{"class":49},"\"High\"",[39,1261,1262],{"class":236},": ",[39,1264,91],{"class":269},[39,1266,273],{"class":236},[39,1268,1269],{"class":49},"\"Normal\"",[39,1271,1262],{"class":236},[39,1273,1274],{"class":269},"8",[39,1276,273],{"class":236},[39,1278,1279],{"class":49},"\"Low\"",[39,1281,1262],{"class":236},[39,1283,1284],{"class":269},"24",[39,1286,1287],{"class":236},"}\n",[39,1289,1290,1293,1295,1298,1301],{"class":41,"line":246},[39,1291,1292],{"class":236},"now ",[39,1294,315],{"class":232},[39,1296,1297],{"class":236}," pd.Timestamp(",[39,1299,1300],{"class":49},"\"2026-09-01 16:00\"",[39,1302,321],{"class":236},[39,1304,1305,1307,1310,1312,1314,1316,1318],{"class":41,"line":259},[39,1306,602],{"class":236},[39,1308,1309],{"class":49},"\"Measured to\"",[39,1311,608],{"class":236},[39,1313,315],{"class":232},[39,1315,622],{"class":236},[39,1317,593],{"class":49},[39,1319,1320],{"class":236},"].fillna(now)\n",[39,1322,1323,1325,1328,1330,1332,1334,1336,1338,1340,1342,1344],{"class":41,"line":266},[39,1324,602],{"class":236},[39,1326,1327],{"class":49},"\"Hours used\"",[39,1329,608],{"class":236},[39,1331,315],{"class":232},[39,1333,1082],{"class":236},[39,1335,588],{"class":49},[39,1337,686],{"class":236},[39,1339,1309],{"class":49},[39,1341,1091],{"class":236},[39,1343,638],{"class":269},[39,1345,321],{"class":236},[39,1347,1348,1350,1353,1355,1357,1359,1362,1365,1367],{"class":41,"line":294},[39,1349,602],{"class":236},[39,1351,1352],{"class":49},"\"Target\"",[39,1354,608],{"class":236},[39,1356,315],{"class":232},[39,1358,622],{"class":236},[39,1360,1361],{"class":49},"\"Priority\"",[39,1363,1364],{"class":236},"].map(",[39,1366,1251],{"class":269},[39,1368,321],{"class":236},[39,1370,1371,1373,1376,1378,1380,1382,1384,1386,1388,1390,1392],{"class":41,"line":324},[39,1372,602],{"class":236},[39,1374,1375],{"class":49},"\"Breach\"",[39,1377,608],{"class":236},[39,1379,315],{"class":232},[39,1381,622],{"class":236},[39,1383,1327],{"class":49},[39,1385,608],{"class":236},[39,1387,500],{"class":232},[39,1389,622],{"class":236},[39,1391,1352],{"class":49},[39,1393,1394],{"class":236},"]\n",[39,1396,1397,1399,1402,1404,1406,1409,1411,1414,1417,1419,1422],{"class":41,"line":329},[39,1398,602],{"class":236},[39,1400,1401],{"class":49},"\"Status\"",[39,1403,608],{"class":236},[39,1405,315],{"class":232},[39,1407,1408],{"class":236}," np.where(t[",[39,1410,593],{"class":49},[39,1412,1413],{"class":236},"].isna(), ",[39,1415,1416],{"class":49},"\"open\"",[39,1418,273],{"class":236},[39,1420,1421],{"class":49},"\"closed\"",[39,1423,321],{"class":236},[39,1425,1426,1428,1430,1432,1434,1436,1438,1440,1442,1444,1446,1448,1450,1452,1454,1456,1458,1460],{"class":41,"line":342},[39,1427,697],{"class":269},[39,1429,700],{"class":236},[39,1431,703],{"class":49},[39,1433,273],{"class":236},[39,1435,1361],{"class":49},[39,1437,273],{"class":236},[39,1439,1401],{"class":49},[39,1441,273],{"class":236},[39,1443,1327],{"class":49},[39,1445,273],{"class":236},[39,1447,1352],{"class":49},[39,1449,273],{"class":236},[39,1451,1375],{"class":49},[39,1453,722],{"class":236},[39,1455,725],{"class":311},[39,1457,315],{"class":232},[39,1459,730],{"class":269},[39,1461,462],{"class":236},[39,1463,1464,1466,1469,1472,1475,1479,1481,1484,1487,1490,1493,1496],{"class":41,"line":368},[39,1465,697],{"class":269},[39,1467,1468],{"class":236},"(",[39,1470,1471],{"class":232},"f",[39,1473,1474],{"class":49},"\"breach rate: ",[39,1476,1478],{"class":1477},"sSjpA","{",[39,1480,602],{"class":236},[39,1482,1483],{"class":49},"'Breach'",[39,1485,1486],{"class":236},"].mean()",[39,1488,1489],{"class":232},":.0%",[39,1491,1492],{"class":1477},"}",[39,1494,1495],{"class":49},"\"",[39,1497,321],{"class":236},[10,1499,1500,1501,1504,1505,1508,1509,23],{},"Two tickets breach. T-5006 is plain: 32 business hours against a 24-hour target. T-5005 is the instructive one: raised at 08:15 on Wednesday, before the office opened, and resolved at 08:45 on Thursday, again before opening — every minute of Wednesday's office day counts, so it used 8.5 hours against a target of 8, although it was fixed before anyone arrived on Thursday. Whether that is a fair breach is a policy question the numbers make visible. Using the report's run time for open tickets makes the flag answer \"is this ticket already out of SLA?\", which is what a team lead wants from a morning report. Freeze ",[14,1502,1503],{},"now"," to the report's cut-off time rather than calling ",[14,1506,1507],{},"pd.Timestamp.now()"," in several places, so every row is measured against the same moment — and so the report can be rerun for yesterday with the same result, as in ",[19,1510,1512],{"href":1511},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible\u002F","Make Excel Output Reproducible",[25,1514,1516],{"id":1515},"step-4-write-the-sla-report","Step 4 — Write the SLA report",[30,1518,1520],{"className":223,"code":1519,"language":225,"meta":35,"style":35},"cols = [\"Ticket\", \"Priority\", \"Opened\", \"Resolved\", \"Status\", \"Clock hours\", \"Hours used\", \"Target\", \"Breach\"]\nwith pd.ExcelWriter(\"sla_report.xlsx\", engine=\"xlsxwriter\", datetime_format=\"ddd dd mmm hh:mm\") as xw:\n    t[cols].to_excel(xw, sheet_name=\"Tickets\", index=False)\n    wb, ws = xw.book, xw.sheets[\"Tickets\"]\n    red = wb.add_format({\"bg_color\": \"#FCE9E9\", \"font_color\": \"#9C0006\"})\n    ws.conditional_format(1, 0, len(t), len(cols) - 1, {\"type\": \"formula\", \"criteria\": \"=$I2=TRUE\", \"format\": red})\n    ws.set_column(\"A:B\", 10)\n    ws.set_column(\"C:D\", 18)\n    summary = t.groupby(\"Priority\").agg(Tickets=(\"Ticket\", \"count\"), Breaches=(\"Breach\", \"sum\"),\n                                        Median_hours=(\"Hours used\", \"median\"))\n    summary.to_excel(xw, sheet_name=\"Summary\")\nprint(summary)\n",[14,1521,1522,1567,1605,1628,1642,1673,1731,1746,1760,1808,1826,1840],{"__ignoreMap":35},[39,1523,1524,1527,1529,1531,1533,1535,1537,1539,1541,1543,1545,1547,1549,1551,1553,1555,1557,1559,1561,1563,1565],{"class":41,"line":42},[39,1525,1526],{"class":236},"cols ",[39,1528,315],{"class":232},[39,1530,655],{"class":236},[39,1532,703],{"class":49},[39,1534,273],{"class":236},[39,1536,1361],{"class":49},[39,1538,273],{"class":236},[39,1540,588],{"class":49},[39,1542,273],{"class":236},[39,1544,593],{"class":49},[39,1546,273],{"class":236},[39,1548,1401],{"class":49},[39,1550,273],{"class":236},[39,1552,605],{"class":49},[39,1554,273],{"class":236},[39,1556,1327],{"class":49},[39,1558,273],{"class":236},[39,1560,1352],{"class":49},[39,1562,273],{"class":236},[39,1564,1375],{"class":49},[39,1566,1394],{"class":236},[39,1568,1569,1572,1575,1578,1580,1583,1585,1588,1590,1593,1595,1598,1600,1602],{"class":41,"line":246},[39,1570,1571],{"class":232},"with",[39,1573,1574],{"class":236}," pd.ExcelWriter(",[39,1576,1577],{"class":49},"\"sla_report.xlsx\"",[39,1579,273],{"class":236},[39,1581,1582],{"class":311},"engine",[39,1584,315],{"class":232},[39,1586,1587],{"class":49},"\"xlsxwriter\"",[39,1589,273],{"class":236},[39,1591,1592],{"class":311},"datetime_format",[39,1594,315],{"class":232},[39,1596,1597],{"class":49},"\"ddd dd mmm hh:mm\"",[39,1599,666],{"class":236},[39,1601,240],{"class":232},[39,1603,1604],{"class":236}," xw:\n",[39,1606,1607,1610,1613,1615,1618,1620,1622,1624,1626],{"class":41,"line":259},[39,1608,1609],{"class":236},"    t[cols].to_excel(xw, ",[39,1611,1612],{"class":311},"sheet_name",[39,1614,315],{"class":232},[39,1616,1617],{"class":49},"\"Tickets\"",[39,1619,273],{"class":236},[39,1621,725],{"class":311},[39,1623,315],{"class":232},[39,1625,730],{"class":269},[39,1627,321],{"class":236},[39,1629,1630,1633,1635,1638,1640],{"class":41,"line":266},[39,1631,1632],{"class":236},"    wb, ws ",[39,1634,315],{"class":232},[39,1636,1637],{"class":236}," xw.book, xw.sheets[",[39,1639,1617],{"class":49},[39,1641,1394],{"class":236},[39,1643,1644,1647,1649,1652,1655,1657,1660,1662,1665,1667,1670],{"class":41,"line":294},[39,1645,1646],{"class":236},"    red ",[39,1648,315],{"class":232},[39,1650,1651],{"class":236}," wb.add_format({",[39,1653,1654],{"class":49},"\"bg_color\"",[39,1656,1262],{"class":236},[39,1658,1659],{"class":49},"\"#FCE9E9\"",[39,1661,273],{"class":236},[39,1663,1664],{"class":49},"\"font_color\"",[39,1666,1262],{"class":236},[39,1668,1669],{"class":49},"\"#9C0006\"",[39,1671,1672],{"class":236},"})\n",[39,1674,1675,1678,1680,1682,1684,1686,1689,1692,1694,1697,1699,1702,1705,1708,1710,1713,1715,1718,1720,1723,1725,1728],{"class":41,"line":324},[39,1676,1677],{"class":236},"    ws.conditional_format(",[39,1679,545],{"class":269},[39,1681,273],{"class":236},[39,1683,92],{"class":269},[39,1685,273],{"class":236},[39,1687,1688],{"class":269},"len",[39,1690,1691],{"class":236},"(t), ",[39,1693,1688],{"class":269},[39,1695,1696],{"class":236},"(cols) ",[39,1698,518],{"class":232},[39,1700,1701],{"class":269}," 1",[39,1703,1704],{"class":236},", {",[39,1706,1707],{"class":49},"\"type\"",[39,1709,1262],{"class":236},[39,1711,1712],{"class":49},"\"formula\"",[39,1714,273],{"class":236},[39,1716,1717],{"class":49},"\"criteria\"",[39,1719,1262],{"class":236},[39,1721,1722],{"class":49},"\"=$I2=TRUE\"",[39,1724,273],{"class":236},[39,1726,1727],{"class":49},"\"format\"",[39,1729,1730],{"class":236},": red})\n",[39,1732,1733,1736,1739,1741,1744],{"class":41,"line":329},[39,1734,1735],{"class":236},"    ws.set_column(",[39,1737,1738],{"class":49},"\"A:B\"",[39,1740,273],{"class":236},[39,1742,1743],{"class":269},"10",[39,1745,321],{"class":236},[39,1747,1748,1750,1753,1755,1758],{"class":41,"line":342},[39,1749,1735],{"class":236},[39,1751,1752],{"class":49},"\"C:D\"",[39,1754,273],{"class":236},[39,1756,1757],{"class":269},"18",[39,1759,321],{"class":236},[39,1761,1762,1765,1767,1770,1772,1775,1778,1780,1782,1784,1786,1789,1791,1794,1796,1798,1800,1802,1805],{"class":41,"line":368},[39,1763,1764],{"class":236},"    summary ",[39,1766,315],{"class":232},[39,1768,1769],{"class":236}," t.groupby(",[39,1771,1361],{"class":49},[39,1773,1774],{"class":236},").agg(",[39,1776,1777],{"class":311},"Tickets",[39,1779,315],{"class":232},[39,1781,1468],{"class":236},[39,1783,703],{"class":49},[39,1785,273],{"class":236},[39,1787,1788],{"class":49},"\"count\"",[39,1790,951],{"class":236},[39,1792,1793],{"class":311},"Breaches",[39,1795,315],{"class":232},[39,1797,1468],{"class":236},[39,1799,1375],{"class":49},[39,1801,273],{"class":236},[39,1803,1804],{"class":49},"\"sum\"",[39,1806,1807],{"class":236},"),\n",[39,1809,1810,1813,1815,1817,1819,1821,1824],{"class":41,"line":388},[39,1811,1812],{"class":311},"                                        Median_hours",[39,1814,315],{"class":232},[39,1816,1468],{"class":236},[39,1818,1327],{"class":49},[39,1820,273],{"class":236},[39,1822,1823],{"class":49},"\"median\"",[39,1825,462],{"class":236},[39,1827,1828,1831,1833,1835,1838],{"class":41,"line":402},[39,1829,1830],{"class":236},"    summary.to_excel(xw, ",[39,1832,1612],{"class":311},[39,1834,315],{"class":232},[39,1836,1837],{"class":49},"\"Summary\"",[39,1839,321],{"class":236},[39,1841,1842,1844],{"class":41,"line":416},[39,1843,697],{"class":269},[39,1845,1846],{"class":236},"(summary)\n",[70,1848,79,1854,79,1857,79,1860,79,1865,79,1871,79,1875,79,1881,79,1885,79,1889,79,1893,79,1897,79,1901,79,1905,79,1909,79,1913,79,1917,79,1921,79,1925,79,1929,79,1931,79,1935,79,1937,79,1940,79,1942,79,1945,79,1947,79,1950,79,1952,79,1955,79,1957,79,1960,79,1963,79,1966,79,1968,79,1971,79,1973,79,1976,79,1978,79,1981,79,1983,79,1986,79,1988,79,1990,79,1992,79,1995,79,1998,79,2002,79,2004,79,2007,79,2009,79,2012,79,2014,79,2016,79,2018,79,2021,79,2023,79,2025,79,2027,79,2029,79,2032,79,2035,79,2039,79,2043,79,2045,79,2048,79,2050,79,2052,79,2054,79,2056,79,2058,79,2060,79,2062,79,2065,79,2068,79,2072,79,2074,79,2077,79,2079,79,2081,79,2083,79,2086,79,2088,79,2091,79,2093,79,2095,79,2097],{"viewBox":1849,"role":73,"ariaLabelledBy":1850,"xmlns":77,"style":1853},"128 0 503 196",[1851,1852],"wh-report-t","wh-report-d","width:100%;max-width:503px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[81,1855,1856],{"id":1851},"The Tickets sheet of the SLA report",[85,1858,1859],{"id":1852},"Rows for several tickets with priority, clock hours, business hours used and target. Tickets whose business hours exceed the target are highlighted in red.",[89,1861],{"x":1862,"y":92,"width":1863,"height":1864,"fill":95},"128","503","196",[89,1866],{"x":1867,"y":106,"width":1868,"height":1869,"rx":92,"fill":152,"stroke":153,"style":1870},"144.7","32.0","24.0","stroke-width:1px",[89,1872],{"x":1873,"y":106,"width":1874,"height":1869,"rx":92,"fill":152,"stroke":153,"style":1870},"176.7","67.4",[125,1876,1880],{"x":1877,"y":1878,"style":1879},"210.4","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[89,1882],{"x":1883,"y":106,"width":1884,"height":1869,"rx":92,"fill":152,"stroke":153,"style":1870},"244.1","65.4",[125,1886,1888],{"x":1887,"y":1878,"style":1879},"276.8","B",[89,1890],{"x":1891,"y":106,"width":1892,"height":1869,"rx":92,"fill":152,"stroke":153,"style":1870},"309.5","91.6",[125,1894,1896],{"x":1895,"y":1878,"style":1879},"355.4","C",[89,1898],{"x":1899,"y":106,"width":1900,"height":1869,"rx":92,"fill":152,"stroke":153,"style":1870},"401.2","88.6",[125,1902,1904],{"x":1903,"y":1878,"style":1879},"445.5","D",[89,1906],{"x":1907,"y":106,"width":1908,"height":1869,"rx":92,"fill":152,"stroke":153,"style":1870},"489.8","61.5",[125,1910,1912],{"x":1911,"y":1878,"style":1879},"520.6","E",[89,1914],{"x":1915,"y":106,"width":1916,"height":1869,"rx":92,"fill":152,"stroke":153,"style":1870},"551.3","63.9",[125,1918,1920],{"x":1919,"y":1878,"style":1879},"583.3","F",[89,1922],{"x":1867,"y":1923,"width":1868,"height":1924,"rx":92,"fill":152,"stroke":153,"style":1870},"40.0","28.0",[125,1926,545],{"x":1927,"y":1928,"style":1879},"160.7","59.0",[89,1930],{"x":1873,"y":1923,"width":1874,"height":1924,"rx":92,"fill":110,"stroke":111,"style":143},[125,1932,1934],{"x":1877,"y":1928,"style":1933},"font-size:11px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Ticket",[89,1936],{"x":1883,"y":1923,"width":1884,"height":1924,"rx":92,"fill":110,"stroke":111,"style":143},[125,1938,1939],{"x":1887,"y":1928,"style":1933},"Priority",[89,1941],{"x":1891,"y":1923,"width":1892,"height":1924,"rx":92,"fill":110,"stroke":111,"style":143},[125,1943,1944],{"x":1895,"y":1928,"style":1933},"Clock hours",[89,1946],{"x":1899,"y":1923,"width":1900,"height":1924,"rx":92,"fill":110,"stroke":111,"style":143},[125,1948,1949],{"x":1903,"y":1928,"style":1933},"Hours used",[89,1951],{"x":1907,"y":1923,"width":1908,"height":1924,"rx":92,"fill":110,"stroke":111,"style":143},[125,1953,1954],{"x":1911,"y":1928,"style":1933},"Target",[89,1956],{"x":1915,"y":1923,"width":1916,"height":1924,"rx":92,"fill":110,"stroke":111,"style":143},[125,1958,1959],{"x":1919,"y":1928,"style":1933},"Breach",[89,1961],{"x":1867,"y":1962,"width":1868,"height":1924,"rx":92,"fill":152,"stroke":153,"style":1870},"68.0",[125,1964,638],{"x":1927,"y":1965,"style":1879},"87.0",[89,1967],{"x":1873,"y":1962,"width":1874,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,1969,1970],{"x":1877,"y":1965,"style":134},"T-5002",[89,1972],{"x":1883,"y":1962,"width":1884,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,1974,1975],{"x":1887,"y":1965,"style":134},"Normal",[89,1977],{"x":1891,"y":1962,"width":1892,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,1979,1980],{"x":1895,"y":1965,"style":134},"89.5",[89,1982],{"x":1899,"y":1962,"width":1900,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,1984,1985],{"x":1903,"y":1965,"style":134},"2.0",[89,1987],{"x":1907,"y":1962,"width":1908,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,1989,1274],{"x":1911,"y":1965,"style":134},[89,1991],{"x":1915,"y":1962,"width":1916,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,1993,1994],{"x":1919,"y":1965,"style":134},"FALSE",[89,1996],{"x":1867,"y":1997,"width":1868,"height":1924,"rx":92,"fill":152,"stroke":153,"style":1870},"96.0",[125,1999,2001],{"x":1927,"y":2000,"style":1879},"115.0","3",[89,2003],{"x":1873,"y":1997,"width":1874,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2005,2006],{"x":1877,"y":2000,"style":134},"T-5003",[89,2008],{"x":1883,"y":1997,"width":1884,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2010,2011],{"x":1887,"y":2000,"style":134},"High",[89,2013],{"x":1891,"y":1997,"width":1892,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2015,178],{"x":1895,"y":2000,"style":134},[89,2017],{"x":1899,"y":1997,"width":1900,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2019,2020],{"x":1903,"y":2000,"style":134},"0.67",[89,2022],{"x":1907,"y":1997,"width":1908,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2024,91],{"x":1911,"y":2000,"style":134},[89,2026],{"x":1915,"y":1997,"width":1916,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2028,1994],{"x":1919,"y":2000,"style":134},[89,2030],{"x":1867,"y":2031,"width":1868,"height":1924,"rx":92,"fill":152,"stroke":153,"style":1870},"124.0",[125,2033,91],{"x":1927,"y":2034,"style":1879},"143.0",[89,2036],{"x":1873,"y":2031,"width":1874,"height":1924,"rx":92,"fill":2037,"stroke":2038,"style":143},"#fce9e9","var(--danger,#dc2626)",[125,2040,2042],{"x":1877,"y":2034,"style":2041},"font-size:11px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","T-5006",[89,2044],{"x":1883,"y":2031,"width":1884,"height":1924,"rx":92,"fill":2037,"stroke":2038,"style":143},[125,2046,2047],{"x":1887,"y":2034,"style":2041},"Low",[89,2049],{"x":1891,"y":2031,"width":1892,"height":1924,"rx":92,"fill":2037,"stroke":2038,"style":143},[125,2051,98],{"x":1895,"y":2034,"style":2041},[89,2053],{"x":1899,"y":2031,"width":1900,"height":1924,"rx":92,"fill":2037,"stroke":2038,"style":143},[125,2055,1868],{"x":1903,"y":2034,"style":2041},[89,2057],{"x":1907,"y":2031,"width":1908,"height":1924,"rx":92,"fill":2037,"stroke":2038,"style":143},[125,2059,1284],{"x":1911,"y":2034,"style":2041},[89,2061],{"x":1915,"y":2031,"width":1916,"height":1924,"rx":92,"fill":2037,"stroke":2038,"style":143},[125,2063,2064],{"x":1919,"y":2034,"style":2041},"TRUE",[89,2066],{"x":1867,"y":2067,"width":1868,"height":1924,"rx":92,"fill":152,"stroke":153,"style":1870},"152.0",[125,2069,2071],{"x":1927,"y":2070,"style":1879},"171.0","5",[89,2073],{"x":1873,"y":2067,"width":1874,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2075,2076],{"x":1877,"y":2070,"style":134},"T-5008",[89,2078],{"x":1883,"y":2067,"width":1884,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2080,1975],{"x":1887,"y":2070,"style":134},[89,2082],{"x":1891,"y":2067,"width":1892,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2084,2085],{"x":1895,"y":2070,"style":134},"open",[89,2087],{"x":1899,"y":2067,"width":1900,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2089,2090],{"x":1903,"y":2070,"style":134},"6.5",[89,2092],{"x":1907,"y":2067,"width":1908,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2094,1274],{"x":1911,"y":2070,"style":134},[89,2096],{"x":1915,"y":2067,"width":1916,"height":1924,"rx":92,"fill":95,"stroke":153,"style":1870},[125,2098,1994],{"x":1919,"y":2070,"style":134},[25,2100,2102],{"id":2101},"common-pitfalls","Common pitfalls",[2104,2105,2106,2114,2120,2130,2136],"ul",{},[2107,2108,2109,2113],"li",{},[2110,2111,2112],"strong",{},"Clock time instead of business time."," Subtracting timestamps counts nights and weekends; clip to office hours on working days.",[2107,2115,2116,2119],{},[2110,2117,2118],{},"Forgetting holidays."," A bank holiday inside an interval adds a full day to the result unless it is in the holiday list.",[2107,2121,2122,2125,2126,23],{},[2110,2123,2124],{},"Timezones."," Tickets logged in UTC and an office working in local time give answers off by hours. Convert to the office's timezone and drop the timezone before calculating — see ",[19,2127,2129],{"href":2128},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fhandle-timezones-in-excel-timestamps-with-python\u002F","Handle Timezones in Excel Timestamps with Python",[2107,2131,2132,2135],{},[2110,2133,2134],{},"Lunch breaks and shifts."," Split days are several windows per day; loop over the windows in the reference version and sum.",[2107,2137,2138,2141,2142,2145,2146,2149,2150,23],{},[2110,2139,2140],{},"Different calendars per team."," A team working Sunday to Thursday needs ",[14,2143,2144],{},"weekmask=\"Sun Mon Tue Wed Thu\""," in ",[14,2147,2148],{},"is_busday"," and ",[14,2151,2152],{},"busday_count",[25,2154,2156],{"id":2155},"performance-notes","Performance notes",[10,2158,2159],{},"The loop version handles a few thousand tickets in a fraction of a second, which is enough for most weekly reports. The vectorised version does 100,000 tickets in well under a second, because everything is a whole-column operation. Keep both: the loop is the specification that people can read and check against a handful of real tickets, and the vectorised function is tested against it.",[25,2161,2163],{"id":2162},"conclusion","Conclusion",[10,2165,2166,2167,2169],{},"Define office hours, working days and holidays once. Count business time by clipping each interval to the office window on each working day — as a loop for clarity, and with ",[14,2168,752],{}," plus partial first and last days for speed — and check the two against each other. Map priorities to targets, measure open tickets up to a fixed report time, flag breaches and write the tickets and a per-priority summary to Excel.",[25,2171,2173],{"id":2172},"frequently-asked-questions","Frequently asked questions",[10,2175,2176,2179],{},[2110,2177,2178],{},"How do I calculate business hours between two datetimes in Python?","\nFor each working day between the two dates, add the part of the interval that falls inside office hours, skipping weekends and holidays with numpy.is_busday. For many rows, count the full working days with numpy.busday_count and add the partial first and last days.",[10,2181,2182,2185],{},[2110,2183,2184],{},"Does Excel have a function for working hours?","\nNot directly. NETWORKDAYS and NETWORKDAYS.INTL count whole working days; working hours need a longer formula that combines them with the start and end times, which is why many teams calculate it in Python instead.",[10,2187,2188,2191],{},[2110,2189,2190],{},"How do I exclude public holidays?","\nPass an array of holiday dates to numpy.is_busday and numpy.busday_count through the holidays argument. Keep the list in a file or a sheet that someone maintains for each year.",[10,2193,2194,2197],{},[2110,2195,2196],{},"What if the office works different days?","\nUse the weekmask argument, for example weekmask=\"Sun Mon Tue Wed Thu\" for a Sunday-to-Thursday week. Different hours per day, or split shifts, are easiest in the loop version with a list of windows per weekday.",[25,2199,2201],{"id":2200},"related","Related",[2104,2203,2204,2210,2217,2224],{},[2107,2205,2206,2207,2209],{},"Up one level: ",[19,2208,22],{"href":21}," — parsing, grouping and date arithmetic.",[2107,2211,2212,2216],{},[19,2213,2215],{"href":2214},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-business-days-between-excel-dates-with-pandas\u002F","Calculate Business Days Between Excel Dates with pandas"," — the whole-day version of this problem.",[2107,2218,2219,2223],{},[19,2220,2222],{"href":2221},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fconvert-excel-time-values-and-durations\u002F","Convert Excel Time Values and Durations"," — writing hours back as Excel durations.",[2107,2225,2226,2230],{},[19,2227,2229],{"href":2228},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-an-on-time-delivery-report\u002F","Build an On-Time Delivery Report"," — another report measured against promised times.",[2232,2233,2234],"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-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":35,"searchDepth":246,"depth":246,"links":2236},[2237,2238,2239,2240,2241,2242,2243,2244,2245,2246],{"id":27,"depth":246,"text":28},{"id":216,"depth":246,"text":217},{"id":745,"depth":246,"text":746},{"id":1237,"depth":246,"text":1238},{"id":1515,"depth":246,"text":1516},{"id":2101,"depth":246,"text":2102},{"id":2155,"depth":246,"text":2156},{"id":2162,"depth":246,"text":2163},{"id":2172,"depth":246,"text":2173},{"id":2200,"depth":246,"text":2201},"2026-09-29","Measure support tickets, approvals and orders against service levels in business hours: count only the time between opening and closing on working days, skip weekends and holidays, handle requests raised overnight, check a vectorised version against a simple one, flag SLA breaches and write the results to Excel.","md",[2251,2253,2255,2257],{"q":2178,"a":2252},"For each working day between the two dates, add the part of the interval that falls inside office hours, skipping weekends and holidays with numpy.is_busday. For many rows, count the full working days with numpy.busday_count and add the partial first and last days.",{"q":2184,"a":2254},"Not directly. NETWORKDAYS and NETWORKDAYS.INTL count whole working days; working hours need a longer formula that combines them with the start and end times, which is why many teams calculate it in Python instead.",{"q":2190,"a":2256},"Pass an array of holiday dates to numpy.is_busday and numpy.busday_count through the holidays argument. Keep the list in a file or a sheet that someone maintains for each year.",{"q":2196,"a":2258},"Use the weekmask argument, for example weekmask=\"Sun Mon Tue Wed Thu\" for a Sunday-to-Thursday week. Different hours per day, or split shifts, are easiest in the loop version with a list of windows per weekday.","calculate working hours between two timestamps",null,{"breadcrumb":2262},[2263,2265,2268],{"name":2264,"item":524},"Home",{"name":2266,"item":2267},"Advanced Data Transformation and Cleaning for Excel Automation","\u002Fadvanced-data-transformation-and-cleaning\u002F",{"name":22,"item":21},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-working-hours-between-two-timestamps",{"title":2271,"description":2272},"Business Hours Between Two Timestamps in Python and pandas","Calculate working hours between two datetimes in pandas: office hours, weekends and holidays with numpy busday_count, a clear loop version and a fast vectorised one that agree, SLA breach flags and an Excel report.","calculate-working-hours-between-two-timestamps","advanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-working-hours-between-two-timestamps\u002Findex",[2276,2277],"dates-and-times","pandas","how-to","md6g8s9y29_hAvSFS_WPtlWI1ssj1fRLuv4gdi7E7To",[2281,2285],{"title":2282,"path":2283,"stem":2284,"children":-1},"Calculate the Nth Weekday of a Month","\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-the-nth-weekday-of-a-month","advanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fcalculate-the-nth-weekday-of-a-month\u002Findex",{"title":2286,"path":2287,"stem":2288,"children":-1},"Convert Excel Time Values and Durations in Python","\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fconvert-excel-time-values-and-durations","advanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fconvert-excel-time-values-and-durations\u002Findex",[2290,2292],{"path":2291,"title":2266},"\u002Fadvanced-data-transformation-and-cleaning",{"path":2293,"title":22},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data",[],[2296,2299,2302,2305,2308,2311],{"path":2297,"title":2298,"kind":2278},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fhighlight-overdue-dates-with-openpyxl","Highlight Overdue Dates in Excel with openpyxl",{"path":2300,"title":2301,"kind":2278},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fhighlight-weekends-and-holidays-in-a-date-column","Highlight Weekends and Holidays in a Date Column",{"path":2303,"title":2304,"kind":2278},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fkeep-the-latest-record-per-key-with-pandas","Keep the Latest Record per Key When Deduplicating with pandas",{"path":2306,"title":2307,"kind":2278},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fcalculate-moving-averages-with-pandas-rolling","Calculate Moving Averages with pandas rolling",{"path":2309,"title":2310,"kind":2278},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Feomonth-and-edate-equivalents-in-pandas","EOMONTH and EDATE Equivalents in pandas",{"path":2312,"title":2313,"kind":2278},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fyearfrac-and-days360-equivalents-in-python","YEARFRAC and DAYS360 Equivalents in Python",1790972103210]