feat(logs): Kompass-Dial für Kurs- und Windeingabe

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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 11:08:36 +02:00
parent 34c7d2d65c
commit 9e03fcda0a
13 changed files with 1330 additions and 37 deletions
+522 -1
View File
@@ -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",
+4 -1
View File
@@ -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"
+170
View File
@@ -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%;
+259
View File
@@ -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<SVGSVGElement>(null)
const [step, setStep] = useState<CourseStep>(() => stepProp ?? loadCourseDialStep())
const [inputDraft, setInputDraft] = useState<string | null>(null)
const [outputModeOverride, setOutputModeOverride] = useState<CourseOutputMode | null>(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<SVGSVGElement>) => {
if (disabled) return
e.preventDefault()
e.currentTarget.setPointerCapture(e.pointerId)
updateFromPointer(e.clientX, e.clientY)
}
const handlePointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return
updateFromPointer(e.clientX, e.clientY)
}
const handlePointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div
className={`course-dial course-dial--${size}${disabled ? ' course-dial--disabled' : ''}`}
>
{!stepProp && (
<div className="course-dial__step-toolbar" role="group" aria-label={ariaLabel}>
{([1, 5, 10] as const).map((s) => (
<button
key={s}
type="button"
className={`course-dial__step-btn${effectiveStep === s ? ' is-active' : ''}`}
onClick={() => handleStepChange(s)}
disabled={disabled}
aria-pressed={effectiveStep === s}
>
{s === 1 ? t('logs.course_step_fine') : s === 5 ? t('logs.course_step_medium') : t('logs.course_step_coarse')}
</button>
))}
</div>
)}
<div
className="course-dial__ring-wrap"
role="slider"
aria-label={ariaLabel}
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={sliderNow}
aria-disabled={disabled}
>
<svg
ref={svgRef}
className="course-dial__svg"
viewBox="0 0 200 200"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<circle className="course-dial__track" cx="100" cy="100" r="88" />
{TICK_DEGREES.map((deg) => {
const inner = polarPoint(deg, 76)
const outer = polarPoint(deg, 88)
const label = polarPoint(deg, 64)
return (
<g key={deg}>
<line className="course-dial__tick" x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} />
<text className="course-dial__label" x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle">
{String(deg).padStart(3, '0')}
</text>
</g>
)
})}
<g className="course-dial__needle" transform={`rotate(${dialDegrees} 100 100)`}>
<line x1="100" y1="100" x2="100" y2="28" />
<circle cx="100" cy="100" r="6" />
</g>
<text className="course-dial__center" x="100" y="100" textAnchor="middle" dominantBaseline="middle">
{centerLabel}
</text>
</svg>
</div>
<p className="course-dial__hint">{t('logs.course_dial_hint')}</p>
<input
id={inputId}
type="text"
inputMode="numeric"
className="input-text course-dial__input"
value={inputValue}
onChange={handleInputChange}
onBlur={commitInput}
onKeyDown={handleInputKeyDown}
disabled={disabled}
placeholder={outputMode === 'cardinal' ? 'NW' : '180'}
aria-label={ariaLabel}
/>
{allowCardinal && displayMode === 'auto' && (
<button
type="button"
className="course-dial__mode-toggle"
onClick={toggleOutputMode}
disabled={disabled}
>
{outputMode === 'cardinal' ? t('logs.wind_mode_degrees') : t('logs.wind_mode_cardinal')}
</button>
)}
</div>
)
}
+42 -30
View File
@@ -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({
/>
</div>
<div className="input-group">
<label>{t('logs.event_mgk')}</label>
<input
type="text"
placeholder="e.g. 180"
className="input-text"
value={evMgk}
onChange={(e) => setEvMgk(e.target.value)}
disabled={saving}
/>
</div>
<div className="input-group">
<label>{t('logs.event_rwk')}</label>
<input
type="text"
placeholder="e.g. 185"
className="input-text"
value={evRwk}
onChange={(e) => setEvRwk(e.target.value)}
<div className="input-group course-dial-section">
<label>
<Compass size={12} style={{ display: 'inline', marginRight: 4 }} />
{t('logs.event_course_section')}
</label>
<div className="course-dial-tabs" role="tablist" aria-label={t('logs.event_course_section')}>
<button
type="button"
role="tab"
aria-selected={activeCourseTab === 'mgk'}
className={`course-dial-tab${activeCourseTab === 'mgk' ? ' is-active' : ''}`}
onClick={() => setActiveCourseTab('mgk')}
disabled={saving}
>
{t('logs.course_tab_mgk')}
</button>
<button
type="button"
role="tab"
aria-selected={activeCourseTab === 'rwk'}
className={`course-dial-tab${activeCourseTab === 'rwk' ? ' is-active' : ''}`}
onClick={() => setActiveCourseTab('rwk')}
disabled={saving}
>
{t('logs.course_tab_rwk')}
</button>
</div>
<CourseDialInput
value={activeCourseTab === 'mgk' ? evMgk : evRwk}
onChange={activeCourseTab === 'mgk' ? setEvMgk : setEvRwk}
disabled={saving}
aria-label={activeCourseTab === 'mgk' ? t('logs.event_mgk') : t('logs.event_rwk')}
/>
</div>
@@ -1476,15 +1487,16 @@ export default function LogEntryEditor({
</div>
<div className="form-grid mb-4">
<div className="input-group">
<div className="input-group course-dial-section">
<label>{t('logs.event_wind_direction')}</label>
<input
type="text"
placeholder="e.g. NNE"
className="input-text"
<CourseDialInput
value={evWindDirection}
onChange={(e) => setEvWindDirection(e.target.value)}
onChange={setEvWindDirection}
disabled={saving || weatherLoading}
allowCardinal
displayMode="auto"
size="sm"
aria-label={t('logs.event_wind_direction')}
/>
</div>
+10
View File
@@ -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 (0360)",
"wind_mode_cardinal": "Kardinal",
"wind_mode_degrees": "Als Grad",
"event_wind_direction": "Wind-Richtung",
"event_wind_strength": "Windstärke",
"event_sea_state": "Seegang",
+10
View File
@@ -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 (0360)",
"wind_mode_cardinal": "Cardinal",
"wind_mode_degrees": "As degrees",
"event_wind_direction": "Wind Dir",
"event_wind_strength": "Wind Str",
"event_sea_state": "Sea State",
+75
View File
@@ -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')
})
})
+160
View File
@@ -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<string>(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 */
}
}
+62
View File
@@ -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)
})
})
+9 -4
View File
@@ -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<LogEventPayload> | Record<strin
const timeRaw = String(e.time ?? '').trim()
const normalized: LogEventPayload = {
time: parseTimeToHHMM(timeRaw) ?? (timeRaw.length >= 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<LogEventPayload> | Record<strin
remarks: ''
}
for (const key of LOG_EVENT_FIELDS) {
if (key === 'time') continue
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue
normalized[key] = String(e[key] ?? '').trim()
}
return normalized
+2 -1
View File
@@ -21,5 +21,6 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+5
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
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())
},