mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(tours): sorts that name their order, the note focused without autofocus
The checklist and the obstacle data sorted texts without a compare function. They now sort by code units through one shared compareCodeUnits, the order a bare sort gave, the same on every machine; the material data of a lock's parts no longer orders by the server's locale, so its hash cannot change with it. The note editor takes the focus through a ref instead of autoFocus, a direct function reference in a map becomes a call, and the GPX import's default fields are a named constant.
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
||||
Unlink,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
@@ -197,6 +197,11 @@ function NoteEditor({
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const box = useRef<HTMLTextAreaElement>(null);
|
||||
// The note opens for writing, so it takes the focus once, when it opens.
|
||||
useEffect(() => {
|
||||
box.current?.focus();
|
||||
}, []);
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
// The map card and the dialog close on Escape; inside the note it only ends the edit.
|
||||
@@ -209,8 +214,8 @@ function NoteEditor({
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={NOTE_MAX}
|
||||
ref={box}
|
||||
value={draft}
|
||||
autoFocus
|
||||
aria-label={t('tours.paddle.check.note')}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { compareCodeUnits } from '@trek/shared';
|
||||
import { AlertTriangle, Info, Loader2, WifiOff } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -9,7 +10,7 @@ function dataDate(regions: readonly { source_timestamp?: string | null }[], lang
|
||||
const times = regions
|
||||
.map((region) => region.source_timestamp)
|
||||
.filter((time): time is string => !!time)
|
||||
.sort();
|
||||
.sort(compareCodeUnits);
|
||||
return times[0] ? new Date(times[0]).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readPbfFile, type OsmWay } from './osm-pbf.reader';
|
||||
import { keptTags, paddleKindOf, paddleNameOf } from './paddle-tags';
|
||||
import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared';
|
||||
import { compareCodeUnits, type PaddleGeometry, type PaddleObstacleKind } from '@trek/shared';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { stat } from 'node:fs/promises';
|
||||
@@ -38,7 +38,7 @@ interface PendingWay {
|
||||
|
||||
function hashOf(tags: Record<string, string>, geometry: PaddleGeometry): string {
|
||||
const sorted = Object.keys(tags)
|
||||
.sort()
|
||||
.sort(compareCodeUnits)
|
||||
.map((key) => [key, tags[key]]);
|
||||
const rounded = JSON.stringify(geometry, (_key, value: unknown) =>
|
||||
typeof value === 'number' ? Math.round(value * 1e6) / 1e6 : value,
|
||||
@@ -147,7 +147,7 @@ export function groupLockParts(features: ExtractedFeature[]): void {
|
||||
for (const part of parts) groups.set(root(part), [...(groups.get(root(part)) ?? []), part]);
|
||||
for (const members of groups.values()) {
|
||||
if (members.length < 2) continue;
|
||||
const key = `lock:${members.map((member) => member.osm_ref).sort()[0]}`;
|
||||
const key = `lock:${members.map((member) => member.osm_ref).sort(compareCodeUnits)[0]}`;
|
||||
for (const member of members) member.group_key = key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { parseRouteGeometry, shortHash } from './tours.helpers';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
compareCodeUnits,
|
||||
obstacleCandidatesOf,
|
||||
obstacleEntriesOf,
|
||||
ownEntriesOf,
|
||||
@@ -66,7 +67,7 @@ export function storedReviewOf(row: TourObstacleReviewRow): StoredObstacleReview
|
||||
}
|
||||
|
||||
function oldest(values: ReadonlyArray<string | null>): string | null {
|
||||
return values.filter((value): value is string => !!value).sort()[0] ?? null;
|
||||
return values.filter((value): value is string => !!value).sort(compareCodeUnits)[0] ?? null;
|
||||
}
|
||||
|
||||
const UNAVAILABLE = { data: 'unavailable', data_as_of: null, read_at: null } as const;
|
||||
@@ -126,7 +127,7 @@ export class TourPaddleCheckService {
|
||||
line,
|
||||
candidates,
|
||||
own,
|
||||
dataKeys: new Set(along.features.map(paddleObstacleKeyOf)),
|
||||
dataKeys: new Set(along.features.map((feature) => paddleObstacleKeyOf(feature))),
|
||||
complete: along.coverage === 'covered' && !limited,
|
||||
data: {
|
||||
data: along.coverage === 'none' ? 'none' : limited ? 'limited' : along.coverage,
|
||||
|
||||
@@ -62,6 +62,9 @@ export interface ImportGpxAsTourResult {
|
||||
* PlacesService.importPreparedGpx. Places and facet rows are persisted in one transaction,
|
||||
* with tour metrics derived from each place's route_geometry.
|
||||
*/
|
||||
/** A GPX file imported without a type is a hike. */
|
||||
const HIKE_IMPORT: TourGpxImportFields = { tour_type: 'hike' };
|
||||
|
||||
@Injectable()
|
||||
export class ToursService {
|
||||
private readonly logger = new Logger(ToursService.name);
|
||||
@@ -522,7 +525,7 @@ export class ToursService {
|
||||
fileBuffer: Buffer,
|
||||
fileName?: string,
|
||||
socketId?: string,
|
||||
fields: TourGpxImportFields = { tour_type: 'hike' },
|
||||
fields: TourGpxImportFields = HIKE_IMPORT,
|
||||
): Promise<ImportGpxAsTourResult | null> {
|
||||
const rows = this.readGpxTracks(fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { compareCodeUnits } from './compare';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('compareCodeUnits', () => {
|
||||
it('orders as a bare sort does, whatever the locale', () => {
|
||||
const texts = ['way/3', 'node/10', 'node/2', 'Node/1', 'check_date', 'canoe', '2026-09-30', '2026-10-01'];
|
||||
expect([...texts].sort(compareCodeUnits)).toEqual([...texts].sort());
|
||||
expect(compareCodeUnits('a', 'a')).toBe(0);
|
||||
expect(compareCodeUnits('a', 'b')).toBe(-1);
|
||||
expect(compareCodeUnits('b', 'a')).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Two texts by their UTF-16 code units: the order a bare `sort()` gives, and
|
||||
* the same on every machine. A hash or a key built over the order must not
|
||||
* depend on the locale the code runs in, as `localeCompare` does.
|
||||
*/
|
||||
export function compareCodeUnits(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
export * from './common/primitives.schema';
|
||||
export * from './common/pagination.schema';
|
||||
export * from './common/compare';
|
||||
|
||||
// Domain contracts
|
||||
export * from './weather/weather.schema';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { compareCodeUnits } from '../common/compare';
|
||||
import { haversineMetres } from '../geo/haversine';
|
||||
import type { PaddleFeature } from '../paddle/paddle-obstacles.schema';
|
||||
import { paddleLayoutOfPoints, paddleLayoutOfTrack, type PaddlePlanPoint } from './paddle-plan';
|
||||
@@ -214,14 +215,14 @@ const isCosmetic = (key: string) => COSMETIC.has(key) || key.startsWith('check_d
|
||||
/** The material data of an obstacle's members, as text to hash: what they are and the tags that bear on passing. */
|
||||
export function materialOf(members: readonly PaddleFeature[]): string {
|
||||
const rows = [...members]
|
||||
.sort((a, b) => a.ref.localeCompare(b.ref))
|
||||
.sort((a, b) => compareCodeUnits(a.ref, b.ref))
|
||||
.map((member) => [
|
||||
member.ref,
|
||||
member.kind,
|
||||
member.geometry.type,
|
||||
Object.keys(member.tags)
|
||||
.filter((key) => !isCosmetic(key))
|
||||
.sort()
|
||||
.sort(compareCodeUnits)
|
||||
.map((key) => [key, member.tags[key]]),
|
||||
]);
|
||||
return JSON.stringify(rows);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { compareCodeUnits } from '../common/compare';
|
||||
import {
|
||||
PADDLE_BLOCKING_KINDS,
|
||||
paddleAccessOf,
|
||||
@@ -166,7 +167,7 @@ function localCheckOf(members: readonly PaddleFeature[]): string | null {
|
||||
)
|
||||
.map(([, value]) => value),
|
||||
);
|
||||
const sorted = dates.sort();
|
||||
const sorted = dates.sort(compareCodeUnits);
|
||||
return sorted[sorted.length - 1] ?? null;
|
||||
}
|
||||
|
||||
@@ -239,7 +240,7 @@ export function obstacleCandidatesOf(input: {
|
||||
...members
|
||||
.map((member) => member.ref)
|
||||
.filter((ref) => ref !== anchor.feature.ref)
|
||||
.sort(),
|
||||
.sort(compareCodeUnits),
|
||||
].slice(0, OBSTACLE_REFS_MAX),
|
||||
kind,
|
||||
name: anchor.feature.name ?? members.find((member) => member.name)?.name ?? null,
|
||||
|
||||
Reference in New Issue
Block a user