fix: use server-computed endsAt for exploration timer to prevent clock drift (#68)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Lint, Build & Test (push) Successful in 1m8s

## Summary

- Exploration timers showed more time than the area's stated duration when the server clock was ahead of the client's
- The timer was derived from `startedAt = endsAt - durationMs`, then computed as `durationSeconds - (clientNow - startedAt) / 1000` — any server/client clock skew directly inflated the result
- Now stores `endsAt` (the server-computed completion timestamp) directly in `ExplorationAreaState` and computes the timer as `(endsAt - Date.now()) / 1000`, which is immune to clock drift
- Old saves without `endsAt` fall back gracefully to the previous `startedAt`-based calculation

## Test plan

- [ ] Start a new exploration — timer should show exactly the area's stated duration (no more "1h area shows 1h15m")
- [ ] Refresh the page mid-exploration — timer should resume from the correct remaining time (using server-anchored `endsAt`)
- [ ] Old saves with `startedAt` but no `endsAt` should still display a timer via the fallback path

Closes #53

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #68
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #68.
This commit is contained in:
2026-03-18 17:09:48 -07:00
committed by Naomi Carrigan
parent aede55a13d
commit cfcf763ce3
3 changed files with 26 additions and 15 deletions
@@ -46,11 +46,21 @@ const formatDuration = (seconds: number): string => {
/**
* Computes the time remaining for an exploration in progress.
* Uses endsAt (server-computed) when available to avoid client/server clock drift.
* Falls back to startedAt + durationSeconds for saves predating the endsAt field.
* @param endsAt - The server-computed completion timestamp, if available.
* @param startedAt - The timestamp when exploration started.
* @param durationSeconds - The total duration in seconds.
* @returns The remaining seconds.
*/
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
const timeRemaining = (
endsAt: number | undefined,
startedAt: number,
durationSeconds: number,
): number => {
if (endsAt !== undefined) {
return Math.max(0, (endsAt - Date.now()) / 1000);
}
const elapsed = (Date.now() - startedAt) / 1000;
return Math.max(0, durationSeconds - elapsed);
};
@@ -217,9 +227,10 @@ const ExplorationPanel = (): JSX.Element => {
});
const status = areaState?.status ?? "locked";
const startedAt = areaState?.startedAt ?? 0;
const endsAt = areaState?.endsAt;
const isReady
= status === "in_progress"
&& timeRemaining(startedAt, area.durationSeconds) <= 0;
&& timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0;
const isPending = pendingAreaId === area.id;
function handleStartClick(): void {
@@ -276,9 +287,8 @@ const ExplorationPanel = (): JSX.Element => {
{status === "in_progress" && !isReady
&& <span className="quest-badge active">
{"⏳ "}
{formatDuration(
Math.ceil(timeRemaining(startedAt, area.durationSeconds)),
)}
{/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
{formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
{" remaining"}
</span>
}