From 62f3c1ea92ef8d3209ca1cabcfad7e02d78d31ea Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 16 Aug 2026 19:15:45 -0400 Subject: [PATCH 1/3] fix(zoo): read signature timestamps as UTC so the scan age stops reading 0h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan-age bookmark sat at "0h" no matter how long ago a system was really scanned. `get_system_signatures/1` serialises timestamps with `Calendar.strftime(dt, "%Y/%m/%d %H:%M:%S")`, which is UTC but carries nothing that says so. `new Date` reads that format as *local* time, so west of UTC every signature resolves to an instant in the future, `now - updated_at` goes negative, and the `Math.max(0, ...)` clamp in `computeSignatureAge` returns 0. At UTC-5 that swallows the first five hours of every system's age, and since pasting the probe scanner window re-stamps every signature it contains, an actively scanned chain never leaves that window. `parseTimestamp` now resolves the zone-less server format through `Date.UTC` explicitly, and still accepts an ISO-8601 string that names its own zone. The value it returns is a real instant, so callers compare it against `Date.now()` without knowing how it was encoded. This is not a regression from #142. The code that PR replaced parsed `updated_at` exactly the same way; what changed is that the bookmark used to render only for unlinked wormhole signatures and only under 12h, so the skew mostly hid behind a bookmark that was not drawn. Making the age render everywhere turned a latent bug into a visible one. The same wire format reaches the signature table, where `TimeLeft` corrects for it by adding `getTimezoneOffset()` to *now*. That is why this is fixed in the helper rather than in the serialiser: sending ISO-8601 instead would fix the source for both readers, but `TimeLeft` would then double-correct and put a fresh offset error into the "Added" and "Updated" columns. `map_structures_event_handler.ex` emits the same ambiguous format and has the same latent trap. Tests run pinned to America/Phoenix. Every existing fixture was built with `toISOString()`, which is unambiguous, and at offset 0 a local-parsed timestamp is indistinguishable from a UTC one — so a regression test for this cannot detect anything in a UTC-run suite. --- assets/jest.config.js | 7 +++++ .../map/helpers/signatureAge.test.ts | 28 +++++++++++++++++++ .../components/map/helpers/signatureAge.ts | 19 ++++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/assets/jest.config.js b/assets/jest.config.js index 6c419da0f..dd450f4a3 100644 --- a/assets/jest.config.js +++ b/assets/jest.config.js @@ -1,3 +1,10 @@ +// Pinned so timestamp handling is exercised somewhere other than UTC. A +// zone-less timestamp parsed as local time is indistinguishable from one parsed +// as UTC when the suite runs at offset 0, which is how a five-hour error in the +// scan-age bookmark survived a green test run. Phoenix observes no DST, so the +// offset is the same in every season. +process.env.TZ = 'America/Phoenix'; + module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', 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 08be35263..3736c0bbe 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts @@ -15,6 +15,13 @@ 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); @@ -80,6 +87,27 @@ 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); + }); + 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 ee4487a06..b83e43d49 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts @@ -50,9 +50,23 @@ 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`. + */ +const SERVER_TIMESTAMP = /^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/; + /** * 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. + * * `new Date('garbage').getTime()` is NaN, and NaN loses every `>` comparison, * so an unparseable value would otherwise be indistinguishable from "no * timestamp" *and* would suppress the fallback below it. @@ -61,7 +75,10 @@ function parseTimestamp(value?: string | null): number { if (!value) { return 0; } - const ts = new Date(value).getTime(); + const parts = SERVER_TIMESTAMP.exec(value); + const ts = parts + ? Date.UTC(+parts[1], +parts[2] - 1, +parts[3], +parts[4], +parts[5], +parts[6]) + : new Date(value).getTime(); return Number.isFinite(ts) ? ts : 0; } From b0e73a9bdb8925d32a16d8b54558bc47435d8db0 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 16 Aug 2026 19:26:22 -0400 Subject: [PATCH 2/3] chore(format): run mix format on map_signatures_event_handler The file has been failing `mix format --check-formatted` and taking the whole static-analysis gate down with it, which blocks every PR into guarzo/zoo, not just this one. Mechanical only: line wrapping, plus the formatter dropping the redundant parens in `&("#{...}_#{&1}")` captures. `mix compile --warnings-as-errors` is clean and the suite is green. --- .../map_signatures_event_handler.ex | 111 ++++++++++-------- 1 file changed, 61 insertions(+), 50 deletions(-) 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 1740d1e32..2539f49a0 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 @@ -93,7 +93,11 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do |> WandererApp.MapUserSettingsRepo.to_form_data!() |> WandererApp.MapUserSettingsRepo.get_boolean_setting("delete_connection_with_sigs") -to_remove = removed_signatures |> Enum.filter(fn %{"eve_id" => eve_id} -> "#{solar_system_id}_#{eve_id}" in removed_sig_eve_ids end) + to_remove = + removed_signatures + |> Enum.filter(fn %{"eve_id" => eve_id} -> + "#{solar_system_id}_#{eve_id}" in removed_sig_eve_ids + end) to_remove_eve_ids = to_remove @@ -113,10 +117,10 @@ to_remove = removed_signatures |> Enum.filter(fn %{"eve_id" => eve_id} -> "#{sol socket |> assign( removed_sig_eve_ids: - removed_sig_eve_ids - |> Enum.reject(fn sig_id -> - sig_id in Enum.map(to_remove_eve_ids, &("#{solar_system_id}_#{&1}")) - end) + removed_sig_eve_ids + |> Enum.reject(fn sig_id -> + sig_id in Enum.map(to_remove_eve_ids, &"#{solar_system_id}_#{&1}") + end) ) end @@ -214,54 +218,60 @@ to_remove = removed_signatures |> Enum.filter(fn %{"eve_id" => eve_id} -> "#{sol removed_signatures: [] }) - saved_eve_ids = (added_signatures ++ updated_signatures) |> Enum.map(fn sig -> sig["eve_id"] end) - saved_system_keys = saved_eve_ids |> Enum.map(&("#{solar_system_id}_#{&1}")) - just_removed_system_keys = new_removed_sig_eve_ids |> Enum.map(&("#{solar_system_id}_#{&1}")) - updated_removed_sig_eve_ids = (old_removed_sig_eve_ids ++ just_removed_system_keys) + saved_eve_ids = + (added_signatures ++ updated_signatures) |> Enum.map(fn sig -> sig["eve_id"] end) + + saved_system_keys = saved_eve_ids |> Enum.map(&"#{solar_system_id}_#{&1}") + just_removed_system_keys = new_removed_sig_eve_ids |> Enum.map(&"#{solar_system_id}_#{&1}") + + updated_removed_sig_eve_ids = + (old_removed_sig_eve_ids ++ just_removed_system_keys) |> Enum.uniq() |> Enum.reject(fn key -> key in saved_system_keys end) {:noreply, - socket - |> assign(removed_sig_eve_ids: updated_removed_sig_eve_ids)} + socket + |> assign(removed_sig_eve_ids: updated_removed_sig_eve_ids)} end def handle_ui_event( - "get_signatures", + "get_signatures", %{"system_id" => solar_system_id}, - %{ - assigns: - %{ - map_id: map_id - } = assigns + %{ + assigns: + %{ + map_id: map_id + } = assigns } = socket ) do - solar_system_id_int = get_integer(solar_system_id) - case WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{ - map_id: map_id, - solar_system_id: solar_system_id_int - }) do - {:ok, system} -> - # Clean up expired signatures before returning them - WandererApp.Map.SignatureCleanup.cleanup_async(system.id) - - removed_sig_eve_ids = Map.get(assigns, :removed_sig_eve_ids, []) - system_signatures = - get_system_signatures(system.id) - |> Enum.map(fn sig -> - if "#{solar_system_id_int}_#{sig.eve_id}" in removed_sig_eve_ids do - sig |> Map.put(:deleted, true) - else - sig - end - end) + solar_system_id_int = get_integer(solar_system_id) - {:reply, %{signatures: system_signatures}, socket} - _ -> - {:reply, %{signatures: []}, socket} - end - end + case WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{ + map_id: map_id, + solar_system_id: solar_system_id_int + }) do + {:ok, system} -> + # Clean up expired signatures before returning them + WandererApp.Map.SignatureCleanup.cleanup_async(system.id) + + removed_sig_eve_ids = Map.get(assigns, :removed_sig_eve_ids, []) + + system_signatures = + get_system_signatures(system.id) + |> Enum.map(fn sig -> + if "#{solar_system_id_int}_#{sig.eve_id}" in removed_sig_eve_ids do + sig |> Map.put(:deleted, true) + else + sig + end + end) + + {:reply, %{signatures: system_signatures}, socket} + _ -> + {:reply, %{signatures: []}, socket} + end + end def handle_ui_event( "link_signature_to_system", @@ -483,17 +493,18 @@ to_remove = removed_signatures |> Enum.filter(fn %{"eve_id" => eve_id} -> "#{sol } = socket ) when not is_nil(main_character_id) do - solar_system_id_int = get_integer(solar_system_id) + solar_system_id_int = get_integer(solar_system_id) WandererApp.Map.Server.Impl.broadcast!(map_id, :signatures_updated, solar_system_id) {:noreply, - socket - |> assign( - removed_sig_eve_ids: - removed_sig_eve_ids|> Enum.reject(fn sig_id -> - sig_id in Enum.map(eve_ids, &("#{solar_system_id_int}_#{&1}")) - end) - )} + socket + |> assign( + removed_sig_eve_ids: + removed_sig_eve_ids + |> Enum.reject(fn sig_id -> + sig_id in Enum.map(eve_ids, &"#{solar_system_id_int}_#{&1}") + end) + )} end def handle_ui_event(event, body, socket), @@ -542,4 +553,4 @@ to_remove = removed_signatures |> Enum.filter(fn %{"eve_id" => eve_id} -> "#{sol defp get_integer(nil), do: nil defp get_integer(value) when is_binary(value), do: String.to_integer(value) defp get_integer(value), do: value -end \ No newline at end of file +end From 9d7f843dc4cc159d61158a9a33135f2ec6d2b1b4 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 16 Aug 2026 19:32:00 -0400 Subject: [PATCH 3/3] fix(zoo): reject server timestamps that only look well-formed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raised in review on #150. The match was unanchored and `Date.UTC` normalises rather than rejects, so a value the previous `new Date` call would have thrown out could now resolve to a real instant: `2026/13/09` became February 2027, `2026/02/31` became March 3rd, and a valid prefix followed by arbitrary text parsed as though the text were not there. That is worse than no timestamp, because nothing downstream can tell the difference — it quietly contradicts the documented contract that an unparseable value falls through to `inserted_at`. The pattern is now anchored at both ends, and the components are read back off the result: anything `Date.UTC` had to normalise fails the comparison. That covers every out-of-range field, leap years included, without enumerating per-field bounds. --- .../map/helpers/signatureAge.test.ts | 22 ++++++++++ .../components/map/helpers/signatureAge.ts | 40 +++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) 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 3736c0bbe..20599df8f 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts @@ -108,6 +108,28 @@ describe('computeSignatureAge', () => { 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 b83e43d49..dc47905c5 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts @@ -53,8 +53,39 @@ export function formatSignatureAge(signatureAgeHours: number): string { /** * 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})/; +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. @@ -76,9 +107,10 @@ function parseTimestamp(value?: string | null): number { return 0; } const parts = SERVER_TIMESTAMP.exec(value); - const ts = parts - ? Date.UTC(+parts[1], +parts[2] - 1, +parts[3], +parts[4], +parts[5], +parts[6]) - : new Date(value).getTime(); + if (parts) { + return parseServerTimestamp(parts); + } + const ts = new Date(value).getTime(); return Number.isFinite(ts) ? ts : 0; }