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