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
44 changes: 0 additions & 44 deletions assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,6 @@ 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 @@ -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' })];

Expand Down
56 changes: 8 additions & 48 deletions assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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('');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export const TimeLeft: FC<TimeLeftProps> = ({ 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));
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading