A simple diff for date/time duration changes

I was tasked with a seemingly small feature request in an application; send an email notification when an event is changed. The challenge began when we should report changes in event time/dates. An event might be scheduled for “tuesday 10:00”, “tuesday 10:00-12:00” or “tuesday 10:00 to wednedsay 12:00”. When an event is changes, we might change the start date, start time, end date, end time, or any combination of these. And when parts isn’t changes, we shouldn’t include it to avoid a wall of text where it’s difficult to spot what is actually changed.

Needless to say, there’s a lot of edge-cases here. I started by diffing parts, seeing if they have been changed, and if I should include them. But this turned into a if-else spaghetti where I needed to look at unrelated parts – for instance when the date has changed, it makes sense to include the end time even though that didn’t change.

I took a step back to design a more maintainable algorithm. While the resulting algorithm only is mildly interesting, the process of looking at the data and designing it is probably useful for other problems.

I wrote down all permutations of possible cases I could print. D is date, T is time. The table is a bit cryptic, but when writing the last form, I found a way to redesign the algorithm.

from to
@ T @ T
@ T @ T-T
@ T-T @ T
@ T-T @ T-T
D D
D D-D
D-D D
D-D D-D
D @ T D @ T
D @ T D @ T-T
D @ T-T D @ T
D @ T-T D @ T-T
D-D @ T D-D @ T
D-D @ T-T D-D @ T
D-D @ T D-D @ T-T
D-D @ T-T D-D @ T-T

The last row is the canonical form, e.g. from Tue @ 10:00-10:00 to Tue @ 10:00-10:00. I had previously designed the algorithm by trying to see what parts I should include, but a better design is to start at the canonical form and see what parts I can hide.

We have four data points:

At two distinct times:

With two different diff states:

unchanged
old and new value is the same
changed
old and new value differ

This leaves us with 4*2*2*2=16 different cases, as is reported in the table above.

Looking at the table, we can also see that we have three different states for rendering:

hide
We don’t need to show this
single
Only need to show the old value
interval
Need to show the entire interval
type private ShowField =
    | Hide
    | Single
    | Interval

We group the data points in intervals such that we have (start, end) pairs in order to see if we need to render an interval or can just render a single value. If the start and end is the same, we can render just a single value, but might need to render both if they differ.

let field (a, b) =
    if a = b then Single else Interval

We run this check for all parts:

But if both the corresponding old and new is the same, we can Hide the entire field.

let diff old new' =
    if old = new'
    then (Hide, Hide)
    else (field old, field new')

let (dt0, dt1) = diff (oldStart.Date, oldEnd.Date) (newStart.Date, newEnd.Date)
let (tm0, tm1) = diff (oldStart.TimeOfDay, oldEnd.TimeOfDay) (newStart.TimeOfDay, newEnd.TimeOfDay)

At this point, we have both checked how we might want to render it, and we have diffed the old and new parts.

Looking at the table, we see we want to group our fields by date and time. Now it’s time to format the “from” and “to” columns from the table. This function will look at the ShowField of the date and time combined, giving 3*3=9 cases to handle, and a quite maintainable lookup table for these.

let fmt dt tm start' end' =
    match (dt, tm) with
    | Interval, Interval -> $"{fmtDt start'} @ {fmtTm start'}-{fmtDt end'} @ {fmtTm end'}"
    | Interval, Single   -> $"{fmtDt start'}-{fmtDt start'} @ {fmtTm start'}"
    | Interval, Hide     -> $"{fmtDt start'}-{fmtDt end'}"
    | Single  , Interval -> $"{fmtDt start'} @ {fmtTm start'}-{fmtTm end'}"
    | Single  , Single   -> $"{fmtDt start'} @ {fmtTm start'}"
    | Single  , Hide     -> $"{fmtDt start'}"
    | Hide    , Interval -> $"@ {fmtTm start'}-{fmtTm end'}"
    | Hide    , Single   -> $"@ {fmtTm start'}"
    | Hide    , Hide     -> $""

match ((fmt dt0 tm0 oldStart oldEnd), (fmt dt1 tm1 newStart newEnd)) with
| ("", "") -> None
| x -> Some x

By flipping the design from “how can I show” to “what can I hide from the canonical form”, we transitioned from a maintenance nightmare to a quite elegant algorithm.

Here is the complete code.

type private ShowField =
    | Hide
    | Single
    | Interval

let toReadableDiff (oldStart: DateTime) (oldEnd: DateTime) (newStart: DateTime) (newEnd: DateTime) : (string*string) option =
    let fmtDt (dt: DateTime) =
        $"{dt:yy}.{dt:MM}.{dt:dd}"

    let fmtTm (dt: DateTime) =
        $"{dt:HH}:{dt:mm}"

    let field (a, b) =
      if a = b then Single else Interval

    let diff old new' =
      if old = new'
      then (Hide, Hide)
      else (field old, field new')

    let (dt0, dt1) = diff (oldStart.Date, oldEnd.Date) (newStart.Date, newEnd.Date)
    let (tm0, tm1) = diff (oldStart.TimeOfDay, oldEnd.TimeOfDay) (newStart.TimeOfDay, newEnd.TimeOfDay)

    let fmt dt tm start' end' =
        match (dt, tm) with
        | Interval, Interval -> $"{fmtDt start'} @ {fmtTm start'}-{fmtDt end'} @ {fmtTm end'}"
        | Interval, Single   -> $"{fmtDt start'}-{fmtDt start'} @ {fmtTm start'}"
        | Interval, Hide     -> $"{fmtDt start'}-{fmtDt end'}"
        | Single  , Interval -> $"{fmtDt start'} @ {fmtTm start'}-{fmtTm end'}"
        | Single  , Single   -> $"{fmtDt start'} @ {fmtTm start'}"
        | Single  , Hide     -> $"{fmtDt start'}"
        | Hide    , Interval -> $"@ {fmtTm start'}-{fmtTm end'}"
        | Hide    , Single   -> $"@ {fmtTm start'}"
        | Hide    , Hide     -> $""

    match ((fmt dt0 tm0 oldStart oldEnd), (fmt dt1 tm1 newStart newEnd)) with
    | ("", "") -> None
    | x -> Some x

Date: 2022-08-09 Tue 00:00

Author: Simen Endsjø

Created: 2023-07-20 Thu 20:10