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..20599df8f 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,49 @@ 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 ee4487a06..dc47905c5 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts @@ -50,9 +50,54 @@ 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. + * * `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,6 +106,10 @@ 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/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