Add progressive web app companion for cross-platform access
Vite + TypeScript PWA that mirrors the Android app's core features: - Pre-processed shelter data (build-time UTM33N→WGS84 conversion) - Leaflet map with shelter markers, user location, and offline tiles - Canvas compass arrow (ported from DirectionArrowView.kt) - IndexedDB shelter cache with 7-day staleness check - Service worker with CacheFirst tiles and precached app shell - i18n for en, nb, nn (ported from Android strings.xml) - iOS/Android compass handling with low-pass filter - Respects user map interaction (no auto-snap on pan/zoom) - Build revision cache-breaker for reliable SW updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
46365b713b
commit
e8428de775
12051 changed files with 1799735 additions and 0 deletions
10
pwa/node_modules/@apideck/better-ajv-errors/src/constants.ts
generated
vendored
Normal file
10
pwa/node_modules/@apideck/better-ajv-errors/src/constants.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { DefinedError } from 'ajv';
|
||||
|
||||
export const AJV_ERROR_KEYWORD_WEIGHT_MAP: Partial<Record<DefinedError['keyword'], number>> = {
|
||||
enum: 1,
|
||||
type: 0,
|
||||
};
|
||||
|
||||
export const QUOTES_REGEX = /"/g;
|
||||
export const NOT_REGEX = /NOT/g;
|
||||
export const SLASH_REGEX = /\//g;
|
||||
434
pwa/node_modules/@apideck/better-ajv-errors/src/index.test.ts
generated
vendored
Normal file
434
pwa/node_modules/@apideck/better-ajv-errors/src/index.test.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import Ajv from 'ajv';
|
||||
import { JSONSchema6 } from 'json-schema';
|
||||
import { betterAjvErrors } from './index';
|
||||
|
||||
describe('betterAjvErrors', () => {
|
||||
let ajv: Ajv;
|
||||
let schema: JSONSchema6;
|
||||
let data: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
ajv = new Ajv({ allErrors: true });
|
||||
schema = {
|
||||
type: 'object',
|
||||
required: ['str'],
|
||||
properties: {
|
||||
str: {
|
||||
type: 'string',
|
||||
},
|
||||
enum: {
|
||||
type: 'string',
|
||||
enum: ['one', 'two'],
|
||||
},
|
||||
bounds: {
|
||||
type: 'number',
|
||||
minimum: 2,
|
||||
maximum: 4,
|
||||
},
|
||||
nested: {
|
||||
type: 'object',
|
||||
required: ['deepReq'],
|
||||
properties: {
|
||||
deepReq: {
|
||||
type: 'boolean',
|
||||
},
|
||||
deep: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
});
|
||||
|
||||
describe('additionalProperties', () => {
|
||||
it('should handle additionalProperties=false', () => {
|
||||
data = {
|
||||
str: 'str',
|
||||
foo: 'bar',
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'additionalProperties',
|
||||
},
|
||||
message: "'foo' property is not expected to be here",
|
||||
path: '{base}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle additionalProperties=true', () => {
|
||||
data = {
|
||||
str: 'str',
|
||||
foo: 'bar',
|
||||
};
|
||||
schema.additionalProperties = true;
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should give suggestions when relevant', () => {
|
||||
data = {
|
||||
str: 'str',
|
||||
bonds: 'bar',
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'additionalProperties',
|
||||
},
|
||||
message: "'bonds' property is not expected to be here",
|
||||
path: '{base}',
|
||||
suggestion: "Did you mean property 'bounds'?",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle object schemas without properties', () => {
|
||||
data = {
|
||||
empty: { foo: 1 },
|
||||
};
|
||||
schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
empty: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'additionalProperties',
|
||||
},
|
||||
message: "'foo' property is not expected to be here",
|
||||
path: '{base}.empty',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required', () => {
|
||||
it('should handle required properties', () => {
|
||||
data = {
|
||||
nested: {},
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'required',
|
||||
},
|
||||
message: "{base} must have required property 'str'",
|
||||
path: '{base}',
|
||||
},
|
||||
{
|
||||
context: {
|
||||
errorType: 'required',
|
||||
},
|
||||
message: "{base}.nested must have required property 'deepReq'",
|
||||
path: '{base}.nested',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple required properties', () => {
|
||||
schema = {
|
||||
type: 'object',
|
||||
required: ['req1', 'req2'],
|
||||
properties: {
|
||||
req1: {
|
||||
type: 'string',
|
||||
},
|
||||
req2: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
data = {};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'required',
|
||||
},
|
||||
message: "{base} must have required property 'req1'",
|
||||
path: '{base}',
|
||||
},
|
||||
{
|
||||
context: {
|
||||
errorType: 'required',
|
||||
},
|
||||
message: "{base} must have required property 'req2'",
|
||||
path: '{base}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type', () => {
|
||||
it('should handle type errors', () => {
|
||||
data = {
|
||||
str: 123,
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'type',
|
||||
},
|
||||
message: "'str' property type must be string",
|
||||
path: '{base}.str',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('minimum/maximum', () => {
|
||||
it('should handle minimum/maximum errors', () => {
|
||||
data = {
|
||||
str: 'str',
|
||||
bounds: 123,
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'maximum',
|
||||
},
|
||||
message: "property 'bounds' must be <= 4",
|
||||
path: '{base}.bounds',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enum', () => {
|
||||
it('should handle enum errors', () => {
|
||||
data = {
|
||||
str: 'str',
|
||||
enum: 'zzzz',
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'enum',
|
||||
allowedValues: ['one', 'two'],
|
||||
},
|
||||
message: "'enum' property must be equal to one of the allowed values",
|
||||
path: '{base}.enum',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should provide suggestions when relevant', () => {
|
||||
data = {
|
||||
str: 'str',
|
||||
enum: 'pne',
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'enum',
|
||||
allowedValues: ['one', 'two'],
|
||||
},
|
||||
message: "'enum' property must be equal to one of the allowed values",
|
||||
path: '{base}.enum',
|
||||
suggestion: "Did you mean 'one'?",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not crash on null value', () => {
|
||||
data = {
|
||||
type: null,
|
||||
};
|
||||
schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['primary', 'secondary'],
|
||||
},
|
||||
},
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
allowedValues: ['primary', 'secondary'],
|
||||
errorType: 'enum',
|
||||
},
|
||||
message: "'type' property must be equal to one of the allowed values",
|
||||
path: '{base}.type',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array paths', () => {
|
||||
data = {
|
||||
custom: [{ foo: 'bar' }, { aaa: 'zzz' }],
|
||||
};
|
||||
schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
custom: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'additionalProperties',
|
||||
},
|
||||
message: "'foo' property is not expected to be here",
|
||||
path: '{base}.custom.0',
|
||||
},
|
||||
{
|
||||
context: {
|
||||
errorType: 'additionalProperties',
|
||||
},
|
||||
message: "'aaa' property is not expected to be here",
|
||||
path: '{base}.custom.1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle file $refs', () => {
|
||||
data = {
|
||||
child: [{ foo: 'bar' }, { aaa: 'zzz' }],
|
||||
};
|
||||
schema = {
|
||||
$id: 'http://example.com/schemas/Main.json',
|
||||
type: 'object',
|
||||
properties: {
|
||||
child: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: './Child.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
ajv.addSchema({
|
||||
$id: 'http://example.com/schemas/Child.json',
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
});
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
errorType: 'additionalProperties',
|
||||
},
|
||||
message: "'foo' property is not expected to be here",
|
||||
path: '{base}.child.0',
|
||||
},
|
||||
{
|
||||
context: {
|
||||
errorType: 'additionalProperties',
|
||||
},
|
||||
message: "'aaa' property is not expected to be here",
|
||||
path: '{base}.child.1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle number enums', () => {
|
||||
data = {
|
||||
isLive: 2,
|
||||
};
|
||||
schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
isLive: {
|
||||
type: 'integer',
|
||||
enum: [0, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
allowedValues: [0, 1],
|
||||
errorType: 'enum',
|
||||
},
|
||||
message: "'isLive' property must be equal to one of the allowed values",
|
||||
path: '{base}.isLive',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe('const', () => {
|
||||
it('should handle const errors', () => {
|
||||
data = {
|
||||
const: 2,
|
||||
};
|
||||
schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
const: {
|
||||
type: 'integer',
|
||||
const: 42,
|
||||
},
|
||||
},
|
||||
};
|
||||
ajv.validate(schema, data);
|
||||
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
context: {
|
||||
allowedValue: 42,
|
||||
errorType: 'const',
|
||||
},
|
||||
message: "'const' property must be equal to the allowed value",
|
||||
path: '{base}.const',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
121
pwa/node_modules/@apideck/better-ajv-errors/src/index.ts
generated
vendored
Normal file
121
pwa/node_modules/@apideck/better-ajv-errors/src/index.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { DefinedError, ErrorObject } from 'ajv';
|
||||
import type { JSONSchema6 } from 'json-schema';
|
||||
import { ValidationError } from './types/ValidationError';
|
||||
import { filterSingleErrorPerProperty } from './lib/filter';
|
||||
import { getSuggestion } from './lib/suggestions';
|
||||
import { cleanAjvMessage, getLastSegment, pointerToDotNotation, safeJsonPointer } from './lib/utils';
|
||||
|
||||
export interface BetterAjvErrorsOptions {
|
||||
errors: ErrorObject[] | null | undefined;
|
||||
data: any;
|
||||
schema: JSONSchema6;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export const betterAjvErrors = ({
|
||||
errors,
|
||||
data,
|
||||
schema,
|
||||
basePath = '{base}',
|
||||
}: BetterAjvErrorsOptions): ValidationError[] => {
|
||||
if (!Array.isArray(errors) || errors.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const definedErrors = filterSingleErrorPerProperty(errors as DefinedError[]);
|
||||
|
||||
return definedErrors.map((error) => {
|
||||
const path = pointerToDotNotation(basePath + error.instancePath);
|
||||
const prop = getLastSegment(error.instancePath);
|
||||
const defaultContext = {
|
||||
errorType: error.keyword,
|
||||
};
|
||||
const defaultMessage = `${prop ? `property '${prop}'` : path} ${cleanAjvMessage(error.message as string)}`;
|
||||
|
||||
let validationError: ValidationError;
|
||||
|
||||
switch (error.keyword) {
|
||||
case 'additionalProperties': {
|
||||
const additionalProp = error.params.additionalProperty;
|
||||
const suggestionPointer = error.schemaPath.replace('#', '').replace('/additionalProperties', '');
|
||||
const { properties } = safeJsonPointer({
|
||||
object: schema,
|
||||
pnter: suggestionPointer,
|
||||
fallback: { properties: {} },
|
||||
});
|
||||
validationError = {
|
||||
message: `'${additionalProp}' property is not expected to be here`,
|
||||
suggestion: getSuggestion({
|
||||
value: additionalProp,
|
||||
suggestions: Object.keys(properties ?? {}),
|
||||
format: (suggestion) => `Did you mean property '${suggestion}'?`,
|
||||
}),
|
||||
path,
|
||||
context: defaultContext,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'enum': {
|
||||
const suggestions = error.params.allowedValues.map((value) => value.toString());
|
||||
const prop = getLastSegment(error.instancePath);
|
||||
const value = safeJsonPointer({ object: data, pnter: error.instancePath, fallback: '' });
|
||||
validationError = {
|
||||
message: `'${prop}' property must be equal to one of the allowed values`,
|
||||
suggestion: getSuggestion({
|
||||
value,
|
||||
suggestions,
|
||||
}),
|
||||
path,
|
||||
context: {
|
||||
...defaultContext,
|
||||
allowedValues: error.params.allowedValues,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'type': {
|
||||
const prop = getLastSegment(error.instancePath);
|
||||
const type = error.params.type;
|
||||
validationError = {
|
||||
message: `'${prop}' property type must be ${type}`,
|
||||
path,
|
||||
context: defaultContext,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'required': {
|
||||
validationError = {
|
||||
message: `${path} must have required property '${error.params.missingProperty}'`,
|
||||
path,
|
||||
context: defaultContext,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'const': {
|
||||
return {
|
||||
message: `'${prop}' property must be equal to the allowed value`,
|
||||
path,
|
||||
context: {
|
||||
...defaultContext,
|
||||
allowedValue: error.params.allowedValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return { message: defaultMessage, path, context: defaultContext };
|
||||
}
|
||||
|
||||
// Remove empty properties
|
||||
const errorEntries = Object.entries(validationError);
|
||||
for (const [key, value] of errorEntries as [keyof ValidationError, unknown][]) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete validationError[key];
|
||||
}
|
||||
}
|
||||
|
||||
return validationError;
|
||||
});
|
||||
};
|
||||
|
||||
export { ValidationError };
|
||||
23
pwa/node_modules/@apideck/better-ajv-errors/src/lib/filter.ts
generated
vendored
Normal file
23
pwa/node_modules/@apideck/better-ajv-errors/src/lib/filter.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { DefinedError } from 'ajv';
|
||||
import { AJV_ERROR_KEYWORD_WEIGHT_MAP } from '../constants';
|
||||
|
||||
export const filterSingleErrorPerProperty = (errors: DefinedError[]): DefinedError[] => {
|
||||
const errorsPerProperty = errors.reduce<Record<string, DefinedError>>((acc, error) => {
|
||||
const prop =
|
||||
error.instancePath + ((error.params as any)?.additionalProperty ?? (error.params as any)?.missingProperty ?? '');
|
||||
const existingError = acc[prop];
|
||||
if (!existingError) {
|
||||
acc[prop] = error;
|
||||
return acc;
|
||||
}
|
||||
const weight = AJV_ERROR_KEYWORD_WEIGHT_MAP[error.keyword] ?? 0;
|
||||
const existingWeight = AJV_ERROR_KEYWORD_WEIGHT_MAP[existingError.keyword] ?? 0;
|
||||
|
||||
if (weight > existingWeight) {
|
||||
acc[prop] = error;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.values(errorsPerProperty);
|
||||
};
|
||||
29
pwa/node_modules/@apideck/better-ajv-errors/src/lib/suggestions.ts
generated
vendored
Normal file
29
pwa/node_modules/@apideck/better-ajv-errors/src/lib/suggestions.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import leven from 'leven';
|
||||
|
||||
export const getSuggestion = ({
|
||||
value,
|
||||
suggestions,
|
||||
format = (suggestion) => `Did you mean '${suggestion}'?`,
|
||||
}: {
|
||||
value: string | null;
|
||||
suggestions: string[];
|
||||
format?: (suggestion: string) => string;
|
||||
}): string => {
|
||||
if (!value) return '';
|
||||
const bestSuggestion = suggestions.reduce(
|
||||
(best, current) => {
|
||||
const distance = leven(value, current);
|
||||
if (best.distance > distance) {
|
||||
return { value: current, distance };
|
||||
}
|
||||
|
||||
return best;
|
||||
},
|
||||
{
|
||||
distance: Infinity,
|
||||
value: '',
|
||||
}
|
||||
);
|
||||
|
||||
return bestSuggestion.distance < value.length ? format(bestSuggestion.value) : '';
|
||||
};
|
||||
23
pwa/node_modules/@apideck/better-ajv-errors/src/lib/utils.ts
generated
vendored
Normal file
23
pwa/node_modules/@apideck/better-ajv-errors/src/lib/utils.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { NOT_REGEX, QUOTES_REGEX, SLASH_REGEX } from '../constants';
|
||||
import pointer from 'jsonpointer';
|
||||
|
||||
export const pointerToDotNotation = (pointer: string): string => {
|
||||
return pointer.replace(SLASH_REGEX, '.');
|
||||
};
|
||||
|
||||
export const cleanAjvMessage = (message: string): string => {
|
||||
return message.replace(QUOTES_REGEX, "'").replace(NOT_REGEX, 'not');
|
||||
};
|
||||
|
||||
export const getLastSegment = (path: string): string => {
|
||||
const segments = path.split('/');
|
||||
return segments.pop() as string;
|
||||
};
|
||||
|
||||
export const safeJsonPointer = <T>({ object, pnter, fallback }: { object: any; pnter: string; fallback: T }): T => {
|
||||
try {
|
||||
return pointer.get(object, pnter);
|
||||
} catch (err) {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
11
pwa/node_modules/@apideck/better-ajv-errors/src/types/ValidationError.ts
generated
vendored
Normal file
11
pwa/node_modules/@apideck/better-ajv-errors/src/types/ValidationError.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { DefinedError } from 'ajv';
|
||||
|
||||
export interface ValidationError {
|
||||
message: string;
|
||||
path: string;
|
||||
suggestion?: string;
|
||||
context: {
|
||||
errorType: DefinedError['keyword'];
|
||||
[additionalContext: string]: unknown;
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue