2026-10-21

worktime · dotnet

Hours will not calculate your pay

I wanted a timesheet that would argue from the facts I typed. The ones I tried wanted to become payroll. Rates, tax, leave, a payout, a holiday rule someone copied out of a PDF in 2014. I want the clock and the disagreement. I do not want a pay engine I am not qualified to ship.

Hours stores what you worked, and what the day was supposed to be. Those stay separate. A correction is another fact appended on top. Tuesday stays Tuesday. The blank cell, where a product deletes the day so the argument can start over, is how these records go rotten. I have watched that happen. The original registration is gone, the correction is the only story, and nobody can tell what was typed on the day.

Hours is the private one. It is on the Novolis page because I use it. This post is the model. It is not an invitation to the repo.

Four things, versioned on their own

An employment is not one blob called "the contract." Hours keeps four ideas, and they move on different days.

The profile is the shape of a working day for that workplace. Envelope, core hours, lunch, how long a day and a week are supposed to be, and what to do with surplus that nobody has classified yet. The template is the default week. Monday through Friday, 08:00 to 16:00, or whatever that workplace actually wrote down. Settings are the person. Fraction of employment, and an override if this one employee does not work the template. Records are what happened. Presence, breaks, a comment, a mark that some hours need money handled somewhere else, the policy snapshot, the notices.

If I mix those, a template edit rewrites history. Someone changes lunch from thirty minutes to forty-five, and last March quietly changes with it. The record has to remember the policy it was judged under. The template is allowed to change for next Monday. It is not allowed to time-travel.

A registration is an assertion

WorkRegistration is immutable. You said this interval happened. A later correction does not open that row and type over it. It appends another assertion. WorkedAsScheduled does not invent a clock-in you never made. If you say the day matched the template, that is its own fact, and it does not pretend a timestamp existed. Manual registrations and corrections carry real DateTimeOffset intervals.

WorkDay is keyed by the employee and the nominal local date. A shift that crosses midnight stays one day if that is the day it belongs to. I am not splitting 23:30–00:40 into two unrelated facts because the date rolled. The nominal date is a decision. The timestamps are the evidence.

DayShape is the expected day after the rules run. Was it a working day. What was expected. Core hours. The routine intervals. Tags. Timezone. And the ordered list of rules that produced it, so "why was Thursday 7.5" has a provenance instead of a shrug.

Project, customer, time type, billability. Those describe the work after it exists. A missing project does not invalidate the hours. You still worked. The allocation can be late. The clock cannot be talked out of existence because a dropdown was empty.

Two accounts

The duration ledger has two accounts. Employee flex. The organisation's control account. They move by the same amount, opposite directions. If your flex goes up by 4.25 hours, the control account goes down by 4.25 hours. I can add the pair and get zero. That is the whole trick, and it is the trick I want. A comment can explain a movement. A legal citation can explain a movement. An adjustment reason can explain a movement. None of them open a third account called overtime-pay and start doing wage arithmetic.

There is a mark for hours that need financial treatment somewhere else. The mark is a label. It is not a currency, not a rate, and not a new flex bucket. Positive flex can be normalised later, and that normalisation is a proposed adjustment with the reason FlexNormalization. It is expressly not a payment. If a payroll system wants to read the mark and do something with money, that system is not this one.

I am not qualified to ship tax. I am barely willing to ship the clock. The moment Hours multiplies an hour by a rate, every bug becomes a wage bug, and I will not be the person who is casually wrong about that.

The dull Norwegian shape

The private flex shape I wrote down is dull, which is the point. It is a starter preset, not a claim that I have read the statute for you.

Expected template 08:00 to 16:00. A week is 37.5 hours, a day is 7.5. Core coverage 09:00 to 15:00. The envelope, the span you are even supposed to be inside, is 07:00 to 17:00. Lunch 11:30 to 12:00.

The case I check the math against is a day that starts at 09:30 and ends at 21:45, with half an hour off. Clock time is 12 hours 15 minutes. Take the half hour. Worked time is 11.75. Expected is 7.5. Flex movement is +4.25, and the control account takes −4.25. The day still carries the notices. You started after core coverage had begun. You were still there after the envelope closed. The daily limit has something to say. The record is kept anyway. You can register tomorrow anyway.

A notice is not a rejected save. I hate products that refuse the truth because the truth is inconvenient for the policy. The policy can shout. The interval stays.

A manager does not get to overwrite you

A manager can propose an adjustment. It waits. It does not hit the ledger until the employee accepts it. Until then it is a proposal sitting next to the day, not a quiet edit.

A dispute keeps the proposal, marks the day, and sends it up, to HR or higher. The registration you made is still there. Nobody gets a blank cell to win the argument. Later registrations are not blocked because a dispute is open. An overdue approval is an anomaly on the period. It is not a lockout. Monthly clocks exist for employee submission, manager review, and the resolution above that, on business days. Miss the date and the miss is visible. The period does not freeze, and new facts are still allowed in.

I have used systems where a missed Friday meant the week could not be touched until an administrator unlocked it. The work happened anyway. The tool then had a fictional week and a side channel in email. Hours would rather show the late fact than pretend the week ended on time.

Draft until someone who is allowed to say so

Legal text starts as a draft. The presets I included are starters: a Norwegian private flex shape, a Norwegian government-handbook shape, and starters for Belgium, England, France, Poland, and Finland. Every one of them begins as Draft. They carry citations and a review state. They are boundaries the UI can show. They are not gates that throw away hours.

A production host refuses to boot until the preset in use is marked Approved by someone who is allowed to approve it. A switch called PermitDraftLegalPreset does not approve anything. It lets an isolated test boot. I am not putting "we think this is the law" in front of a workplace because the code compiled, and I am not letting a test flag leak into production and do it anyway.

Novolis.Time.Worktime.Legal is not counsel. Country rules and the actual agreement still have to be read by someone whose job that is. My job is to make the unapproved text refuse to start, and to keep the approved text from rewriting the ledger.

What this is not

It does not calculate payroll, pay rates, tax, payment, or leave. Leave is its own product, with its own law. Pay is somebody else's program.

It also does not try to be a friendly calendar that smooths the day into what the template wished had happened. The template is the expectation. The registration is the day. The saldo is the difference, in hours, in two accounts that sum to zero. If that sounds too small for a timesheet product, good. The large versions are where the wage bugs live.

All posts