From 57586ac1ed124ff7c20ae0f839b35cdc52e77273 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 16 Aug 2026 19:47:30 -0400 Subject: [PATCH] fix(timestamps): send ISO-8601 and drop the offset corrections that hid it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #150, which fixed the scan-age bookmark at the reader while leaving the ambiguous wire format in place. `get_system_signatures/1` and `get_system_structures/1` serialised UTC as `%Y/%m/%d %H:%M:%S`, which carries nothing marking it as UTC, so `new Date` read it as local time. Two readers compensated by adding `getTimezoneOffset()` to *now* — `TimeLeft` and `getRowBackgroundColor` — which cancelled the error out and made the format look correct. Any reader that did not know the trick was silently wrong by the viewer's offset, which is how the bookmark came to sit at "0h". Both handlers now emit `DateTime.to_iso8601/1`, and both corrections are gone. They had to move together: either half alone leaves the readers double-correcting in the opposite direction. `signatureAge.ts` drops the format-sniffing branch #150 added, and its tests go with it — nothing in lib/ emits the zone-less format any more, so there is one parse path again. Structure timestamps have no frontend reader at all (`mapServerStructure` spreads them through untouched; the structure timers use a separate `end_time` field), so that half is hygiene, not a behaviour change. Verified: 1808 Elixir tests, 35 frontend tests, `mix format --check-formatted`, `mix compile --warnings-as-errors`, prettier, and no new tsc errors. The new tests fail against the old format — the backend one because `DateTime.from_iso8601/1` rejects it outright, the row-colour one because a signature added seconds ago was landing seven hours stale. --- .../map/helpers/signatureAge.test.ts | 44 --------------- .../components/map/helpers/signatureAge.ts | 56 +++---------------- .../helpers/getRowBackgroundColor.test.ts | 28 ++++++++++ .../helpers/getRowBackgroundColor.ts | 5 +- .../components/ui-kit/TimeLeft/TimeLeft.tsx | 7 ++- .../map_signatures_event_handler.ex | 4 +- .../map_structures_event_handler.ex | 4 +- .../map_signatures_event_handler_test.exs | 40 +++++++++++++ 8 files changed, 88 insertions(+), 100 deletions(-) create mode 100644 assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/helpers/getRowBackgroundColor.test.ts create mode 100644 test/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler_test.exs diff --git a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts index 20599df8f..a1d4da76e 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts @@ -15,13 +15,6 @@ const sig = (overrides: Partial = {}): SystemSignature => ({ const hoursAgo = (h: number) => new Date(NOW - h * HOUR).toISOString(); -// The format the server actually puts on the wire: `Calendar.strftime(dt, -// "%Y/%m/%d %H:%M:%S")` in `get_system_signatures/1`. It is UTC, but carries -// nothing that says so. Fixtures built with `toISOString()` are ISO-8601 with a -// `Z`, so they never exercised this path. -const serverHoursAgo = (h: number) => - new Date(NOW - h * HOUR).toISOString().replace(/-/g, '/').slice(0, 19).replace('T', ' '); - describe('computeSignatureAge', () => { it('reports no age when the system has no signatures at all', () => { expect(computeSignatureAge([], NOW).signatureAgeHours).toBe(-1); @@ -87,49 +80,12 @@ describe('computeSignatureAge', () => { expect(newestUpdatedAt).toBe(NOW - 7 * HOUR); }); - // The reported bug: west of UTC, `new Date('2026/08/09 10:00:00')` resolves to - // 10:00 *local*, which is later than the real instant, so every age landed in - // the `Math.max(0, ...)` clamp and the bookmark read 0h for hours on end. - it('reads the zone-less server timestamp format as UTC', () => { - const sigs = [sig({ updated_at: serverHoursAgo(6) })]; - - expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(6); - }); - - it('reads the server format on the inserted_at fallback too', () => { - const sigs = [sig({ inserted_at: serverHoursAgo(9) })]; - - expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(9); - }); - it('still reads an ISO-8601 timestamp with an explicit zone', () => { const sigs = [sig({ updated_at: hoursAgo(6) })]; expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(6); }); - // `Date.UTC` normalises rather than rejects, so without a round-trip check - // these build a real-looking instant out of garbage — indistinguishable, to - // every caller, from a timestamp the server actually sent. - it.each([ - ['a month that does not exist', '2026/13/09 10:00:00'], - ['a day the month does not have', '2026/02/31 10:00:00'], - ['an hour past the end of the day', '2026/08/09 99:99:99'], - ['trailing text after a valid prefix', '2026/08/09 10:00:00 and then some'], - ])('falls back to inserted_at for %s', (_label, updated_at) => { - const sigs = [sig({ updated_at, inserted_at: serverHoursAgo(4) })]; - - expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(4); - }); - - it('accepts the last second of a leap day', () => { - const sigs = [sig({ updated_at: '2024/02/29 23:59:59' })]; - - expect(computeSignatureAge(sigs, Date.parse('2024/02/29T23:59:59Z') + HOUR).newestUpdatedAt).toBe( - Date.UTC(2024, 1, 29, 23, 59, 59), - ); - }); - it('reports no age when neither timestamp will parse', () => { const sigs = [sig({ updated_at: 'not-a-date', inserted_at: 'also-not-a-date' })]; diff --git a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts index dc47905c5..4e9e20d73 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts @@ -50,53 +50,17 @@ export function formatSignatureAge(signatureAgeHours: number): string { return `${Math.floor(signatureAgeHours / DAY_HOURS)}d`; } -/** - * The zone-less timestamp format the LiveView puts on the wire, from - * `Calendar.strftime(dt, "%Y/%m/%d %H:%M:%S")` in `get_system_signatures/1`. - * - * Anchored at both ends: an unanchored match would accept a well-formed prefix - * followed by anything at all, which `new Date` would have rejected outright. - */ -const SERVER_TIMESTAMP = /^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/; - -/** - * Resolves the zone-less server format as UTC, or 0 if the components do not - * describe a real instant. - * - * `Date.UTC` normalises out-of-range components rather than rejecting them, so - * month 13 becomes February of the next year and 2026/02/31 becomes March 3rd — - * a plausible-looking timestamp built out of garbage, which is worse than no - * timestamp at all because the caller cannot tell it apart from a real one. - * Reading the components back off the result rejects exactly the values that - * were normalised, which covers every out-of-range field without enumerating - * per-field bounds or special-casing leap years. - */ -function parseServerTimestamp(parts: RegExpExecArray): number { - const [, year, month, day, hour, minute, second] = parts.map(Number); - const ts = Date.UTC(year, month - 1, day, hour, minute, second); - const back = new Date(ts); - - const roundTrips = - back.getUTCFullYear() === year && - back.getUTCMonth() === month - 1 && - back.getUTCDate() === day && - back.getUTCHours() === hour && - back.getUTCMinutes() === minute && - back.getUTCSeconds() === second; - - return roundTrips ? ts : 0; -} - /** * Parses a signature timestamp, treating anything unparseable as absent. * - * The server sends UTC with nothing marking it as UTC, and `new Date` reads that - * format as *local* time. West of UTC that puts every timestamp in the future, - * so `now - updated_at` went negative and the age clamped to 0 — the bookmark - * sat at "0h" no matter how long ago the system was really scanned. The offset - * is applied explicitly here rather than by the `getTimezoneOffset()` correction - * `TimeLeft` uses, so the value this returns is a real instant that callers can - * compare against `Date.now()` without knowing how it was encoded. + * The server sends ISO-8601 with an explicit zone, so `new Date` resolves it to + * a real instant and this needs no correction. It previously sent UTC formatted + * as `%Y/%m/%d %H:%M:%S` with nothing marking it as UTC, which `new Date` read + * as *local* time — west of UTC that put every timestamp in the future, drove + * `now - updated_at` negative, and pinned the age bookmark at "0h" no matter how + * long ago the system was really scanned. Both readers of that format carried a + * `getTimezoneOffset()` correction to cancel it out; the format and the + * corrections were removed together, so there is one parse path again. * * `new Date('garbage').getTime()` is NaN, and NaN loses every `>` comparison, * so an unparseable value would otherwise be indistinguishable from "no @@ -106,10 +70,6 @@ function parseTimestamp(value?: string | null): number { if (!value) { return 0; } - const parts = SERVER_TIMESTAMP.exec(value); - if (parts) { - return parseServerTimestamp(parts); - } const ts = new Date(value).getTime(); return Number.isFinite(ts) ? ts : 0; } diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/helpers/getRowBackgroundColor.test.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/helpers/getRowBackgroundColor.test.ts new file mode 100644 index 000000000..8497a21e8 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/helpers/getRowBackgroundColor.test.ts @@ -0,0 +1,28 @@ +import { getRowBackgroundColor } from './getRowBackgroundColor'; + +const NOW = Date.parse('2026-08-09T12:00:00Z'); +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +// The row highlight and `TimeLeft` both used to add `getTimezoneOffset()` to +// *now*, cancelling out the fact that signature timestamps arrived as zone-less +// UTC that `new Date` had read as local. Now that the server sends ISO-8601, the +// timestamps are real instants and the correction has to be gone: west of UTC it +// would push every age hours into the past and no row would ever highlight. +describe('getRowBackgroundColor', () => { + it('highlights a signature added seconds ago', () => { + expect(getRowBackgroundColor(new Date(NOW - 5 * SECOND), NOW)).toContain('amber-300'); + }); + + it('uses the ten-minute colour for a signature a few minutes old', () => { + expect(getRowBackgroundColor(new Date(NOW - 5 * MINUTE), NOW)).toContain('amber-500'); + }); + + it('stops highlighting past ten minutes', () => { + expect(getRowBackgroundColor(new Date(NOW - 11 * MINUTE), NOW)).toBe(''); + }); + + it('returns nothing without a date', () => { + expect(getRowBackgroundColor(undefined, NOW)).toBe(''); + }); +}); diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/helpers/getRowBackgroundColor.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/helpers/getRowBackgroundColor.ts index 68f49c1d8..046129597 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/helpers/getRowBackgroundColor.ts +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/helpers/getRowBackgroundColor.ts @@ -3,13 +3,12 @@ import { TIME_TEN_MINUTES, } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/constants.ts'; -export const getRowBackgroundColor = (date: Date | undefined): string => { +export const getRowBackgroundColor = (date: Date | undefined, now: number = Date.now()): string => { if (!date) { return ''; } - const currentDate = new Date(); - const diff = currentDate.getTime() + currentDate.getTimezoneOffset() * TIME_ONE_MINUTE - date.getTime(); + const diff = now - date.getTime(); if (diff < TIME_ONE_MINUTE) { return '[&_.ssc-header]:text-amber-300 [&_.ssc-header]:hover:text-amber-200 [&_.ssc-header]:font-bold'; diff --git a/assets/js/hooks/Mapper/components/ui-kit/TimeLeft/TimeLeft.tsx b/assets/js/hooks/Mapper/components/ui-kit/TimeLeft/TimeLeft.tsx index 3ebb0ec00..140bed22f 100644 --- a/assets/js/hooks/Mapper/components/ui-kit/TimeLeft/TimeLeft.tsx +++ b/assets/js/hooks/Mapper/components/ui-kit/TimeLeft/TimeLeft.tsx @@ -31,7 +31,12 @@ export const TimeLeft: FC = ({ cDate = new Date() }) => { const update = () => { const currentDate = new Date(); - const diff = currentDate.getTime() + currentDate.getTimezoneOffset() * 60000 - date.getTime(); + // No timezone correction: `cDate` is built from a timestamp that names its + // own zone, so it is already a real instant. This used to add + // `getTimezoneOffset()` to compensate for signature timestamps arriving as + // zone-less UTC that `new Date` read as local time; the server now sends + // ISO-8601 and correcting again would reintroduce the same error, inverted. + const diff = currentDate.getTime() - date.getTime(); setTimeDiff(calculateTimeDiff(diff)); }; diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex index 2539f49a0..8309fe898 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex @@ -545,8 +545,8 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do ]) |> Map.put(:character_name, Map.get(character_names_map, s.character_eve_id)) |> Map.put(:linked_system, MapEventHandler.get_system_static_info(linked_system_id)) - |> Map.put(:inserted_at, inserted_at |> Calendar.strftime("%Y/%m/%d %H:%M:%S")) - |> Map.put(:updated_at, updated_at |> Calendar.strftime("%Y/%m/%d %H:%M:%S")) + |> Map.put(:inserted_at, DateTime.to_iso8601(inserted_at)) + |> Map.put(:updated_at, DateTime.to_iso8601(updated_at)) end) end diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex index 662a4207f..3c13c245e 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex @@ -124,8 +124,8 @@ defmodule WandererAppWeb.MapStructuresEventHandler do :structure_type, :inherited_from_map_id ]) - |> Map.update!(:inserted_at, &Calendar.strftime(&1, "%Y/%m/%d %H:%M:%S")) - |> Map.update!(:updated_at, &Calendar.strftime(&1, "%Y/%m/%d %H:%M:%S")) + |> Map.update!(:inserted_at, &DateTime.to_iso8601/1) + |> Map.update!(:updated_at, &DateTime.to_iso8601/1) end) Logger.debug(fn -> diff --git a/test/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler_test.exs b/test/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler_test.exs new file mode 100644 index 000000000..a245922ce --- /dev/null +++ b/test/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler_test.exs @@ -0,0 +1,40 @@ +defmodule WandererAppWeb.MapSignaturesEventHandlerTest do + use WandererApp.DataCase, async: false + + alias WandererAppWeb.MapSignaturesEventHandler + + describe "get_system_signatures/1" do + setup do + map = WandererAppWeb.Factory.create_map() + system = WandererAppWeb.Factory.create_map_system(map.id) + signature = WandererAppWeb.Factory.create_map_system_signature(system.id) + + %{system: system, signature: signature} + end + + # The frontend reads these with `new Date`, which resolves a zone-less + # `2026/08/09 10:00:00` as *local* time. That silently shifted every + # signature by the viewer's UTC offset and pinned the scan-age bookmark at + # "0h" west of UTC. The zone marker is the whole contract: assert on it here + # so the format cannot regress to something ambiguous. + test "serialises timestamps as ISO-8601 with an explicit zone", %{system: system} do + [serialised] = MapSignaturesEventHandler.get_system_signatures(system.id) + + assert {:ok, _dt, 0} = DateTime.from_iso8601(serialised.updated_at) + assert {:ok, _dt, 0} = DateTime.from_iso8601(serialised.inserted_at) + assert String.ends_with?(serialised.updated_at, "Z") + assert String.ends_with?(serialised.inserted_at, "Z") + end + + test "the serialised instant matches the stored one", %{ + system: system, + signature: signature + } do + [serialised] = MapSignaturesEventHandler.get_system_signatures(system.id) + + {:ok, parsed, 0} = DateTime.from_iso8601(serialised.updated_at) + + assert DateTime.compare(parsed, signature.updated_at) == :eq + end + end +end