DEV Community

Cover image for Your daily target was already behind before you woke up
Umair
Umair

Posted on

Your daily target was already behind before you woke up

I built a performance tracker for myself. Counters for cold emails and calls, financial goals, the usual. Then I opened it one morning and every single counter said Behind target.

I had logged nothing yet. It was 7am. Being behind at 7am on a daily target is not information, it is an insult.

The bug turned out to be one line of date arithmetic, and fixing it made me rewrite how the whole app thinks about elapsed time.

The bug

Pace measures progress against elapsed time. If you are 40% through a period and 40% of the way to the target, you are on track. Simple enough.

Here is how it counted elapsed days:

export function daysElapsed(start: IsoDate, today: IsoDate, end: IsoDate): number {
  if (today < start) return 0;
  return daysInclusive(start, minIso(today, end));
}
Enter fullscreen mode Exit fullscreen mode

For a weekly goal on Wednesday that returns 3 of 7. Fine.

For a daily counter it returns 1 of 1. At every moment of the day. So expectedPercent is 100 from midnight onward, and anything below 95% of that reads as behind.

The counter detail page said it out loud once I looked:

Complete:         0%
Expected by now:  100%
Enter fullscreen mode Exit fullscreen mode

A one-day period was fully elapsed the instant it opened.

Why whole days were wrong everywhere, not just here

My first instinct was to special-case daily periods. That was the wrong fix.

Whole-day granularity is wrong on a weekly goal too. On Wednesday afternoon, 3 / 7 claims 42.8% of the week has gone, when really about 36% has. The error is just small enough to ignore, right up until the period is one day long and the error becomes 100%.

So the fix is to count today as far as it has actually gone:

const isRunningToday = !isClosed && hasStarted && today <= periodEnd;
const fraction = clampFraction(input.todayFraction);
const elapsedForPace = isRunningToday
  ? Math.max(0, daysElapsed - 1) + fraction
  : daysElapsed;

const expectedPercent =
  noTarget || totalDays <= 0
    ? null
    : Math.round((elapsedForPace / totalDays) * 10000) / 100;
Enter fullscreen mode Exit fullscreen mode

The fraction comes from the clock in the owner's timezone, not the server's:

export function dayFractionElapsed(timezone: string, now = new Date()): number {
  const parts = new Intl.DateTimeFormat("en-GB", {
    timeZone: timezone,
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    hour12: false,
  }).formatToParts(now);

  const value = (type: string) =>
    Number(parts.find((part) => part.type === type)?.value ?? 0);

  // Midnight formats as hour 24 in some locales. Treat it as the start.
  return ((value("hour") % 24) * 3600 + value("minute") * 60 + value("second")) / 86400;
}
Enter fullscreen mode Exit fullscreen mode

A reset at 23:00 in Karachi must not anchor to yesterday because the database runs in UTC.

The part that took the longest to get right

daysElapsed is also used to compute averages and projections. If I made it fractional everywhere, "average per day" would divide by 0.4 on a fresh day and report numbers four times too high.

So only the pace comparison uses the finer measure. daysElapsed stays a whole number:

assert.equal(running.expectedPercent, 45);   // 4 full days plus half of today, of 10
assert.equal(running.daysElapsed, 5);        // averages still divide by whole days
assert.equal(running.averagePerElapsedDay?.toString(), "60");
Enter fullscreen mode Exit fullscreen mode

One concept, two different correct answers depending on the question. I would have shipped the wrong one if I had not written the test.

Omitting the fraction keeps the old whole-day behaviour, which means closed periods and anything without a clock are untouched. That mattered: I did not want a display fix quietly changing historical records.

The rule underneath all of this

The reason a wrong expectedPercent bothered me so much is a constraint I had set at the start: the app is not allowed to change what already happened.

That shows up in three places.

Periods freeze when they close. A goal period stores the target it was measured against, not a reference to the goal. Edit your monthly target today and last month still reads against the number it actually ran with. Most trackers recalculate from current settings, so your history silently rewrites itself every time you adjust a goal.

Totals are derived, never stored as truth. Every counter increment is its own row with a timestamp. The period's current_count is a cache, and it is rebuilt from the events rather than incremented in place.

Writes that belong together happen together. This is where it stopped being a frontend problem. Logging a click has to insert an activity row and recompute the cached total, and doing that in two round trips leaves the total permanently wrong if the second one fails. So it happens in one Postgres function:

CREATE OR REPLACE FUNCTION public.pace_log_counter_activity(
  p_counter_period_id uuid,
  p_delta integer,
  p_entry_type counter_activity_type,
  p_client_request_id text,
  p_note text DEFAULT NULL
)
RETURNS public.counter_periods
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = public
AS $$
Enter fullscreen mode Exit fullscreen mode

client_request_id is unique, and the insert uses ON CONFLICT DO NOTHING. A double tap or a retried request is a no-op instead of a double count. SECURITY INVOKER means row-level security still decides what the caller can touch, so passing an id you do not own returns "not found" rather than someone else's data.

What I would tell past me

The elapsed-time bug was live for days and I never noticed, because I only ever looked at the weekly goal where the error was 6%. It took a daily counter, where the same error is total, to make it visible.

If you have a calculation that degrades gracefully, you will not find out it is wrong. You will find out when someone uses it at the edge where it degrades catastrophically. Write the test for the smallest input, not the typical one.


Pace is at pace.umairrx.dev if you want to see what it turned into. Next.js 16, Supabase with RLS, and no AI in it anywhere, which was also deliberate.

Happy to go deeper on the schema if anyone wants it.

Top comments (0)