From 9e03fcda0a391c6ba4f051591c290a42be8a45ef Mon Sep 17 00:00:00 2001 From: elpatron Date: Sun, 31 May 2026 11:08:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(logs):=20Kompass-Dial=20f=C3=BCr=20Kurs-?= =?UTF-8?q?=20und=20Windeingabe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt Textfelder für MgK, rwK und Wind durch einen mobilen Kompass-Ring, normalisiert Kurswinkel beim Speichern und führt Vitest mit Regressionstests für html lang ein. Co-authored-by: Cursor --- client/package-lock.json | 523 +++++++++++++++++++++- client/package.json | 5 +- client/src/App.css | 170 +++++++ client/src/components/CourseDialInput.tsx | 259 +++++++++++ client/src/components/LogEntryEditor.tsx | 72 +-- client/src/i18n/locales/de.json | 10 + client/src/i18n/locales/en.json | 10 + client/src/utils/courseAngle.test.ts | 75 ++++ client/src/utils/courseAngle.ts | 160 +++++++ client/src/utils/locale.test.ts | 62 +++ client/src/utils/logEntryPayload.ts | 13 +- client/tsconfig.app.json | 3 +- client/vite.config.ts | 5 + 13 files changed, 1330 insertions(+), 37 deletions(-) create mode 100644 client/src/components/CourseDialInput.tsx create mode 100644 client/src/utils/courseAngle.test.ts create mode 100644 client/src/utils/courseAngle.ts create mode 100644 client/src/utils/locale.test.ts diff --git a/client/package-lock.json b/client/package-lock.json index 7b07755..d951186 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -32,12 +32,14 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "happy-dom": "^20.9.0", "playwright": "^1.51.0", "qrcode": "^1.5.4", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^6.3.5", - "vite-plugin-pwa": "^1.0.1" + "vite-plugin-pwa": "^1.0.1", + "vitest": "^3.2.4" }, "engines": { "node": ">=20.0.0" @@ -2896,6 +2898,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2991,6 +3011,23 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", @@ -3255,6 +3292,131 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3360,6 +3522,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -3541,6 +3713,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3642,6 +3824,33 @@ "node": ">=10.0.0" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -3848,6 +4057,16 @@ "node": ">=0.10.0" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3979,6 +4198,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -4068,6 +4300,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4406,6 +4645,16 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4857,6 +5106,24 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz", + "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -5710,6 +5977,13 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6007,6 +6281,23 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -6705,6 +6996,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6773,6 +7071,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -6783,6 +7088,13 @@ "node": ">=0.1.14" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6937,6 +7249,26 @@ "node": ">=10" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -7018,6 +7350,20 @@ "utrie": "^1.0.2" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -7035,6 +7381,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -7439,6 +7815,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-pwa": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz", @@ -7470,6 +7869,79 @@ } } }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -7486,6 +7958,16 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", @@ -7610,6 +8092,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7855,6 +8354,28 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/client/package.json b/client/package.json index 8caa702..7117a1e 100644 --- a/client/package.json +++ b/client/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run", "preview": "vite preview", "generate:flyer": "node ../scripts/generate-beta-flyer.mjs", "generate:flyer:setup": "playwright install chromium" @@ -36,12 +37,14 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "happy-dom": "^20.9.0", "playwright": "^1.51.0", "qrcode": "^1.5.4", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^6.3.5", - "vite-plugin-pwa": "^1.0.1" + "vite-plugin-pwa": "^1.0.1", + "vitest": "^3.2.4" }, "engines": { "node": ">=20.0.0" diff --git a/client/src/App.css b/client/src/App.css index 5deac6c..78e8f9e 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -154,6 +154,176 @@ select.input-text { user-select: none; } +.course-dial-section { + grid-column: 1 / -1; +} + +.course-dial-tabs { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.course-dial-tab { + flex: 1; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--app-border-subtle); + background: var(--app-btn-secondary-bg); + color: var(--app-text-muted); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.course-dial-tab.is-active { + background: var(--app-accent-bg); + border-color: var(--app-accent-border); + color: var(--app-accent-light); +} + +.course-dial-tab:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.course-dial { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + width: 100%; + max-width: 260px; + margin: 0 auto; +} + +.course-dial--sm { + max-width: 220px; +} + +.course-dial--disabled { + opacity: 0.65; + pointer-events: none; +} + +.course-dial__step-toolbar { + display: flex; + gap: 6px; + width: 100%; +} + +.course-dial__step-btn { + flex: 1; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--app-border-subtle); + background: var(--app-surface-alt); + color: var(--app-text-muted); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.course-dial__step-btn.is-active { + border-color: var(--app-accent-border); + background: var(--app-accent-bg); + color: var(--app-accent-light); +} + +.course-dial__ring-wrap { + width: 100%; + touch-action: none; +} + +.course-dial__svg { + width: 100%; + height: auto; + display: block; + cursor: pointer; + user-select: none; +} + +.course-dial__track { + fill: none; + stroke: var(--app-border); + stroke-width: 2; +} + +.course-dial__tick { + stroke: var(--app-text-subtle); + stroke-width: 1.5; +} + +.course-dial__label { + fill: var(--app-text-muted); + font-size: 9px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.course-dial__needle line { + stroke: var(--app-accent-light); + stroke-width: 3; + stroke-linecap: round; +} + +.course-dial__needle circle { + fill: var(--app-accent-light); +} + +.course-dial__center { + fill: var(--app-text); + font-size: 15px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.course-dial__hint { + margin: 0; + font-size: 12px; + color: var(--app-text-muted); + text-align: center; + line-height: 1.4; +} + +.course-dial__input { + width: 100%; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.course-dial__mode-toggle { + border: none; + background: none; + color: var(--app-accent-light); + font-size: 13px; + font-weight: 500; + cursor: pointer; + text-decoration: underline; + padding: 4px; +} + +@media (prefers-reduced-motion: reduce) { + .course-dial__needle { + transition: none; + } +} + +@media (max-width: 640px) { + .course-dial { + max-width: min(72vw, 220px); + } + + .course-dial--sm { + max-width: min(68vw, 200px); + } + + .course-dial__label { + font-size: 8px; + } +} + .themed-select { position: relative; width: 100%; diff --git a/client/src/components/CourseDialInput.tsx b/client/src/components/CourseDialInput.tsx new file mode 100644 index 0000000..0769835 --- /dev/null +++ b/client/src/components/CourseDialInput.tsx @@ -0,0 +1,259 @@ +import { useCallback, useId, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + type CourseOutputMode, + type CourseStep, + dialDegreesToStorageValue, + formatCourseAngle, + formatCourseDisplay, + isCardinalDirection, + loadCourseDialStep, + parseCourseAngle, + pointerAngleToDegrees, + resolveCourseOutputMode, + saveCourseDialStep, + snapDegrees, + valueToDialDegrees +} from '../utils/courseAngle.js' + +interface CourseDialInputProps { + value: string + onChange: (value: string) => void + disabled?: boolean + step?: CourseStep + allowCardinal?: boolean + displayMode?: 'degrees' | 'cardinal' | 'auto' + size?: 'md' | 'sm' + 'aria-label': string + id?: string +} + +const TICK_DEGREES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330] + +function polarPoint(degrees: number, radius: number): { x: number; y: number } { + const rad = (degrees * Math.PI) / 180 + return { + x: 100 + Math.sin(rad) * radius, + y: 100 - Math.cos(rad) * radius + } +} + +export default function CourseDialInput({ + value, + onChange, + disabled = false, + step: stepProp, + allowCardinal = false, + displayMode = 'degrees', + size = 'md', + 'aria-label': ariaLabel, + id: idProp +}: CourseDialInputProps) { + const { t } = useTranslation() + const generatedId = useId() + const inputId = idProp ?? `${generatedId}-input` + const svgRef = useRef(null) + const [step, setStep] = useState(() => stepProp ?? loadCourseDialStep()) + const [inputDraft, setInputDraft] = useState(null) + const [outputModeOverride, setOutputModeOverride] = useState(null) + + const effectiveStep = stepProp ?? step + const outputMode = + outputModeOverride ?? + resolveCourseOutputMode(value, displayMode, allowCardinal) + + const dialDegrees = useMemo( + () => snapDegrees(valueToDialDegrees(value, allowCardinal), effectiveStep), + [value, allowCardinal, effectiveStep] + ) + + const centerLabel = useMemo( + () => formatCourseDisplay(value, allowCardinal), + [value, allowCardinal] + ) + + const applyDegrees = useCallback( + (degrees: number) => { + onChange(dialDegreesToStorageValue(degrees, outputMode, effectiveStep)) + setInputDraft(null) + }, + [onChange, outputMode, effectiveStep] + ) + + const updateFromPointer = useCallback( + (clientX: number, clientY: number) => { + const svg = svgRef.current + if (!svg || disabled) return + const rect = svg.getBoundingClientRect() + const cx = rect.left + rect.width / 2 + const cy = rect.top + rect.height / 2 + const raw = pointerAngleToDegrees(clientX, clientY, cx, cy) + applyDegrees(raw) + }, + [applyDegrees, disabled] + ) + + const handlePointerDown = (e: React.PointerEvent) => { + if (disabled) return + e.preventDefault() + e.currentTarget.setPointerCapture(e.pointerId) + updateFromPointer(e.clientX, e.clientY) + } + + const handlePointerMove = (e: React.PointerEvent) => { + if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return + updateFromPointer(e.clientX, e.clientY) + } + + const handlePointerUp = (e: React.PointerEvent) => { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId) + } + } + + const handleInputChange = (e: React.ChangeEvent) => { + setInputDraft(e.target.value) + } + + const commitInput = () => { + const draft = (inputDraft ?? value).trim() + setInputDraft(null) + if (!draft) { + onChange('') + return + } + if (allowCardinal && outputMode === 'cardinal' && isCardinalDirection(draft)) { + onChange(draft.toUpperCase()) + return + } + const parsed = parseCourseAngle(draft) + if (parsed === null) return + onChange(formatCourseAngle(snapDegrees(parsed, effectiveStep))) + } + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + commitInput() + return + } + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault() + const base = parseCourseAngle(value) ?? dialDegrees + const delta = e.key === 'ArrowUp' ? effectiveStep : -effectiveStep + applyDegrees(base + delta) + } + } + + const handleStepChange = (next: CourseStep) => { + if (stepProp !== undefined) return + setStep(next) + saveCourseDialStep(next) + const parsed = parseCourseAngle(value) + if (parsed !== null) { + onChange(formatCourseAngle(snapDegrees(parsed, next))) + } + } + + const toggleOutputMode = () => { + const next: CourseOutputMode = outputMode === 'cardinal' ? 'degrees' : 'cardinal' + setOutputModeOverride(next) + const deg = valueToDialDegrees(value, allowCardinal) + onChange(dialDegreesToStorageValue(deg, next, effectiveStep)) + } + + const inputValue = inputDraft ?? value + const sliderNow = dialDegrees + + return ( +
+ {!stepProp && ( +
+ {([1, 5, 10] as const).map((s) => ( + + ))} +
+ )} + +
+ + + {TICK_DEGREES.map((deg) => { + const inner = polarPoint(deg, 76) + const outer = polarPoint(deg, 88) + const label = polarPoint(deg, 64) + return ( + + + + {String(deg).padStart(3, '0')} + + + ) + })} + + + + + + {centerLabel} + + +
+ +

{t('logs.course_dial_hint')}

+ + + + {allowCardinal && displayMode === 'auto' && ( + + )} +
+ ) +} diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 04f1a4e..24f6beb 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -24,6 +24,8 @@ import { import type { SignatureValue } from '../types/signatures.js' import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js' import EventTimeInput24h from './EventTimeInput24h.tsx' +import CourseDialInput from './CourseDialInput.tsx' +import { degreesToCardinal } from '../utils/courseAngle.js' import { hashEntryForSigning } from '../utils/entryCanonicalHash.js' import { signLogEntry } from '../services/entrySigning.js' import { getLogbookAccess } from '../services/logbookAccess.js' @@ -180,6 +182,7 @@ export default function LogEntryEditor({ const [evGpsLng, setEvGpsLng] = useState('') const [evRemarks, setEvRemarks] = useState('') const [evLocationName, setEvLocationName] = useState('') + const [activeCourseTab, setActiveCourseTab] = useState<'mgk' | 'rwk'>('mgk') const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) @@ -814,10 +817,7 @@ export default function LogEntryEditor({ // Calculate wind compass direction sector if (wind?.deg !== undefined) { - const deg = wind.deg - const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] - const index = Math.round(deg / 22.5) % 16 - setEvWindDirection(sectors[index]) + setEvWindDirection(degreesToCardinal(wind.deg)) } if (data.weather && Array.isArray(data.weather) && data.weather[0]) { @@ -1377,27 +1377,38 @@ export default function LogEntryEditor({ /> -
- - setEvMgk(e.target.value)} - disabled={saving} - /> -
- -
- - setEvRwk(e.target.value)} +
+ +
+ + +
+
@@ -1476,15 +1487,16 @@ export default function LogEntryEditor({
-
+
- setEvWindDirection(e.target.value)} + onChange={setEvWindDirection} disabled={saving || weatherLoading} + allowCardinal + displayMode="auto" + size="sm" + aria-label={t('logs.event_wind_direction')} />
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 98fb71c..5568c87 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -190,6 +190,16 @@ "event_time": "Uhrzeit", "event_mgk": "MgK Kurs", "event_rwk": "RwK Kurs", + "event_course_section": "Kurs", + "course_dial_hint": "Am Ring drehen oder Wert eingeben", + "course_step_fine": "1°", + "course_step_medium": "5°", + "course_step_coarse": "10°", + "course_tab_mgk": "MgK", + "course_tab_rwk": "rwK", + "course_invalid": "Ungültiger Kurs (0–360)", + "wind_mode_cardinal": "Kardinal", + "wind_mode_degrees": "Als Grad", "event_wind_direction": "Wind-Richtung", "event_wind_strength": "Windstärke", "event_sea_state": "Seegang", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index ec9297b..cc37433 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -190,6 +190,16 @@ "event_time": "Time", "event_mgk": "MgK Course", "event_rwk": "RwK Course", + "event_course_section": "Course", + "course_dial_hint": "Drag the ring or enter a value", + "course_step_fine": "1°", + "course_step_medium": "5°", + "course_step_coarse": "10°", + "course_tab_mgk": "MgK", + "course_tab_rwk": "rwK", + "course_invalid": "Invalid course (0–360)", + "wind_mode_cardinal": "Cardinal", + "wind_mode_degrees": "As degrees", "event_wind_direction": "Wind Dir", "event_wind_strength": "Wind Str", "event_sea_state": "Sea State", diff --git a/client/src/utils/courseAngle.test.ts b/client/src/utils/courseAngle.test.ts new file mode 100644 index 0000000..108cae3 --- /dev/null +++ b/client/src/utils/courseAngle.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + cardinalToDegrees, + degreesToCardinal, + formatCourseAngle, + isCardinalDirection, + normalizeCourseAngleString, + normalizeWindDirectionString, + parseCourseAngle, + pointerAngleToDegrees, + snapDegrees +} from './courseAngle.js' + +describe('parseCourseAngle', () => { + it('parses padded and plain degrees', () => { + expect(parseCourseAngle('042')).toBe(42) + expect(parseCourseAngle('185°')).toBe(185) + expect(parseCourseAngle('360')).toBe(0) + }) + + it('rejects invalid values', () => { + expect(parseCourseAngle('999')).toBeNull() + expect(parseCourseAngle('abc')).toBeNull() + }) + + it('parses cardinal labels', () => { + expect(parseCourseAngle('NW')).toBe(315) + }) +}) + +describe('snapDegrees', () => { + it('snaps to step', () => { + expect(snapDegrees(47, 5)).toBe(45) + expect(snapDegrees(358, 5)).toBe(0) + }) +}) + +describe('cardinal helpers', () => { + it('roundtrips cardinal through degrees', () => { + expect(degreesToCardinal(225)).toBe('SW') + expect(cardinalToDegrees('SW')).toBe(225) + expect(isCardinalDirection('nne')).toBe(true) + }) +}) + +describe('pointerAngleToDegrees', () => { + it('returns 0 for north', () => { + expect(pointerAngleToDegrees(100, 50, 100, 100)).toBe(0) + }) + + it('returns 90 for east', () => { + expect(Math.round(pointerAngleToDegrees(150, 100, 100, 100))).toBe(90) + }) +}) + +describe('normalizeCourseAngleString', () => { + it('keeps empty when allowed', () => { + expect(normalizeCourseAngleString('', { allowEmpty: true })).toBe('') + }) + + it('normalizes numeric course', () => { + expect(normalizeCourseAngleString('042')).toBe('42') + expect(formatCourseAngle(42, true)).toBe('042') + }) +}) + +describe('normalizeWindDirectionString', () => { + it('preserves cardinal wind', () => { + expect(normalizeWindDirectionString('nw')).toBe('NW') + }) + + it('normalizes degree wind', () => { + expect(normalizeWindDirectionString('090')).toBe('90') + }) +}) diff --git a/client/src/utils/courseAngle.ts b/client/src/utils/courseAngle.ts new file mode 100644 index 0000000..5776856 --- /dev/null +++ b/client/src/utils/courseAngle.ts @@ -0,0 +1,160 @@ +export const CARDINAL_DIRECTIONS = [ + 'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', + 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW' +] as const + +export type CardinalDirection = (typeof CARDINAL_DIRECTIONS)[number] +export type CourseStep = 1 | 5 | 10 + +const CARDINAL_SET = new Set(CARDINAL_DIRECTIONS) + +export function isCardinalDirection(value: string): boolean { + return CARDINAL_SET.has(value.trim().toUpperCase()) +} + +export function cardinalToDegrees(label: string): number | null { + const upper = label.trim().toUpperCase() + const index = CARDINAL_DIRECTIONS.indexOf(upper as CardinalDirection) + if (index < 0) return null + return (index * 22.5) % 360 +} + +export function degreesToCardinal(degrees: number): CardinalDirection { + const normalized = ((degrees % 360) + 360) % 360 + const index = Math.round(normalized / 22.5) % 16 + return CARDINAL_DIRECTIONS[index] +} + +export function snapDegrees(degrees: number, step: CourseStep): number { + const normalized = ((degrees % 360) + 360) % 360 + const snapped = Math.round(normalized / step) * step + return snapped >= 360 ? 0 : snapped +} + +/** 0° = north, clockwise (maritime compass). */ +export function pointerAngleToDegrees( + clientX: number, + clientY: number, + centerX: number, + centerY: number +): number { + const dx = clientX - centerX + const dy = centerY - clientY + const radians = Math.atan2(dx, dy) + let degrees = (radians * 180) / Math.PI + if (degrees < 0) degrees += 360 + return degrees +} + +export function parseCourseAngle(value: string): number | null { + const trimmed = value.trim().replace(/°/g, '') + if (!trimmed) return null + + const cardinalDeg = cardinalToDegrees(trimmed) + if (cardinalDeg !== null) return Math.round(cardinalDeg) + + if (!/^\d{1,3}$/.test(trimmed)) return null + const degrees = parseInt(trimmed, 10) + if (Number.isNaN(degrees)) return null + if (degrees === 360) return 0 + if (degrees < 0 || degrees > 360) return null + return degrees +} + +export function formatCourseAngle(degrees: number, pad = false): string { + const normalized = ((Math.round(degrees) % 360) + 360) % 360 + const text = String(normalized) + return pad ? text.padStart(3, '0') : text +} + +export function normalizeCourseAngleString( + value: string, + options?: { allowEmpty?: boolean } +): string { + const trimmed = value.trim() + if (!trimmed) return options?.allowEmpty ? '' : '' + + if (isCardinalDirection(trimmed)) { + return trimmed.toUpperCase() + } + + const parsed = parseCourseAngle(trimmed) + if (parsed === null) return trimmed + return formatCourseAngle(parsed) +} + +export function normalizeWindDirectionString(value: string): string { + const trimmed = value.trim() + if (!trimmed) return '' + + if (isCardinalDirection(trimmed)) { + return trimmed.toUpperCase() + } + + const parsed = parseCourseAngle(trimmed) + if (parsed === null) return trimmed + return formatCourseAngle(parsed) +} + +export function valueToDialDegrees(value: string, allowCardinal = false): number { + const parsed = parseCourseAngle(value) + if (parsed !== null) return parsed + if (allowCardinal && isCardinalDirection(value)) { + return cardinalToDegrees(value) ?? 0 + } + return 0 +} + +export type CourseOutputMode = 'degrees' | 'cardinal' + +export function resolveCourseOutputMode( + value: string, + displayMode: 'degrees' | 'cardinal' | 'auto', + allowCardinal: boolean +): CourseOutputMode { + if (!allowCardinal || displayMode === 'degrees') return 'degrees' + if (displayMode === 'cardinal') return 'cardinal' + return isCardinalDirection(value) ? 'cardinal' : 'degrees' +} + +export function dialDegreesToStorageValue( + degrees: number, + mode: CourseOutputMode, + step: CourseStep +): string { + const snapped = snapDegrees(degrees, step) + if (mode === 'cardinal') return degreesToCardinal(snapped) + return formatCourseAngle(snapped) +} + +export function formatCourseDisplay( + value: string, + allowCardinal: boolean +): string { + if (!value.trim()) return '—' + if (allowCardinal && isCardinalDirection(value)) return value.toUpperCase() + const parsed = parseCourseAngle(value) + if (parsed === null) return value + return `${formatCourseAngle(parsed, true)}°` +} + +const STEP_STORAGE_KEY = 'kaptein-course-dial-step' + +export function loadCourseDialStep(): CourseStep { + try { + const raw = sessionStorage.getItem(STEP_STORAGE_KEY) + if (raw === '5') return 5 + if (raw === '10') return 10 + } catch { + /* ignore */ + } + return 1 +} + +export function saveCourseDialStep(step: CourseStep): void { + try { + sessionStorage.setItem(STEP_STORAGE_KEY, String(step)) + } catch { + /* ignore */ + } +} diff --git a/client/src/utils/locale.test.ts b/client/src/utils/locale.test.ts new file mode 100644 index 0000000..dd9d29a --- /dev/null +++ b/client/src/utils/locale.test.ts @@ -0,0 +1,62 @@ +import type { i18n as I18nInstance } from 'i18next' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveIntlLocale } from './dateTimeFormat.js' +import { initSeo, normalizeSeoLang, updatePageSeo } from './seo.js' + +const HTML_LANG = /^de|en$/ + +function createMockI18n(language: string): I18nInstance { + return { + isInitialized: true, + language, + t: (key: string) => key, + on: vi.fn() + } as unknown as I18nInstance +} + +describe('normalizeSeoLang', () => { + it.each([ + ['de', 'de'], + ['de-DE', 'de'], + ['en', 'en'], + ['en-US', 'en'], + ['en-GB', 'en'] + ] as const)('maps %s to short code %s', (input, expected) => { + expect(normalizeSeoLang(input)).toBe(expected) + }) +}) + +describe('updatePageSeo html lang', () => { + beforeEach(() => { + document.documentElement.lang = 'de' + window.history.replaceState({}, '', '/') + }) + + it.each([ + ['de', 'de'], + ['en', 'en'], + ['en-GB', 'en'] + ] as const)('sets html lang to %s when i18n language is %s', (i18nLanguage, expectedLang) => { + initSeo(createMockI18n(i18nLanguage)) + updatePageSeo() + + expect(document.documentElement.lang).toBe(expectedLang) + expect(document.documentElement.lang).toMatch(HTML_LANG) + }) +}) + +describe('resolveIntlLocale', () => { + it('uses full BCP 47 tags for Intl formatting only', () => { + expect(resolveIntlLocale('de')).toBe('de-DE') + expect(resolveIntlLocale('en')).toBe('en-GB') + }) + + it('does not reuse Intl locale tags for html lang', () => { + const intlLocale = resolveIntlLocale('en') + const htmlLang = normalizeSeoLang('en') + + expect(intlLocale).toBe('en-GB') + expect(htmlLang).toBe('en') + expect(htmlLang).not.toBe(intlLocale) + }) +}) diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index c9f4648..5530db8 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -1,3 +1,8 @@ +import { + normalizeCourseAngleString, + normalizeWindDirectionString +} from './courseAngle.js' + export interface LogEventPayload { time: string mgk: string @@ -79,10 +84,10 @@ export function normalizeLogEvent(event: Partial | Record= 5 ? timeRaw.slice(0, 5) : timeRaw), - mgk: '', - rwk: '', + mgk: normalizeCourseAngleString(String(e.mgk ?? ''), { allowEmpty: true }), + rwk: normalizeCourseAngleString(String(e.rwk ?? ''), { allowEmpty: true }), windPressure: '', - windDirection: '', + windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')), windStrength: '', seaState: '', weatherIcon: '', @@ -96,7 +101,7 @@ export function normalizeLogEvent(event: Partial | Record import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' @@ -20,6 +21,10 @@ function readAppVersion(): string { // https://vite.dev/config/ export default defineConfig({ + test: { + environment: 'happy-dom', + include: ['src/**/*.test.ts'] + }, define: { __APP_VERSION__: JSON.stringify(readAppVersion()) },