Skip to content
Kumar Chandrachooda
.NET

Guessing Your Shift with Manhattan Distance

Badge gates record when you arrived, not which shift you worked - so the platform infers it, scoring every candidate shift by L1 distance in time-of-day space and letting the smallest number win.

By Kumar Chandrachooda 21 May 2025 5 min read
Two time axes, one grid walk - the closest shift wins

Badge gates are honest but ignorant. They can tell you that employee 20117 entered at 13:42 and left at 22:17; they cannot tell you whether that was a late afternoon shift, an early night shift, or a day shift worked by someone with spectacular timekeeping problems. The distinction matters because shifts carry allowances — night work pays extra — and the reconciliation from part 4 needs a shift on every row it rewrites. So somewhere in the estate, a routine has to look at two timestamps and guess. This part is about that guess: a nearest-neighbour classifier that nobody involved would ever have called machine learning, implemented as a foreach loop over a shifts table.

The candidates come from the client

Shifts are not global. Each client the company serves defines its own working patterns in ShiftAllowance — start time, end time, and the allowance amount the shift pays:

SELECT Id, ShiftName, ShiftFrom, ShiftTo, ShiftAllowanceAmount
FROM   dbo.ShiftAllowance
WHERE  CustomerId = @clientId AND IsActive = 1;

An employee's candidate set is whatever their client has defined — typically a handful of rows: a general shift, an early shift, a night shift, perhaps a weekend pattern. The inference question is therefore small and discrete: given a first-login and last-logout time, which of these five or six windows is the best explanation?

The algorithm in full

The routine — named CalaculateShift in the source, a typo faithfully preserved across two codebases — pads each candidate shift by thirty minutes on each side, then scores it against the observed times:

public static int? InferShift(TimeSpan login, TimeSpan logout,
                              List<ShiftAllowance> candidates)
{
    var scored = new List<(int shiftId, TimeSpan distance)>();

    foreach (var shift in candidates)
    {
        var paddedStart = shift.ShiftFrom - TimeSpan.FromMinutes(30);
        var paddedEnd   = shift.ShiftTo   + TimeSpan.FromMinutes(30);

        var startGap = (paddedStart - login).Duration();
        var endGap   = (logout - paddedEnd).Duration();

        scored.Add((shift.Id, startGap + endGap));
    }

    return scored.OrderBy(s => s.distance)
                 .Select(s => (int?)s.shiftId)
                 .FirstOrDefault();
}

Read it slowly, because the whole platform's payroll accuracy hangs on these dozen lines.

  • The padding encodes forgiveness. Arriving twenty minutes before your shift, or badging out twenty-five minutes after it, should not make a different shift a “better” explanation of your day. Widening every window by thirty minutes each side means normal human slop scores as zero-ish distance against the right shift, instead of accumulating against it.
  • The score is Manhattan distance. |paddedStart − login| + |logout − paddedEnd| is the L1 metric in a two-dimensional space whose axes are arrival gap and departure gap. Not Euclidean — no squaring, no square roots — just the sum of two absolute time differences. For this job L1 is the right choice, and not for mathematical reasons: each term of the sum is independently explainable to a human. “You were 40 minutes off the start and 90 off the end, total 130” survives an argument with an aggrieved employee; the square root of the sum of squared minutes does not.
  • The minimum wins, unconditionally. OrderBy(...).First() — an argmin. Whatever candidate scores lowest becomes the row's SuggestedShiftId.

One-sided shifts get a sensible special case: a pattern defined with only a start time (or only an end) is scored on its one real edge instead of accumulating a meaningless penalty on the undefined side.

Call it what it is: a 1-nearest-neighbour classifier

Strip away the domain and this is textbook: a fixed set of labelled points (shifts), a distance metric (L1 on time-of-day), and classification by nearest neighbour, k=1. Nobody on the project would have described it that way, and that is rather the point — most “machine learning problems” inside business systems are small enough that the solution stops looking like machine learning at all. No training, no features beyond the two raw timestamps, no model file; just geometry over a reference table that HR can edit.

But naming the algorithm honestly also names its gaps honestly, and 1-NN has famous ones that this implementation inherits in full:

  1. No confidence. The argmin always produces an answer. An employee whose badge times sit six hours from every defined shift still gets a SuggestedShiftId — the least wrong label, presented with exactly the same authority as a perfect match. There is no “distance too large, flag for a human” threshold, which is the one improvement I would insist on today; it is a single if statement, and it converts silent nonsense into a review queue.
  2. No tie-breaking. Two candidates at equal distance are resolved by whatever order the database returned them in — OrderBy is stable, so the winner is consistent but arbitrary. With overlapping shift definitions this is not hypothetical.
  3. No learning. The thirty-minute pad and the metric never adjust, even though the fact table accumulates exactly the data you would need to tune them — the system's suggestion sits beside the human's correction on every row. That unused feedback loop is one of the quiet findings of part 16.

The auto-approval band

Inference only suggests. Whether the row also gets auto-approved — no manager action needed — runs through a second mechanism, and it is where the money logic from part 4 reappears. Employees not eligible for shift allowances are simple: they get the “no shift” pseudo-shift as their input and immediate auto-approval, because nothing they do pays a differential. Allowance-eligible employees are approved automatically only when their day looks unremarkable:

var officeStart = customer.MinTime ?? new TimeSpan(8, 0, 0);
var officeEnd   = customer.MaxTime ?? new TimeSpan(10, 0, 0);

row.IsApproved = login >= officeStart && login <= officeEnd;

Each client carries a MinTime/MaxTime pair — defaulting to 08:00 and 10:00 — defining the band of ordinary arrivals. Arrive inside the band and the system trusts itself; arrive outside it and a human has to look. It is the same principle as part 4's allowance rule wearing different clothes: automation handles the cases where being wrong is cheap, and anything anomalous enough to suggest a night shift — which is to say, anything that might pay — routes to a manager. The two thresholds live on the Customer row as data, not in code, so a client with a genuinely different rhythm just gets different numbers.

Is the band crude? Certainly. It conflates “arrived unusually” with “needs human review”, so a day-shift employee who came in at 07:45 to beat traffic lands in a manager's queue. But crude and legible beats clever and opaque in any system whose outputs people get paid by. Every rule in this pipeline can be explained in one sentence to the person it affects, and having watched employees dispute attendance rows, I promise that property is worth more than accuracy.

The inference is the glamorous fifty lines. Around it sits the unglamorous mass: the loop that runs this for every active employee, every day, back to the beginning of 2019, inventing blank rows for the days nobody badged at all — with sentinel dates standing in for “never” and one database round-trip per row. Next, gap-filling two years of attendance.