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,67 @@
import { describe, it, expect } from 'vitest';
import {
distanceMeters,
bearingDegrees,
formatDistance,
} from '../src/util/distance-utils';
describe('distanceMeters', () => {
it('returns 0 for same point', () => {
expect(distanceMeters(59.9, 10.7, 59.9, 10.7)).toBe(0);
});
it('calculates Oslo to Bergen distance (~305km)', () => {
// Oslo: 59.9139, 10.7522 — Bergen: 60.3913, 5.3221
const d = distanceMeters(59.9139, 10.7522, 60.3913, 5.3221);
expect(d).toBeGreaterThan(300_000);
expect(d).toBeLessThan(310_000);
});
it('calculates short distance (~1.1km)', () => {
// ~1km apart in Oslo
const d = distanceMeters(59.91, 10.75, 59.92, 10.75);
expect(d).toBeGreaterThan(1000);
expect(d).toBeLessThan(1200);
});
});
describe('bearingDegrees', () => {
it('north bearing is ~0', () => {
const b = bearingDegrees(59.9, 10.7, 60.9, 10.7);
expect(b).toBeCloseTo(0, 0);
});
it('east bearing is ~90', () => {
const b = bearingDegrees(59.9, 10.0, 59.9, 11.0);
expect(b).toBeGreaterThan(85);
expect(b).toBeLessThan(95);
});
it('south bearing is ~180', () => {
const b = bearingDegrees(60.0, 10.7, 59.0, 10.7);
expect(b).toBeCloseTo(180, 0);
});
it('west bearing is ~270', () => {
const b = bearingDegrees(59.9, 11.0, 59.9, 10.0);
expect(b).toBeGreaterThan(265);
expect(b).toBeLessThan(275);
});
});
describe('formatDistance', () => {
it('formats short distance in meters', () => {
expect(formatDistance(42)).toBe('42 m');
expect(formatDistance(999)).toBe('999 m');
});
it('formats long distance in km', () => {
expect(formatDistance(1000)).toBe('1.0 km');
expect(formatDistance(1500)).toBe('1.5 km');
expect(formatDistance(12345)).toBe('12.3 km');
});
it('rounds meters to nearest integer', () => {
expect(formatDistance(42.7)).toBe('43 m');
});
});

64
pwa/tests/i18n.test.ts Normal file
View file

@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Must mock navigator.languages before importing i18n
const mockLanguages = { value: ['en'] };
Object.defineProperty(globalThis.navigator, 'languages', {
get: () => mockLanguages.value,
configurable: true,
});
describe('i18n', () => {
beforeEach(async () => {
// Re-import to reset state
vi.resetModules();
});
it('returns English strings by default', async () => {
mockLanguages.value = ['en'];
const { initLocale, t } = await import('../src/i18n/i18n');
initLocale();
expect(t('app_name')).toBe('Tilfluktsrom');
expect(t('status_ready')).toBe('Ready');
});
it('detects Norwegian Bokmål', async () => {
mockLanguages.value = ['nb-NO'];
const { initLocale, t } = await import('../src/i18n/i18n');
initLocale();
expect(t('status_ready')).toBe('Klar');
});
it('detects "no" as Bokmål', async () => {
mockLanguages.value = ['no'];
const { initLocale, t } = await import('../src/i18n/i18n');
initLocale();
expect(t('status_ready')).toBe('Klar');
});
it('detects Nynorsk', async () => {
mockLanguages.value = ['nn'];
const { initLocale, t } = await import('../src/i18n/i18n');
initLocale();
expect(t('status_offline')).toBe('Fråkopla modus');
});
it('substitutes %d placeholders', async () => {
mockLanguages.value = ['en'];
const { initLocale, t } = await import('../src/i18n/i18n');
initLocale();
expect(t('status_shelters_loaded', 556)).toBe('556 shelters loaded');
expect(t('shelter_capacity', 200)).toBe('200 places');
});
it('falls back to English for unknown locale', async () => {
mockLanguages.value = ['fr'];
const { initLocale, t } = await import('../src/i18n/i18n');
initLocale();
expect(t('status_ready')).toBe('Ready');
});
it('returns key for unknown string key', async () => {
const { t } = await import('../src/i18n/i18n');
expect(t('nonexistent_key')).toBe('nonexistent_key');
});
});

View file

@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { findNearest } from '../src/location/shelter-finder';
import type { Shelter } from '../src/types';
const shelters: Shelter[] = [
{
lokalId: 'a',
romnr: 1,
plasser: 100,
adresse: 'Near',
latitude: 59.91,
longitude: 10.75,
},
{
lokalId: 'b',
romnr: 2,
plasser: 200,
adresse: 'Mid',
latitude: 59.95,
longitude: 10.75,
},
{
lokalId: 'c',
romnr: 3,
plasser: 300,
adresse: 'Far',
latitude: 60.0,
longitude: 10.75,
},
{
lokalId: 'd',
romnr: 4,
plasser: 400,
adresse: 'Very Far',
latitude: 61.0,
longitude: 10.75,
},
];
describe('findNearest', () => {
it('returns shelters sorted by distance', () => {
const result = findNearest(shelters, 59.9, 10.75);
expect(result).toHaveLength(3);
expect(result[0].shelter.lokalId).toBe('a');
expect(result[1].shelter.lokalId).toBe('b');
expect(result[2].shelter.lokalId).toBe('c');
});
it('returns requested count', () => {
const result = findNearest(shelters, 59.9, 10.75, 2);
expect(result).toHaveLength(2);
});
it('returns all if fewer than count', () => {
const result = findNearest(shelters.slice(0, 1), 59.9, 10.75, 5);
expect(result).toHaveLength(1);
});
it('calculates distance and bearing', () => {
const result = findNearest(shelters, 59.9, 10.75, 1);
expect(result[0].distanceMeters).toBeGreaterThan(0);
expect(result[0].bearingDegrees).toBeGreaterThanOrEqual(0);
expect(result[0].bearingDegrees).toBeLessThan(360);
});
it('handles empty shelter list', () => {
const result = findNearest([], 59.9, 10.75);
expect(result).toHaveLength(0);
});
});