Forum Discussion
Calculate overlapping hours
Hello,
I need to report how many hours a staff member supervised one or more volunteers. For a very simplified example,
| Volunteer Name | Date | Start Time | End Time | Supervisor |
| Fred | 1/1/26 | 1:00pm | 3:00pm | Lucy |
| Ethel | 1/1/26 | 2:30pm | 4:30pm | Lucy |
Here 4 volunteer hours were served, but because there was a 30 minute overlap, Lucy only supervised for 3.5 hours.
Is there a way to get Excel to calculate that? To say: look at all the entries with matching date and matching supervisor, and add up non-overlapping time.
I'm not expecting this to be possible, but I thought I'd ask. Thanks!
Hi
To help people who search for existing solutions it would be smart of you to revise the title of this thread with Calculate non-overlapping hours ;-) - Thanks
With Power Query > Pivot Report:
Added dummy records to those you provided. Looks good to me...
#1 Please double-check and confirm (or not). Sample (to download) avail. at:
https://1drv.ms/x/c/1cd824d35610aacb/IQCkJLQ4qI2hQ6OG2-ntg7wiATHCsDR7AVWdpHB_O7bJjgs?e=xn9UML
#2 Let me know if you prefer a flat Table instead of a PivotTable Report
How to use:
With your data in TableData > Right-click somewhere in PivotTable Report > RefreshNotes:
1) If you double-click on a number in the [Total Hours] col. of the PivotTable Report a sheet will give you the details of what made that number. However, you might get less records than in TableData as the query filters out some records for perf. reason.
As an ex. : on 11/06/25 Freddie has 9 records but double-clicking on his 10.5 [Total Hours] for that date you get 8 records. The one that's filtered out is:because [Start Time] > [End Time]
2) Also filtered out are records, where for the same [Supervisor] on a given [Date], we have duplicated [Start Time] & [End Time]
3) With the provided sample dataset, [Supervisor] Foo isn't reported as it has only 1 record where [End Time] = [Start Time]
14 Replies
- LorenzoSilver Contributor
Another Power Query alternative adapted from a solution (https://stackoverflow.com/questions/79487991/combine-rows-if-the-dates-exist-in-another-row) developed by Ron Rosenfeld - Credit OliverScheurich for finding that case
// TableData let Source = Excel.CurrentWorkbook(){[Name="TableData"]}[Content], FilteredTime = Table.SelectRows( Source, each [End Time] > [Start Time] ), ChangedTypes = Table.TransformColumnTypes( FilteredTime, { {"Volunteer Name", type text}, {"Date", type date}, {"Start Time", type time}, {"End Time", type time}, {"Supervisor", type text} } ) in ChangedTypes // RonRosenfeld_NoOverlap // Adapted from solutiuon posted by Ron Rosenfeld: // https://stackoverflow.com/questions/79487991/combine-rows-if-the-dates-exist-in-another-row let Source = TableData, SelectedColumns = Table.SelectColumns( Source, {"Supervisor", "Date", "Start Time", "End Time"} ), // Group by Date & Supervisor NoOverlapBySupervisorDate = Table.Group( SelectedColumns, {"Date", "Supervisor"}, // Add [Shifted End Time] column to each sorted subgroup {"Shifted", (t) => let RemovedColumns = Table.RemoveColumns( t, {"Date", "Supervisor"} ), SortedStartTime = Table.Sort( RemovedColumns, {"Start Time",Order.Ascending} ), ShiftedEndTime = Table.FromColumns( Table.ToColumns( SortedStartTime ) & { {null} & List.RemoveLastN( Table.Column( SortedStartTime,"End Time"), 1 ) }, type table [Start Time = time, End Time = time, Shifted End Time = nullable time] ), // Overlap exists if [Start time] is less than the [Shifted End time] TimeOverlaps = Table.AddColumn( ShiftedEndTime, "Overlap", each [Start Time] < [Shifted End Time], type logical ), // First row in [Overlap] column will be null so we fill up from the second row to see if they overlap FilledUpOverlap = Table.FillUp( TimeOverlaps, {"Overlap"} ) in // Group each subtable by the logical overlap Table.Group( FilledUpOverlap, "Overlap", { {"Start Time", each List.Min( [Start Time] ), type time}, {"End Time", each List.Max( [End Time] ), type time} }, GroupKind.Local, (x, y) => Number.From( y <> true ) ), type table [Overlap = logical, Start Time = time, End Time = time] } ), ExpandedTimeColumns = Table.ExpandTableColumn( NoOverlapBySupervisorDate, "Shifted", {"Start Time", "End Time"} ), AddedHours = Table.AddColumn(ExpandedTimeColumns, "Hours", each Duration.TotalHours( [End Time] - [Start Time] ), type number ) in AddedHoursUpdated workbook avail. at https://1drv.ms/x/c/1cd824d35610aacb/IQCkJLQ4qI2hQ6OG2-ntg7wiATHCsDR7AVWdpHB_O7bJjgs?e=xn9UML
- PeterBartholomew1Silver Contributor
This has a limitation of only addressing data for a single supervisor.
= LET( starts, EXPAND(startTimes,,2,1), finish, EXPAND(endTimes,,2,-1), stack, SORT(VSTACK(starts, finish)), headcount, SCAN(0, TAKE(stack,,-1), SUM), event, VSTACK(TAKE(stack,,1), TAKE(stack,-1,1)), duration, DROP(event, 1) - DROP(event,-1), SUM(IF(headcount, duration)) )It builds a table that has a row for every event (volunteer arrivals and departures) and accumulates the headcount to determine which intervals are paid.
- djclementsSilver Contributor
Nice logic. As a LAMBDA function, it can be applied to the results of GROUPBY-THUNK to handle all supervisors:
= LET( grp, GROUPBY(tblData[Supervisor], tblData[Date] + tblData[[Start Time]:[End Time]], THUNK, 0, 0,, tblData[Start Time] < tblData[End Time]), HSTACK(DROP(grp,, -2), MAP(CHOOSECOLS(grp, -2), CHOOSECOLS(grp, -1), GroupOverlapHours)) )Where GroupOverlapHours is defined as:
= LAMBDA(startTime,endTime, LET( starts, EXPAND(startTime(),, 2, 1), finish, EXPAND(endTime(),, 2, -1), stack, SORT(VSTACK(starts, finish)), headcount, SCAN(0, TAKE(stack,, -1), SUM), event, VSTACK(TAKE(stack,, 1), TAKE(stack, -1, 1)), duration, DROP(event, 1) - DROP(event, -1), SUM(IF(headcount, duration)) * 24 ) )Alternative definition, using my VALS/EVALS functions:
= LAMBDA(startTime,endTime, LET( one, startTime(), two, endTime(), arr, SCAN(VALS3(), SORTBY(MAP(one, two, VALS2), one, 1, two, 1), LAMBDA(acc,val, LAMBDA(beg,end,low,upp,lId, IF(AND(beg >= low, beg <= upp), VALS3(low, MAX(upp, end), lId), VALS3(beg, end, lId + 1)))( val(1), val(2), acc(1), acc(2), acc(3)) ) ), grp, TAKE(GROUPBY(EVALS(arr, 3, 1), arr, LAMBDA(x, INDEX(x, ROWS(x), 1)), 0, 0),, -1), SUM(EVALS(grp, 2, 1) - EVALS(grp, 1, 1)) * 24 ) )Additional resources: https://gist.github.com/RackofLambda
- PeterBartholomew1Silver Contributor
Hi David
That is truly impressive!
I probably should have taken it one step further to present the formula as a Lambda function, as you did with GroupOverlapHours. I am coming to think that practically every calculation should be held within a well-documented Lambda function. Your use of GROUPBY to marshal complex calculations is also impressive. Far from being the poor man's equivalent of the Pivot Table, GROUPBY and PIVOTBY appear to have hidden depths that allow them to go well beyond simple aggregations.
The use of thunks also presents some interesting choices. In some cases they appear to be used to insert fragments of code for conditional execution; other times it is more a matter of referencing precalculated values held within memory for repeated use.
- LorenzoSilver Contributor
Provided Copilot (ChatGPT 5.1 mode) the code of the Power Query marked as solution and asked:
1) Find an alternative ==> Worked 1st time
2) Add test in case there's only 1 record per Date & Supervisor group
3) Add commentsCopilot_NoOverlap_v1:
// TableData let Source = Excel.CurrentWorkbook(){[Name="TableData"]}[Content], FilteredTime = Table.SelectRows( Source, each [End Time] > [Start Time] ), ChangedTypes = Table.TransformColumnTypes( FilteredTime, { {"Volunteer Name", type text}, {"Date", type date}, {"Start Time", type time}, {"End Time", type time}, {"Supervisor", type text} } ) in ChangedTypes // Copilot_NoOverlap_v1 let Source = TableData, // Input table Grouped = Table.Group( Source, {"Date", "Supervisor"}, // Group by Date + Supervisor {"Hours", (tbl as table) as number => let RowCount = Table.RowCount(tbl), // Count rows in group Result = if RowCount = 1 then // Single interval in group Duration.TotalHours( tbl[End Time]{0} - tbl[Start Time]{0} ) // Safe because TableData filters invalid rows // Clamp negative durations to 0 else // Multi‑interval case let Starts = tbl[Start Time], // List of StartTime Ends = tbl[End Time], // List of EndTime TimeFlags = List.Combine( // Build Start(+1)/End(-1) events { List.Transform(Starts, each [Time = _, Flag = 1]), // Start event List.Transform(Ends, each [Time = _, Flag = -1]) // End event } ), SortedTimeFlags = List.Sort( TimeFlags, // Sort by Time, End before Start (a, b) => if a[Time] < b[Time] then -1 else if a[Time] > b[Time] then 1 else if a[Flag] < b[Flag] then -1 else 1 ), seed = [Active = 0, LastTime = null, Total = #duration(0,0,0,0)], // Initial accumulator state TimelineState = List.Accumulate( SortedTimeFlags, seed, // Walk timeline and accumulate coverage (state, current) => let currentTime = current[Time], // Current event time activeBefore = state[Active], // Active intervals before event total = if activeBefore > 0 and state[LastTime] <> null then state[Total] + (currentTime - state[LastTime]) // Add covered duration else state[Total], // No active coverage newActive = activeBefore + current[Flag] // Update active count in [ Active = newActive, // New active count LastTime = currentTime, // Update last timestamp Total = total // Updated total duration ] ) in Duration.TotalHours(TimelineState[Total]) // Convert duration to hours in Result, // Return hours for this group type number // Output type } ), SortedSupervisorByDate = Table.Sort( Grouped, {{"Date", Order.Ascending}, {"Supervisor", Order.Ascending}} ) in SortedSupervisorByDate- LorenzoSilver Contributor
To reduce dependency on Records (could be "slow" on large tables) :
1) Provided Copilot code to replace its initial steps with a Table (vs. Records) approach
2) Provided it directions to build function BuildFlagTable
3) Asked it to integrate everything and add commentsCopilot_NoOverlap_v2:
// TableData let Source = Excel.CurrentWorkbook(){[Name="TableData"]}[Content], FilteredTime = Table.SelectRows( Source, each [End Time] > [Start Time] ), ChangedTypes = Table.TransformColumnTypes( FilteredTime, { {"Volunteer Name", type text}, {"Date", type date}, {"Start Time", type time}, {"End Time", type time}, {"Supervisor", type text} } ) in ChangedTypes // CopilotNoOverlap_v2 let Source = TableData, // Build a Time/Flag table for a given column and flag BuildFlagTable = (tbl as table, columnName as text, flag as number) as table => let Renamed = Table.RenameColumns( Table.SelectColumns(tbl, {columnName}), {{columnName, "Time"}} ), // Extract and rename to 'Time' WithFlag = Table.AddColumn( Renamed, "Flag", each flag, Int64.Type ) // Add flag (+1 or -1) in WithFlag, // Build combined Start(+1) and End(-1) table BuildTimeFlagsTable = (tbl as table) as table => let Starts = BuildFlagTable(tbl, "Start Time", 1), // Start events Ends = BuildFlagTable(tbl, "End Time", -1) // End events in Table.Combine({Starts, Ends}), // Append both tables Grouped = Table.Group( Source, {"Date", "Supervisor"}, // Group by Date + Supervisor {"Hours", (tbl as table) as number => let RowCount = Table.RowCount(tbl), // Count rows in group Result = if RowCount = 1 then // Single interval in group Duration.TotalHours( tbl[End Time]{0} - tbl[Start Time]{0} ) // Safe due to TableData filter else // Multi‑interval case let TimeFlagsTable = BuildTimeFlagsTable(tbl), // Build Start/End events SortedTimeFlagsTable = Table.Sort( TimeFlagsTable, { {"Time", Order.Ascending}, {"Flag", Order.Descending} } ), // Sort by Time, End before Start SortedTimeFlags = Table.ToRecords(SortedTimeFlagsTable), // Convert to list of records seed = [Active = 0, LastTime = null, Total = #duration(0,0,0,0)], // Initial accumulator TimelineState = List.Accumulate( SortedTimeFlags, seed, (state, current) => let currentTime = current[Time], // Current event time activeBefore = state[Active], // Active intervals before event total = if activeBefore > 0 and state[LastTime] <> null then state[Total] + (currentTime - state[LastTime]) // Add covered duration else state[Total], // No active coverage newActive = activeBefore + current[Flag] // Update active count in [ Active = newActive, // New active count LastTime = currentTime, // Update last timestamp Total = total // Updated total duration ] ) in Duration.TotalHours(TimelineState[Total]) // Convert duration to hours in Result, // Return hours for this group type number } ), SortedSupervisorByDate = Table.Sort( Grouped, {{"Date", Order.Ascending}, {"Supervisor", Order.Ascending}} ) in SortedSupervisorByDateUpdated workbook at: https://1drv.ms/x/c/1cd824d35610aacb/IQCkJLQ4qI2hQ6OG2-ntg7wiATHCsDR7AVWdpHB_O7bJjgs?e=akf0vp
- volunteersfplCopper Contributor
I understand pivot tables a bit, so I'm going to go with Lorenzo Lorenzo's solution, but will also copy IlirU's code for reference--thanks to you both!
mathetes, for my use, there is no deeper purpose. There is a yearly report that requires an entry for how many staff hours were spent supervising volunteers, so I'm just looking for a way to report good-quality data to meet that requirement. When I set up this summer's time reporting form, volunteers will report precise times, I just rounded to 15 minutes for dummy-data purposes.
- mathetesGold Contributor
It looks to me as if Lorenzo has given you an effective answer. I was about to ask some more defining questions, which I will do anyway, in case his response doesn't work for you (if, for example, Power Query is a feature you haven't used and don't understand; no shame in that: I understand it, but have never used it myself).
My questions--and I'm writing as a person who at one time was the director of the HR & compensation database for a major corporation, so these are questions I would have asked at the time of a management request for a report on time spent--get at the deeper purpose being served. Let me first refer you back to my initial response, where I asked these questions:
- Are they only paid for time supervising?
- Are they paid more for time supervising than time spent at work but not supervising?
- If they're paid a straight hourly (or salary) regardless of whether time is spent supervising, do you still need to know specifically the amount of time spent supervising? Why? How is it used?
Part of what these questions are trying to get at is this: How precise do you really need to be? You're already (it would appear) rounding times to the nearest quarter hour. Which means your total times might well deviate from reality by significant margin. Does that matter? To ask it again: How precise do you really need to be? Would it, for example, be just as valuable, maybe more so(!), to know that Susy routinely supervises two or three people a day (regardless of hours), while Larry rarely supervises more than one? And to repeat the question above, are these staff members tracking their own time--are they paid hourly or on salary--and are they compensated differently for time spent supervising?
How is this summary data on hours spent supervising used? What difference does it make in your management of these staff people?
- LorenzoSilver Contributor
Hi
To help people who search for existing solutions it would be smart of you to revise the title of this thread with Calculate non-overlapping hours ;-) - Thanks
With Power Query > Pivot Report:
Added dummy records to those you provided. Looks good to me...
#1 Please double-check and confirm (or not). Sample (to download) avail. at:
https://1drv.ms/x/c/1cd824d35610aacb/IQCkJLQ4qI2hQ6OG2-ntg7wiATHCsDR7AVWdpHB_O7bJjgs?e=xn9UML
#2 Let me know if you prefer a flat Table instead of a PivotTable Report
How to use:
With your data in TableData > Right-click somewhere in PivotTable Report > RefreshNotes:
1) If you double-click on a number in the [Total Hours] col. of the PivotTable Report a sheet will give you the details of what made that number. However, you might get less records than in TableData as the query filters out some records for perf. reason.
As an ex. : on 11/06/25 Freddie has 9 records but double-clicking on his 10.5 [Total Hours] for that date you get 8 records. The one that's filtered out is:because [Start Time] > [End Time]
2) Also filtered out are records, where for the same [Supervisor] on a given [Date], we have duplicated [Start Time] & [End Time]
3) With the provided sample dataset, [Supervisor] Foo isn't reported as it has only 1 record where [End Time] = [Start Time]
- sophieturnerBrass Contributor
Yes, it’s possible, but it takes a small setup. The idea is to combine all time ranges for the same date and supervisor, then calculate the total span without double-counting overlaps. In your example, Lucy’s supervision runs from 1:00pm to 4:30pm, which equals 3.5 hours. You can do this in Excel by finding the earliest start time and latest end time for each supervisor and date, then subtracting the two. This works well as long as there are no gaps in supervision time.
- mathetesGold Contributor
Given that example, it's incredibly easy to calculate the 3.5 hours. Simply subtract the minimum of Lucy's start times from the maximum of her end times.
(making assumptions about which columns contain the relevant data),
time spent supervising =MAX(D2:D3)-MIN(C2:C3)Granted, that doesn't deal with other staff members and other days; just wanted to make the point that a simple example doesn't require much complications in solving.
But as you point out in asking the question, it's a very simplified example. Give us a more typical full day, complete with some examples that make it complicated. Along the way, can you explain the rules here that govern Lucy's (and other's) staff compensation, in particular
- Are they only paid for time supervising?
- Are they paid more for time supervising than time spent at work but not supervising?
- If they're paid a straight hourly (or salary) regardless of whether time is spent supervising, do you still need to know specifically the amount of time spent supervising? Why? How is it used?
By the way, you should expect it to be possible, whatever it is. Excel is very powerful; the challenge often is getting the problem clearly set forth and articulated.
- volunteersfplCopper Contributor
Yes, the real-world situation is 300+ volunteers entering shifts across 3 summer months at 28 locations with 30-40 supervisors, and shifts aren't necessarily adjacent, so Lucy might supervise 2 volunteers from 10:00-noon, 1 volunteer from 11:30am-1pm, and then have an hour off and then have volunteers in from 3:00-5:00pm, so just taking MIN to MAX (10am-5pm) wouldn't be accurate. Some volunteers come in every day, some only once a week, but by the end of the summer, I have upwards of 10,000 rows of time entries.
I have had to just report volunteer hours, but now I need to report how many staff hours were spent supervising volunteers, so I would change how time is logged to have start and end time, instead of total hours.
This is one month of time entries at one location. Not difficult to go through and manually calculate, but time consuming at scale.
Volunteer Name Date Start Time End Time Supervisor
G 5/17/2025 10:00 AM 1:15 PM 2
L 5/17/2025 10:00 AM 12:00 PM 1
O 6/6/2025 1:00 PM 2:00 PM 2
O 6/9/2025 2:00 PM 4:00 PM 2
W 6/9/2025 3:00 PM 5:00 PM 2
T 6/10/2025 10:00 AM 12:00 PM 2
I 6/11/2025 10:00 AM 12:00 PM 3
S 6/11/2025 1:00 PM 3:00 PM 1
T 6/11/2025 2:00 PM 4:00 PM 3
T 6/12/2025 11:00 AM 1:00 PM 2
Z 6/12/2025 11:00 AM 5:00 PM 3
B 6/13/2025 2:00 PM 4:00 PM 2
D 6/13/2025 1:00 PM 3:00 PM 2
N 6/13/2025 2:00 PM 4:00 PM 2(wouldn't publish copied table so sorry for lack of formatting)
- IlirUBrass Contributor
Hi volunteersfpl,
Does the following formula meet your requirements? The formula works in Excel 365.
=LET( table, A1:D15, d, DROP(table, 1), date, CHOOSECOLS(d, 2), startt, CHOOSECOLS(d, 3), endt, CHOOSECOLS(d, 4), dr, DROP(GROUPBY(date, endt, MAX,, 0) * 24,, 1), VSTACK(HSTACK(TAKE(TAKE(table, 1),, 2), "Hours"), CHOOSECOLS(HSTACK(GROUPBY(date, CHOOSECOLS(d, 1), ARRAYTOTEXT,, 0), (BYROW(IFNA(--TEXTSPLIT(TEXTJOIN(";",, BYROW(--TEXTSPLIT(TEXTJOIN(";",, BYROW(IFNA(HSTACK(--TEXTSPLIT(TEXTJOIN(";",, BYROW(IFNA(--TEXTSPLIT(TEXTJOIN(";",, DROP(GROUPBY(date, MAP(startt, endt, LAMBDA(a,b, ARRAYTOTEXT(SEQUENCE(, INT((b - a) * 24), a * 24)))), ARRAYTOTEXT,, 0),, 1)), ", ", ";"), ""), LAMBDA(c, ARRAYTOTEXT(UNIQUE(c, TRUE))))), ", ", ";", TRUE), INT(dr)), ""), LAMBDA(z, ARRAYTOTEXT(SCAN(0, z, LAMBDA(a,b, IF(b = "", a, b))))))), ", ", ";"), LAMBDA(w, ARRAYTOTEXT(UNIQUE(w, TRUE))))), ", ", ";"), ""), COUNT) - 1) / 24 + MOD(dr, 1) / 24), {2,1,3})))Hope this helps.
IlirU