Calculating users' "busy duration" taking into account overlapping activities

Viewed 94

I've been trying to work out a solution to this problem but so far haven't been able to work it out. I'm using Oracle.

I have a set of data that looks like this:

| USER   | ACTIVITY   | START_TIME      | END_TIME        | DURATION |
|--------|------------|-----------------|-----------------|----------|
| jsmith | Front Desk | 2020-08-24 8:00 | 2020-08-24 9:30 | 90       |
| jsmith | Phones     | 2020-08-24 8:15 | 2020-08-24 8:45 | 30       |
| jsmith | Phones     | 2020-08-24 9:45 | 2020-08-24 9:50 | 5        |
| bjones | Phones     | 2020-08-24 9:00 | 2020-08-24 9:10 | 10       |
| bjones | Front Desk | 2020-08-24 9:05 | 2020-08-24 9:15 | 10       |
| bjones | Phones     | 2020-08-24 9:15 | 2020-08-24 9:45 | 30       |

The above output can be generated from the following query:

SELECT
    USER,
    ACTIVITY,
    START_TIME,
    END_TIME,
    DURATION
FROM USER_ACTIVITIES
    WHERE USER IN ('jsmith', 'bjones')
    AND START_TIME BETWEEN '2020-08-24 00:00:00' AND '2020-08-25 00:00:00'
    ORDER BY USER, START_TIME, END_TIME
;

I need to calculate the total "busy" time per user, taking into account that some of the activities overlap each other. Using the existing query I'll get a total duration per user of 125 for jsmith and 50 for bjones, However since some of the activities overlapped this doesn't reflect the total amount of time the users were busy.

The output I'm looking for is the total busy duration per day by user:

| USER   | DATE       | DURATION |
|--------|------------|----------|
| jsmith | 2020-08-24 | 95       |
| bjones | 2020-08-24 | 45       |

Any help with this would be greatly appreciated.

4 Answers

You can unpivot the minutes first, and then exempt the non-overlapping interval through use of NOT EXISTS (I didn't consider the day interval due to this case, you can add EXTRACT( hour FROM max_end_time - min_start_time )*3600 if needed for other calculation cases )

WITH  t AS
(  
SELECT "user" , MIN(start_time) AS min_start_time, MAX(end_time) AS max_end_time
  FROM user_activities
 GROUP BY "user" 
), t2 AS
(
 SELECT "user", min_start_time + NUMTODSINTERVAL(level, 'minute') AS minutes
   FROM t  
CONNECT BY level <= EXTRACT( hour FROM max_end_time - min_start_time )*60 + 
                    EXTRACT( minute FROM max_end_time - min_start_time ) 
    AND PRIOR SYS_GUID() IS NOT NULL
    AND PRIOR "user" = "user"
)
SELECT "user", COUNT(*) AS "Duration"
  FROM t2  
 WHERE EXISTS ( SELECT * 
                  FROM user_activities 
                 WHERE minutes BETWEEN start_time and end_time
                   AND "user" = t2."user" )
 GROUP BY "user" 

        

Demo

I would address this with gaps-and-islands techniques rather than recursion:

select usr, sum(duration) * 24 * 60 duration 
from (
    select usr, max(end_time) - min(start_time) duration
    from (
        select 
            ua.*,
            sum(case when start_time <= lag_end_time then 0 else 1 end) over(partition by usr order by start_time) grp
        from (
            select 
                ua.*,
                lag(end_time) over(partition by usr order by start_time) lag_end_time
            from user_activities ua
        ) ua
    ) ua
    group by usr, grp
) ua
group by usr 

The idea is to build groups of records having the same user and overlapping periods, using a window sum. You can then take the difference between the end and start of each "island", and finally aggregate per user.

The below code requires at least 12c:

WITH user_activities( "user", activity, start_time, end_time ) AS
(
  SELECT 'jsmith', 'Front Desk', timestamp'2020-08-24 08:00:00' , timestamp'2020-08-24 09:30:00' FROM dual UNION ALL
  SELECT 'jsmith', 'Phones'    , timestamp'2020-08-24 08:15:00' , timestamp'2020-08-24 08:45:00' FROM dual UNION ALL
  SELECT 'jsmith', 'Phones'    , timestamp'2020-08-24 09:45:00' , timestamp'2020-08-24 09:50:00' FROM dual UNION ALL
  SELECT 'bjones', 'Phones'    , timestamp'2020-08-24 09:00:00' , timestamp'2020-08-24 09:10:00' FROM dual UNION ALL
  SELECT 'bjones', 'Front Desk', timestamp'2020-08-24 09:05:00' , timestamp'2020-08-24 09:15:00' FROM dual UNION ALL
  SELECT 'bjones', 'Phones'    , timestamp'2020-08-24 09:15:00' , timestamp'2020-08-24 09:45:00' FROM dual 
)
select  "user", sum(durations) as durations
from     
(
    select "user",  extract(hour from (end_time - start_time)) * 60 + extract(minute from (end_time - start_time)) as durations
    from    user_activities 
    match_recognize
    (
        partition by "user"
        order by     start_time, end_time
        measures     first(start_time) start_time, max(end_time) as end_time
        pattern      (a* b)
        define       a as max(end_time) >= next(start_time)
    ) 
)
group by "user";

This should solve your problem if you are interested in match_recognize

output:

enter image description here

Many possible solutions. Here is another one: using CTE, first calculate the clean end time (if following start time is earlier than end time take following start time instead) using the LEAD function. Then sum and group by user:

WITH sampledata (username,activity,start_time,end_time)
AS
(
  SELECT 'jsmith', 'Front Desk' ,'2020-08-24 8:00','2020-08-24 9:30' FROM DUAL UNION ALL
  SELECT 'jsmith', 'Phones'     ,'2020-08-24 8:15','2020-08-24 8:45' FROM DUAL UNION ALL
  SELECT 'jsmith', 'Phones'     ,'2020-08-24 9:45','2020-08-24 9:50' FROM DUAL UNION ALL
  SELECT 'bjones', 'Phones'     ,'2020-08-24 9:00','2020-08-24 9:10' FROM DUAL UNION ALL
  SELECT 'bjones', 'Front Desk' ,'2020-08-24 9:05','2020-08-24 9:15' FROM DUAL UNION ALL
  SELECT 'bjones', 'Phones'     ,'2020-08-24 9:15','2020-08-24 9:45' FROM DUAL  
), clean_sampledata (username,activity,start_time,end_time)
AS
(
SELECT 
  username,
  activity,
  TO_DATE(start_time,'YYYY-MM-DD HH24:MI'),
  TO_DATE(end_time,'YYYY-MM-DD HH24:MI')
  FROM sampledata
), clear_overlapped (username,activity,start_time,clean_end_time)
AS
(
SELECT 
  username,
  activity,
  start_time,
  NVL(LEAST(LEAD(start_time)  OVER (PARTITION BY username ORDER BY start_time),end_time),end_time)
  FROM clean_sampledata
), cleaned_minutes_per_username (username,mins)
AS
(
SELECT 
  username,
  ROUND((clean_end_time - start_time) * 1440)
  FROM clear_overlapped
)
SELECT
  username,
  SUM(mins)
  FROM cleaned_minutes_per_username
  GROUP BY username ;

bjones  45
jsmith  50
Related