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:
Ole-Morten Duesund 2026-03-08 17:41:38 +01:00
commit e8428de775
12051 changed files with 1799735 additions and 0 deletions

View 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;

View 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',
},
]);
});
});
});

View 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 };

View 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);
};

View 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) : '';
};

View 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;
}
};

View 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;
};
}