DEV Community

XNeuronal
XNeuronal

Posted on

The briefing said nothing today, the reminder said today: an all-day event is a date, not an instant

A tester reported that an all-day event had rung at midnight, announcing itself for today, and that the morning briefing a few hours later said nothing was planned today. Same server, same row, a few hours apart.

To keep a private report private, take an invented example with the same shape: say, a day trip on Friday 11 September, no hour given. Replaying a row like that through the code of the time was worse than the report. Thursday's briefing listed the trip as today. Friday's briefing did not list it at all. And the same date imported from the phone's calendar showed up on Friday AND on Saturday.

XNeuronal is an Android app with a Node backend in TypeScript and a Postgres database. The fix landed on 08/09/2026, in two backend commits and one app commit. Every block below is copied from the repository and lightly trimmed.

Two columns for one appointment

A dated row carries its time twice. due_local is a timestamp without time zone: the hour as the owner said it, "2026-09-11T14:00", no zone attached. due_at is a timestamptz: the instant that hour maps to in the owner's timezone, computed at the target date so that a meeting booked in August for November survives the switch to winter time.

For a timed event, that is right. For an all-day event, it hides a question. There is no hour, only a date, and the moment you store a date in a column that holds instants, somebody has to pick the instant. Every piece of code that writes the column picks it on its own.

Three writers, three instants

The first writer is the server's derivation, used when the assistant creates an event from what the owner dictated. Before the fix, deriveInstants did not know what an all-day event was:

export function deriveInstants(input, timezone) {
  const dueLocal = input.due_local ?? null;
  const endLocal = input.end_local ?? null;
  return {
    due_local: dueLocal,
    end_local: endLocal,
    due_at: dueLocal ? wallToInstant(dueLocal, timezone).toISOString() : (input.due_at ?? null),
    end_at: endLocal ? wallToInstant(endLocal, timezone).toISOString() : (input.end_at ?? null)
  };
}
Enter fullscreen mode Exit fullscreen mode

An all-day Friday arrives as due_local: '2026-09-11T00:00'. Midnight on Friday, two hours ahead of UTC, is 2026-09-10T22:00:00Z: Thursday evening. A single day usually comes without an end, so end_at stays null.

The second writer is the agenda's creation form, in the app, which has no wall clock at all for an all-day event:

} else if (kind === 'event') {
  all_day = allDay;
  let start = dateValue;
  if (!allDay) {
    // ... apply the typed hour
  } else {
    start = new Date(dateValue);
    start.setHours(0, 0, 0, 0);
  }
  due_at = start.toISOString();
Enter fullscreen mode Exit fullscreen mode

This is JavaScript's contribution. There is no date-only type; the closest thing is a Date at local midnight, and toISOString() serializes it in UTC. On a phone ahead of UTC, "Friday" leaves the device as Thursday 22:00Z. Same instant as the first writer, by a different road.

The third writer is the import from the phone's calendar, and here the platform has an opinion. Android's calendar provider stores an all-day event at UTC midnight, with an exclusive end: the midnight after the last day, as in iCalendar. The app's mapper knew it:

function wallHours(start: number, end: number, allDay: boolean) {
  if (allDay) {
    // Android stores all-day bounds at UTC midnight ; the UTC date IS the
    // civil day, and dtend is exclusive (the midnight after the last day).
    const lastDay = utcDay(Math.max(start, end - DAY));
    return { start_local: `${utcDay(start)}T00:00`, end_local: `${lastDay}T23:59` };
  }
  return { start_local: localTimestampToWall(new Date(start)), end_local: localTimestampToWall(new Date(end)) };
}
Enter fullscreen mode Exit fullscreen mode

Those wall clocks are correct. But the server stored the raw provider instants next to them (due_at: upcoming.start, end_at: upcoming.end), so the row ran from Friday 00:00Z to Saturday 00:00Z, exclusive end kept. And whenever the phone reported its timezone, a recompute job re-derived every future row from its wall clock in that zone, which moved imported all-day rows back onto Thursday evening.

The readers had already agreed

What makes this bug instructive: the code that READ all-day rows was consistent. It had been fixed after an earlier incident, and it walks all-day rows in UTC. The morning briefing's day filter, in recapSnapshot.ts:

function coversDay(n: RecapNeuron, day: string, tz: string): boolean {
  if (!n.due_at) return false;
  const zone = n.all_day ? 'UTC' : tz;
  const start = localDateOf(n.due_at, zone);
  const end = n.end_at ? localDateOf(n.end_at, zone) : start;
  return start <= day && day <= end;
}
Enter fullscreen mode Exit fullscreen mode

The app's agenda does the same in agendaDates.coveredDayKeys, and the write-back to the phone's calendar puts all-day bounds on UTC midnight too. Three readers, one convention: an all-day row sits on 00:00Z of its first day and 23:59Z of its last.

The writers had never been told. Feed their rows to those readers, briefing at 08:00 local:

Row as written Thursday briefing Friday briefing Saturday briefing Rings at (local)
Dictated: Thu 22:00Z, no end today absent absent Fri 00:00
Form: Thu 22:00Z, no wall clock today absent absent Fri 00:00
Imported: Fri 00:00Z to Sat 00:00Z absent today today Fri 00:00 on the phone, 02:00 from the server

The first two lines are the report. The reminder rang at midnight on Friday in the owner's zone: the right day. The briefing read the same instant in UTC and saw Thursday. Each was right about its own rule. Nobody owned the rule for the row.

One convention, in one function

The fix writes the convention down once, in wallClock.ts:

export function allDayInstants(
  startWall: string,
  endWall: string | null | undefined
): { due_at: string; end_at: string; due_local: string; end_local: string } {
  const date = startWall.slice(0, 10);
  let endDate = endWall ? endWall.slice(0, 10) : date;
  if (endDate < date) endDate = date;
  return {
    due_local: `${date}T00:00`,
    end_local: `${endDate}T23:59`,
    due_at: `${date}T00:00:00.000Z`,
    end_at: `${endDate}T23:59:00.000Z`
  };
}
Enter fullscreen mode Exit fullscreen mode

No Date, no zone, no arithmetic: the date part of a wall clock in, strings out. Every earlier bug came from turning the date into an instant too early, so this function never does.

Then every writer goes through it. deriveInstants gains a branch:

  if (input.all_day === true) {
    const startWall = dueLocal ?? (input.due_at ? instantToWall(input.due_at, timezone) : null);
    if (startWall) {
      const endWall = endLocal ?? (input.end_at ? instantToWall(input.end_at, timezone) : null);
      return allDayInstants(startWall, endWall);
    }
  }
Enter fullscreen mode Exit fullscreen mode

The second line handles the form without touching the app. An old build still sends Thursday 22:00Z; the server reads it back as a wall clock in the owner's zone, gets Friday 00:00, and keeps only the date. The test pins that road (TZ is two hours ahead of UTC in September):

test('an all-day fiche sent as the form local-midnight instant lands on that civil day, not the day before', () => {
  // The agenda form sends new Date(day).setHours(0), which is 22:00Z the evening before.
  const out = deriveInstants({ due_at: '2026-09-10T22:00:00.000Z', all_day: true }, TZ);
  assert.equal(out.due_at, '2026-09-11T00:00:00.000Z');
  assert.equal(out.end_at, '2026-09-11T23:59:00.000Z');
  assert.equal(out.due_local, '2026-09-11T00:00');
});
Enter fullscreen mode Exit fullscreen mode

The recompute job now ignores the zone for all-day rows. The import drops Android's exclusive end:

  // The phone sends Android's exclusive next-midnight end for an all-day
  // event ; the agenda wants 23:59Z of the LAST day (allDayInstants).
  const bounds = entry.all_day
    ? allDayInstants(upcoming.start_local, upcoming.end_local)
    : { due_at: upcoming.start, end_at: upcoming.end, due_local: upcoming.start_local, end_local: upcoming.end_local };
Enter fullscreen mode Exit fullscreen mode

The cron that moves a yearly event to its next occurrence uses it too. Four writers, one function, with tests on the derivation, the recompute and the import (the yearly re-arm has none).

The fix that rang at two in the morning

Two hours later, the next commit. Anchoring the row on Friday 00:00Z fixed every reader asking "which day does this cover?" and broke the one asking "when do I ring?". The reminder cron rang at due_at, and Friday 00:00Z is 02:00 two hours ahead of UTC. Four hours behind UTC, it is 20:00 on Thursday.

An all-day row answers two questions, and they need two rules. The day it covers is a date, the same everywhere. The moment it rings is an hour in the owner's life, in the owner's zone. The product already had an hour meaning "start of my day": the time the morning briefing is delivered, a setting. So reminderRingInstant.ts:

export function allDayRingWall(dueLocal: string, hour: number, minute: number): string {
  return `${dueLocal.slice(0, 10)}T${pad(hour)}:${pad(minute)}`;
}

export function ringInstantMs(neuron: RingSource, settings: RingSettings): number {
  if (neuron.all_day && isWallFormat(neuron.due_local)) {
    const wall = allDayRingWall(neuron.due_local, settings.briefing_hour, settings.briefing_minute);
    return wallToInstant(wall, settings.timezone).getTime();
  }
  return new Date(neuron.due_at).getTime();
}
Enter fullscreen mode Exit fullscreen mode

The civil date from the anchor, the hour from the settings, the zone conversion last. Lead times count back from that instant, so "the day before" rings Thursday at the briefing hour, not Thursday at 02:00. The tests use real offsets, including the one that moves:

test('the briefing hour and minute are the owner setting, not a constant', () => {
  const ms = ringInstantMs(
    { due_at: '2026-12-10T00:00:00.000Z', due_local: '2026-12-10T00:00', all_day: true },
    { timezone: TZ, briefing_hour: 7, briefing_minute: 30 }
  );
  // Winter time : 07:30 local = 06:30Z.
  assert.equal(new Date(ms).toISOString(), '2026-12-10T06:30:00.000Z');
});
Enter fullscreen mode Exit fullscreen mode

The server is only half the delivery. Reminders ring through a local alarm scheduled on the phone (it works offline), with the server push as a safety net, so the app got a mirror, allDayRing.ts, same function, same name, used by LocalReminderService:

  if (neuron.all_day && isWallFormat(neuron.due_local)) {
    const prefs = await getPrefs();
    ts = wallToLocalTimestamp(allDayRingWall(neuron.due_local, prefs.briefing_hour, prefs.briefing_minute));
  } else {
    ts = isWallFormat(neuron.due_local) ? wallToLocalTimestamp(neuron.due_local) : new Date(neuron.due_at).getTime();
  }
Enter fullscreen mode Exit fullscreen mode

Repairing the rows without shipping an app

Fixed writers correct the rows written from now on. The rows already stored were still on Thursday evening, and no reader can tell which convention wrote a row. So the repair is a pass in the backfill that already runs at every server boot:

  // Second pass : all-day rows written under the old derivation [...] are
  // pinned to the agenda's UTC anchors. Idempotent : a row already on the
  // convention is not touched.
  for (const row of await deps.listFutureAllDay(nowIso)) {
    if (!row.due_local) continue;
    const day = allDayInstants(row.due_local, row.end_local);
    if (day.due_at === row.due_at && day.end_at === row.end_at) continue;
    await deps.updateRow(row.id, { due_at: day.due_at, end_at: day.end_at, due_local: day.due_local, end_local: day.end_local });
    moved++;
  }
Enter fullscreen mode Exit fullscreen mode

With its read:

      const { data, error } = await client
        .from('neurons')
        .select('id, due_at, end_at, due_local, end_local')
        .eq('status', 'active')
        .eq('all_day', true)
        .not('due_local', 'is', null)
        .gte('due_at', nowIso)
        .limit(5000);
Enter fullscreen mode Exit fullscreen mode

It rebuilds from due_local, the owner's words, never from the instant it repairs. It only touches the future: a past event belongs to history. And it calls the same function as the writers, so a repaired row holds exactly what a fresh write holds.

Because the repair and the readers live on the server, the agenda, the briefing and the backup push were right after the next boot, on every installed version of the app. The phone's local alarm is the exception: that code ships inside the app, and an older build still rings an all-day event at local midnight until it is updated. On the right day, which the repaired row guarantees; the hour needs the release.

What the code does not prove

That comment said idempotent, and the tests agreed, because their fixtures held '2026-09-11T00:00:00.000Z'. The database client hands the same instant back as '2026-09-11T00:00:00+00:00'. Two strings for one instant: the skip never fired in production, each boot rewrote identical values, and the update trigger moved updated_at on every future all-day row. The live table showed exactly that. The values were right; the guarantee was not. The recompute job compared the same way.

I found it while writing this article, and the fix went in on 10/09/2026. Both guards now compare instants, not their spelling, and the tests use the shape the database really returns:

export function sameInstant(a: string | null | undefined, b: string | null | undefined): boolean {
  if (a == null || b == null) return a == null && b == null;
  if (a === b) return true;
  const ta = Date.parse(a);
  const tb = Date.parse(b);
  return !Number.isNaN(ta) && ta === tb;
}
Enter fullscreen mode Exit fullscreen mode

The backfill skips rows without a wall clock, and a row from the old form has none. In the live table no future all-day row lacked one, so nothing is stranded today; that is a fact about the data, not a property of the code.

The query that fetches a day's rows still builds its window on the owner's wall clock, so a UTC-anchored row can be fetched for one boundary day too many. A comment calls this deliberate (both renderers re-filter on calendar days), but it is a second convention living next to the first.

The lesson I kept: a date without an hour is not a midnight. Store the day as a day, and let each question build its own instant at the last moment. "Which days does it cover" needs no zone; "when does it ring" needs the owner's zone and the owner's idea of morning. Then grep for every writer, because readers get fixed after incidents and writers get forgotten.

Thanks to the tester who noticed that the phone and the briefing disagreed about today; reports like that are what move XNeuronal forward. The agenda described here ships in the Android app at xneuronal.com.

Top comments (0)