Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions assets/jest.config.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
50 changes: 50 additions & 0 deletions assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ const sig = (overrides: Partial<SystemSignature> = {}): 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);
Expand Down Expand Up @@ -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' })];

Expand Down
49 changes: 49 additions & 0 deletions assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
end
Loading