Compare commits
6 Commits
fbeffccf93
...
7fcae34c60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fcae34c60 | ||
|
|
1535c829ca | ||
|
|
c62bdde727 | ||
|
|
a7a4df52a9 | ||
|
|
c1d72184eb | ||
|
|
2c0e9c360f |
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
@@ -6,20 +6,25 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"daisyui": "^5.5.23",
|
||||
"smol-toml": "^1.6.1",
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"happy-dom": "^20.10.4",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.9",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
|
||||
693
pnpm-lock.yaml
generated
693
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
daisyui:
|
||||
specifier: ^5.5.23
|
||||
version: 5.5.23
|
||||
smol-toml:
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1
|
||||
vue:
|
||||
specifier: ^3.5.34
|
||||
version: 3.5.38(typescript@6.0.3)
|
||||
@@ -24,9 +27,15 @@ importers:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.6
|
||||
version: 6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))(vue@3.5.38(typescript@6.0.3))
|
||||
'@vue/test-utils':
|
||||
specifier: ^2.4.11
|
||||
version: 2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))
|
||||
'@vue/tsconfig':
|
||||
specifier: ^0.9.1
|
||||
version: 0.9.1(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
|
||||
happy-dom:
|
||||
specifier: ^20.10.4
|
||||
version: 20.10.4
|
||||
tailwindcss:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1
|
||||
@@ -36,6 +45,9 @@ importers:
|
||||
vite:
|
||||
specifier: ^8.0.12
|
||||
version: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@24.13.2)(happy-dom@20.10.4)(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))
|
||||
vue-tsc:
|
||||
specifier: ^3.2.8
|
||||
version: 3.3.5(typescript@6.0.3)
|
||||
@@ -68,6 +80,10 @@ packages:
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -90,9 +106,16 @@ packages:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@one-ini/wasm@0.1.1':
|
||||
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
|
||||
|
||||
'@oxc-project/types@0.133.0':
|
||||
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.3':
|
||||
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -191,6 +214,9 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@tailwindcss/node@4.3.1':
|
||||
resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==}
|
||||
|
||||
@@ -288,9 +314,24 @@ packages:
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@24.13.2':
|
||||
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2':
|
||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7':
|
||||
resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -298,6 +339,35 @@ packages:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
|
||||
|
||||
'@vitest/mocker@4.1.9':
|
||||
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
|
||||
|
||||
'@vitest/spy@4.1.9':
|
||||
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
|
||||
|
||||
@@ -339,6 +409,16 @@ packages:
|
||||
'@vue/shared@3.5.38':
|
||||
resolution: {integrity: sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==}
|
||||
|
||||
'@vue/test-utils@2.4.11':
|
||||
resolution: {integrity: sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==}
|
||||
peerDependencies:
|
||||
'@vue/compiler-dom': 3.x
|
||||
'@vue/server-renderer': 3.x
|
||||
vue: 3.x
|
||||
peerDependenciesMeta:
|
||||
'@vue/server-renderer':
|
||||
optional: true
|
||||
|
||||
'@vue/tsconfig@0.9.1':
|
||||
resolution: {integrity: sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==}
|
||||
peerDependencies:
|
||||
@@ -350,9 +430,68 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
abbrev@2.0.0:
|
||||
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
alien-signals@3.2.1:
|
||||
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
brace-expansion@2.1.1:
|
||||
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
|
||||
|
||||
buffer-image-size@0.6.4:
|
||||
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
commander@10.0.1:
|
||||
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
config-chain@1.1.13:
|
||||
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -363,6 +502,20 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
editorconfig@1.0.7:
|
||||
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
enhanced-resolve@5.21.6:
|
||||
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -371,9 +524,19 @@ packages:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -383,18 +546,52 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
happy-dom@20.10.4:
|
||||
resolution: {integrity: sha512-bKrsQnFNpcjZG0UPsH7UMN7Oyp3AB42LXk2GuiQmu7l4QFxH7lsw5T1eWEtE2+vbIFrTC45sbNSB2pkB8MTfKA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
ini@1.3.8:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-beautify@1.15.4:
|
||||
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
js-cookie@3.0.8:
|
||||
resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -469,9 +666,20 @@ packages:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minipass@7.1.3:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
@@ -480,9 +688,32 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nopt@7.2.1:
|
||||
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
hasBin: true
|
||||
|
||||
obug@2.1.3:
|
||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
engines: {node: '>=16 || 14 >=14.18'}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -494,15 +725,64 @@ packages:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
proto-list@1.2.4:
|
||||
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
|
||||
|
||||
rolldown@1.0.3:
|
||||
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.4:
|
||||
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shebang-regex@3.0.0:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
signal-exit@4.1.0:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
smol-toml@1.6.1:
|
||||
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string-width@5.1.2:
|
||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
tailwindcss@4.3.1:
|
||||
resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==}
|
||||
|
||||
@@ -510,10 +790,21 @@ packages:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.2.4:
|
||||
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -568,9 +859,53 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.9:
|
||||
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.9
|
||||
'@vitest/browser-preview': 4.1.9
|
||||
'@vitest/browser-webdriverio': 4.1.9
|
||||
'@vitest/coverage-istanbul': 4.1.9
|
||||
'@vitest/coverage-v8': 4.1.9
|
||||
'@vitest/ui': 4.1.9
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
|
||||
vue-tsc@3.3.5:
|
||||
resolution: {integrity: sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==}
|
||||
hasBin: true
|
||||
@@ -585,6 +920,40 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
whatwg-mimetype@3.0.0:
|
||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
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
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
@@ -616,6 +985,15 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
string-width-cjs: string-width@4.2.3
|
||||
strip-ansi: 7.2.0
|
||||
strip-ansi-cjs: strip-ansi@6.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -642,8 +1020,13 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.2
|
||||
optional: true
|
||||
|
||||
'@one-ini/wasm@0.1.1': {}
|
||||
|
||||
'@oxc-project/types@0.133.0': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.3':
|
||||
optional: true
|
||||
|
||||
@@ -695,6 +1078,8 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tailwindcss/node@4.3.1':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -768,16 +1153,72 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@24.13.2':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.13.2
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))(vue@3.5.38(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.9(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.9
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.9': {}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
dependencies:
|
||||
'@volar/source-map': 2.4.28
|
||||
@@ -854,19 +1295,88 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.5.38': {}
|
||||
|
||||
'@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.38
|
||||
js-beautify: 1.15.4
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
vue-component-type-helpers: 3.3.5
|
||||
optionalDependencies:
|
||||
'@vue/server-renderer': 3.5.38(vue@3.5.38(typescript@6.0.3))
|
||||
|
||||
'@vue/tsconfig@0.9.1(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))':
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
|
||||
abbrev@2.0.0: {}
|
||||
|
||||
alien-signals@3.2.1: {}
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
brace-expansion@2.1.1:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
buffer-image-size@0.6.4:
|
||||
dependencies:
|
||||
'@types/node': 24.13.2
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
commander@10.0.1: {}
|
||||
|
||||
config-chain@1.1.13:
|
||||
dependencies:
|
||||
ini: 1.3.8
|
||||
proto-list: 1.2.4
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
daisyui@5.5.23: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
editorconfig@1.0.7:
|
||||
dependencies:
|
||||
'@one-ini/wasm': 0.1.1
|
||||
commander: 10.0.1
|
||||
minimatch: 9.0.9
|
||||
semver: 7.8.4
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
enhanced-resolve@5.21.6:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -874,19 +1384,76 @@ snapshots:
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
glob@10.5.0:
|
||||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 9.0.9
|
||||
minipass: 7.1.3
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
happy-dom@20.10.4:
|
||||
dependencies:
|
||||
'@types/node': 24.13.2
|
||||
'@types/whatwg-mimetype': 3.0.2
|
||||
'@types/ws': 8.18.1
|
||||
buffer-image-size: 0.6.4
|
||||
entities: 7.0.1
|
||||
whatwg-mimetype: 3.0.0
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jackspeak@3.4.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-beautify@1.15.4:
|
||||
dependencies:
|
||||
config-chain: 1.1.13
|
||||
editorconfig: 1.0.7
|
||||
glob: 10.5.0
|
||||
js-cookie: 3.0.8
|
||||
nopt: 7.2.1
|
||||
|
||||
js-cookie@3.0.8: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -936,16 +1503,41 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
nopt@7.2.1:
|
||||
dependencies:
|
||||
abbrev: 2.0.0
|
||||
|
||||
obug@2.1.3: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
dependencies:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.3
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
@@ -956,6 +1548,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
proto-list@1.2.4: {}
|
||||
|
||||
rolldown@1.0.3:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.133.0
|
||||
@@ -977,17 +1571,61 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.3
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.3
|
||||
|
||||
semver@7.8.4: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
smol-toml@1.6.1: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string-width@5.1.2:
|
||||
dependencies:
|
||||
eastasianwidth: 0.2.0
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
tailwindcss@4.3.1: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.2.4: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
@@ -1007,8 +1645,38 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
|
||||
vitest@4.1.9(@types/node@24.13.2)(happy-dom@20.10.4)(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/runner': 4.1.9
|
||||
'@vitest/snapshot': 4.1.9
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.3
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.2
|
||||
happy-dom: 20.10.4
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
|
||||
vue-tsc@3.3.5(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@volar/typescript': 2.4.28
|
||||
@@ -1024,3 +1692,28 @@ snapshots:
|
||||
'@vue/shared': 3.5.38
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
whatwg-mimetype@3.0.0: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
48
src/App.vue
48
src/App.vue
@@ -1,19 +1,39 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { useMacroplan } from './composables/useMacroplan'
|
||||
import PlanEditor from './components/PlanEditor.vue'
|
||||
import MacroplanGrid from './components/MacroplanGrid.vue'
|
||||
|
||||
const { source, plan, error, resetToSample } = useMacroplan()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="font-sans min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||
<div class="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div class="card-body items-center text-center gap-3">
|
||||
<img src="/favicon.svg" alt="" class="size-12" />
|
||||
<h1 class="card-title text-3xl">Macroplan</h1>
|
||||
<p class="text-base-content/70">
|
||||
A week-granular, learning-oriented view of the features a team has
|
||||
committed to deliver.
|
||||
<div class="flex h-screen flex-col bg-base-200 font-sans text-base-content">
|
||||
<header class="navbar min-h-0 gap-2 border-b border-base-300 bg-base-100 px-4 py-2">
|
||||
<img src="/favicon.svg" alt="" class="size-6" />
|
||||
<div class="flex-1">
|
||||
<h1 class="text-lg font-semibold leading-tight">Macroplan</h1>
|
||||
<p class="text-xs text-base-content/60 leading-tight">
|
||||
A week-granular, learning-oriented record of what we committed to deliver.
|
||||
</p>
|
||||
<div class="card-actions mt-2">
|
||||
<button class="btn btn-primary">Get started</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<button class="btn btn-ghost btn-sm" @click="resetToSample">Reset to sample</button>
|
||||
</header>
|
||||
|
||||
<main class="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<section
|
||||
class="flex min-h-0 flex-col border-base-300 max-md:h-2/5 max-md:border-b md:w-2/5 md:max-w-xl md:border-r"
|
||||
>
|
||||
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
Source (TOML)
|
||||
</div>
|
||||
<PlanEditor v-model="source" :error="error" class="min-h-0 flex-1" />
|
||||
</section>
|
||||
|
||||
<section class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<h2 v-if="plan" class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2>
|
||||
<MacroplanGrid v-if="plan" :plan="plan" />
|
||||
<p v-else class="text-sm text-base-content/60">Nothing to render yet — fix the source on the left.</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
52
src/components/MacroplanGrid.test.ts
Normal file
52
src/components/MacroplanGrid.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import MacroplanGrid from './MacroplanGrid.vue'
|
||||
import { parseMacroplan } from '../model/parse'
|
||||
import { buildPlan } from '../model/plan'
|
||||
import { SAMPLE_PLAN } from '../data/sample'
|
||||
|
||||
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), '2026-06-17')
|
||||
|
||||
function mountGrid() {
|
||||
return mount(MacroplanGrid, { props: { plan } })
|
||||
}
|
||||
|
||||
describe('MacroplanGrid renders the sample plan', () => {
|
||||
it('renders one row per feature, in order', () => {
|
||||
const names = mountGrid()
|
||||
.findAll('.namecell')
|
||||
.map((n) => n.text())
|
||||
expect(names).toEqual(['Auth', 'Payments', 'Dashboard', 'Search', 'Notifications'])
|
||||
})
|
||||
|
||||
it('renders the right markers per feature', () => {
|
||||
const rows = mountGrid().findAll('.namecell')
|
||||
// each feature row is namecell + week cells + learncell; grab the row text via the grid
|
||||
const grid = mountGrid()
|
||||
const text = grid.text()
|
||||
expect(rows).toHaveLength(5)
|
||||
// on-time, late, and slip glyphs all present somewhere in the grid
|
||||
expect(text).toContain('◉') // Auth delivered on time
|
||||
expect(text).toContain('▲') // Payments delivered late
|
||||
expect(text).toContain('△') // re-estimates
|
||||
expect(text).toContain('◯') // open original estimates
|
||||
})
|
||||
|
||||
it('draws a now column and the milestone header', () => {
|
||||
const w = mountGrid()
|
||||
expect(w.find('.col-now').exists()).toBe(true)
|
||||
expect(w.text()).toContain('now')
|
||||
expect(w.text()).toContain('MVP go-live')
|
||||
})
|
||||
|
||||
it('shows a learning for a delivered feature and a status note for an in-flight one', () => {
|
||||
const text = mountGrid().text()
|
||||
expect(text).toContain('Vendor lead time') // Payments learning
|
||||
expect(text).toContain('No recovery plan yet') // Dashboard status note
|
||||
})
|
||||
|
||||
it('labels week columns', () => {
|
||||
expect(mountGrid().text()).toContain('Jun 15')
|
||||
})
|
||||
})
|
||||
341
src/components/MacroplanGrid.vue
Normal file
341
src/components/MacroplanGrid.vue
Normal file
@@ -0,0 +1,341 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Plan, FeatureRow, MarkerKind } from '../model/types'
|
||||
import { weekLabel, type WeekId } from '../model/week'
|
||||
|
||||
const props = defineProps<{ plan: Plan }>()
|
||||
|
||||
type Tone = 'success' | 'warning' | 'error' | 'neutral'
|
||||
|
||||
const GLYPH: Record<MarkerKind, string> = {
|
||||
original: '◯',
|
||||
reestimate: '△',
|
||||
'delivered-on-time': '◉',
|
||||
'delivered-late': '▲',
|
||||
}
|
||||
const MARKER_CLASS: Record<MarkerKind, string> = {
|
||||
original: 'text-base-content/50',
|
||||
reestimate: 'text-warning',
|
||||
'delivered-on-time': 'text-success',
|
||||
'delivered-late': 'text-error',
|
||||
}
|
||||
// When two markers land on the same week, the higher rank owns the cell.
|
||||
const RANK: Record<MarkerKind, number> = {
|
||||
'delivered-late': 3,
|
||||
'delivered-on-time': 3,
|
||||
reestimate: 2,
|
||||
original: 1,
|
||||
}
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
error: 'text-error',
|
||||
neutral: 'text-primary',
|
||||
}
|
||||
const TONE_BORDER: Record<Tone, string> = {
|
||||
success: 'border-success',
|
||||
warning: 'border-warning',
|
||||
error: 'border-error',
|
||||
neutral: 'border-primary',
|
||||
}
|
||||
const TONE_DOT: Record<Tone, string> = {
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
error: 'bg-error',
|
||||
neutral: 'bg-base-300',
|
||||
}
|
||||
|
||||
const weeks = computed(() => props.plan.weeks)
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `minmax(7rem, max-content) repeat(${weeks.value.length}, var(--wk)) minmax(9rem, 1fr)`,
|
||||
}))
|
||||
|
||||
function tone(row: FeatureRow): Tone {
|
||||
if (row.delivered) return row.onTime ? 'success' : 'error'
|
||||
if (row.status === 'green') return 'success'
|
||||
if (row.status === 'orange') return 'warning'
|
||||
if (row.status === 'red') return 'error'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function statusWord(row: FeatureRow): string {
|
||||
return row.status === 'green'
|
||||
? 'on track'
|
||||
: row.status === 'orange'
|
||||
? 'at risk'
|
||||
: row.status === 'red'
|
||||
? 'blocked'
|
||||
: 'in flight'
|
||||
}
|
||||
|
||||
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
|
||||
let best: MarkerKind | null = null
|
||||
for (const m of row.markers) {
|
||||
if (m.week === w && (best === null || RANK[m.kind] > RANK[best])) best = m.kind
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
interface Cell {
|
||||
inBar: boolean
|
||||
isStart: boolean
|
||||
glyph: string
|
||||
glyphCls: string
|
||||
}
|
||||
|
||||
// rows × weeks render matrix, computed once.
|
||||
const matrix = computed<Cell[][]>(() =>
|
||||
props.plan.rows.map((row) =>
|
||||
weeks.value.map((w) => {
|
||||
const m = markerAt(row, w)
|
||||
const inBar = w >= row.startWeek && w <= row.barEndWeek
|
||||
return {
|
||||
inBar,
|
||||
isStart: inBar && w === row.startWeek,
|
||||
glyph: m ? GLYPH[m] : '',
|
||||
glyphCls: m ? MARKER_CLASS[m] : '',
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Per-week column metadata for headers + the now / milestone vertical rules.
|
||||
const cols = computed(() =>
|
||||
weeks.value.map((w) => ({
|
||||
w,
|
||||
label: weekLabel(w),
|
||||
isNow: props.plan.nowInRange && w === props.plan.nowWeek,
|
||||
milestones: props.plan.milestones.filter((m) => m.week === w),
|
||||
})),
|
||||
)
|
||||
|
||||
function colClass(i: number): string {
|
||||
const c = cols.value[i]
|
||||
if (c.isNow) return 'col-now'
|
||||
if (c.milestones.length) return 'col-ms'
|
||||
return ''
|
||||
}
|
||||
|
||||
function milestoneTitle(ms: Plan['milestones']): string {
|
||||
return ms
|
||||
.map((m) => (m.unmet.length ? `${m.name} — unmet: ${m.unmet.join(', ')}` : `${m.name} — all met`))
|
||||
.join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-auto rounded-box border border-base-300 bg-base-100">
|
||||
<div class="plan-grid" :style="gridStyle">
|
||||
<!-- header row -->
|
||||
<div class="hcell corner">{{ plan.title }}</div>
|
||||
<div
|
||||
v-for="(c, ci) in cols"
|
||||
:key="'h-' + c.w"
|
||||
class="hcell wkhead"
|
||||
:class="[colClass(ci), { 'now-head': c.isNow }]"
|
||||
>
|
||||
<span class="wklabel">{{ c.label }}</span>
|
||||
<span v-if="c.isNow" class="badge-now">now</span>
|
||||
<span
|
||||
v-else-if="c.milestones.length"
|
||||
class="badge-ms"
|
||||
:title="milestoneTitle(c.milestones)"
|
||||
>◆ {{ c.milestones[0].name
|
||||
}}<span v-if="c.milestones.some((m) => m.unmet.length)" class="text-error"> !</span></span>
|
||||
</div>
|
||||
<div class="hcell learnhead">Learning / Status</div>
|
||||
|
||||
<!-- feature rows -->
|
||||
<template v-for="(row, ri) in plan.rows" :key="row.name">
|
||||
<div class="namecell border-l-4" :class="TONE_BORDER[tone(row)]">
|
||||
<span class="truncate">{{ row.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(cell, ci) in matrix[ri]"
|
||||
:key="row.name + '-' + ci"
|
||||
class="cell"
|
||||
:class="colClass(ci)"
|
||||
>
|
||||
<i v-if="cell.inBar" class="bar" :class="TONE_TEXT[tone(row)]"></i>
|
||||
<i v-if="cell.isStart" class="riser" :class="TONE_TEXT[tone(row)]"></i>
|
||||
<span v-if="cell.glyph" class="glyph" :class="cell.glyphCls">{{ cell.glyph }}</span>
|
||||
</div>
|
||||
<div class="learncell">
|
||||
<template v-if="row.delivered">
|
||||
<span v-if="row.learning" class="note">{{ row.learning }}</span>
|
||||
<span v-else class="muted">{{ row.onTime ? 'delivered on time' : 'delivered late' }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="dot" :class="TONE_DOT[tone(row)]"></span>
|
||||
<span class="note" :title="row.note || ''">{{ row.note || statusWord(row) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- legend -->
|
||||
<div class="legend">
|
||||
<span><b class="text-base-content/50">◯</b> original estimate</span>
|
||||
<span><b class="text-warning">△</b> re-estimate (slip)</span>
|
||||
<span><b class="text-success">◉</b> on time</span>
|
||||
<span><b class="text-error">▲</b> late</span>
|
||||
<span><b>┣━</b> feature bar</span>
|
||||
<span><b class="text-error">◆</b> milestone</span>
|
||||
<span>vertical rule = now</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plan-grid {
|
||||
--wk: 2.5rem;
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.hcell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-base-300);
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.corner {
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wkhead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.wklabel {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.now-head {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.badge-now {
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.badge-ms {
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
max-width: 6rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.learnhead {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.namecell {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-base-100);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
padding: 0 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--color-base-200);
|
||||
}
|
||||
.namecell .truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2rem;
|
||||
border-bottom: 1px solid var(--color-base-200);
|
||||
}
|
||||
.bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.riser {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 28%;
|
||||
bottom: 28%;
|
||||
width: 2px;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.glyph {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.learncell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2rem;
|
||||
padding: 0 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
border-bottom: 1px solid var(--color-base-200);
|
||||
}
|
||||
.learncell .muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.learncell .note {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dot {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 9999px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.col-now {
|
||||
border-left: 2px solid var(--color-primary);
|
||||
}
|
||||
.col-ms {
|
||||
border-left: 2px dashed color-mix(in oklch, var(--color-base-content) 35%, transparent);
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1.25rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.legend b {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
60
src/components/PlanEditor.vue
Normal file
60
src/components/PlanEditor.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: string; error: string | null }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
function onInput(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor">
|
||||
<textarea
|
||||
class="editor-area"
|
||||
:value="modelValue"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
aria-label="Macroplan TOML source"
|
||||
@input="onInput"
|
||||
></textarea>
|
||||
<div v-if="error" class="editor-error" role="alert">
|
||||
<span class="font-semibold">Can’t parse:</span> {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor-area {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
font-family: inherit; /* Fira Code, from the global theme */
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
.editor-error {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.74rem;
|
||||
background: var(--color-error);
|
||||
color: var(--color-error-content);
|
||||
border-top: 1px solid color-mix(in oklch, black 12%, var(--color-error));
|
||||
}
|
||||
</style>
|
||||
50
src/composables/useMacroplan.ts
Normal file
50
src/composables/useMacroplan.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { parseMacroplan } from '../model/parse'
|
||||
import { buildPlan } from '../model/plan'
|
||||
import type { Plan } from '../model/types'
|
||||
import { SAMPLE_PLAN } from '../data/sample'
|
||||
|
||||
const STORAGE_KEY = 'macroplan:source'
|
||||
|
||||
/**
|
||||
* Authoring state for a single Macroplan: the TOML source (autosaved to
|
||||
* localStorage), the parsed Plan, and the current parse error. The last
|
||||
* successfully-parsed Plan keeps rendering through transient typos (F3).
|
||||
*/
|
||||
export function useMacroplan() {
|
||||
const source = ref(localStorage.getItem(STORAGE_KEY) ?? SAMPLE_PLAN)
|
||||
const lastGood = ref<Plan | null>(null)
|
||||
|
||||
const parsed = computed<{ plan: Plan | null; error: string | null }>(() => {
|
||||
try {
|
||||
return { plan: buildPlan(parseMacroplan(source.value)), error: null }
|
||||
} catch (e) {
|
||||
return { plan: null, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
parsed,
|
||||
(p) => {
|
||||
if (p.plan) lastGood.value = p.plan
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(source, (v) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, v)
|
||||
} catch {
|
||||
/* localStorage may be full or blocked — autosave is best-effort */
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
source,
|
||||
plan: computed(() => parsed.value.plan ?? lastGood.value),
|
||||
error: computed(() => parsed.value.error),
|
||||
resetToSample: () => {
|
||||
source.value = SAMPLE_PLAN
|
||||
},
|
||||
}
|
||||
}
|
||||
50
src/data/sample.ts
Normal file
50
src/data/sample.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Default Macroplan shown on first load — exercises every state:
|
||||
// on-time delivery, late delivery with slips, in-flight (green/orange/red),
|
||||
// an overdue Feature, and a Milestone with unmet required Features.
|
||||
export const SAMPLE_PLAN = `title = "Q3 — Checkout revamp"
|
||||
|
||||
# A Feature: start week, Original Estimate (the immovable baseline), then any
|
||||
# Re-estimates (slips), an optional Delivery, and an optional Learning / Status.
|
||||
# Dates are TOML date literals; any day is snapped to that week's Monday.
|
||||
|
||||
[[feature]]
|
||||
name = "Auth"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-15
|
||||
delivered = 2026-06-15 # on time → ◉
|
||||
learning = "Spiking the OAuth flow first paid off — do discovery spikes earlier."
|
||||
|
||||
[[feature]]
|
||||
name = "Payments"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-15 # ◯ baseline
|
||||
reestimates = [2026-06-29, 2026-07-13] # two slips → △ △
|
||||
delivered = 2026-07-20 # after the baseline → ▲ late
|
||||
learning = "Vendor lead time was the real constraint — derisk vendors up front."
|
||||
|
||||
[[feature]]
|
||||
name = "Dashboard"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-08 # already past 'now' and undelivered → overdue
|
||||
status = "red"
|
||||
note = "No recovery plan yet — needs an owner."
|
||||
|
||||
[[feature]]
|
||||
name = "Search"
|
||||
start = 2026-06-08
|
||||
original = 2026-06-22
|
||||
reestimates = [2026-07-06] # slipped once, still in flight → △
|
||||
status = "orange"
|
||||
note = "Third-party search API is flaky; spike a fallback."
|
||||
|
||||
[[feature]]
|
||||
name = "Notifications"
|
||||
start = 2026-06-22
|
||||
original = 2026-07-06
|
||||
status = "green"
|
||||
|
||||
[[milestone]]
|
||||
name = "MVP go-live"
|
||||
week = 2026-07-06
|
||||
requires = ["Auth", "Payments", "Dashboard"]
|
||||
`
|
||||
85
src/model/parse.ts
Normal file
85
src/model/parse.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { parse as parseToml } from 'smol-toml'
|
||||
import { toYmd } from './week'
|
||||
import type { RawPlan, RawFeature, RawMilestone, StatusLevel } from './types'
|
||||
|
||||
/** Thrown for any malformed source — message is safe to show the author. */
|
||||
export class PlanParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'PlanParseError'
|
||||
}
|
||||
}
|
||||
|
||||
const STATUSES: StatusLevel[] = ['green', 'orange', 'red']
|
||||
|
||||
/** Parse + validate a Macroplan TOML source into the raw model. */
|
||||
export function parseMacroplan(source: string): RawPlan {
|
||||
let data: Record<string, unknown>
|
||||
try {
|
||||
data = parseToml(source) as Record<string, unknown>
|
||||
} catch (e) {
|
||||
throw new PlanParseError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
const features = asBlocks(data.feature, 'feature').map(parseFeature)
|
||||
const milestones = asBlocks(data.milestone, 'milestone').map(parseMilestone)
|
||||
|
||||
return {
|
||||
title: data.title != null ? String(data.title) : 'Untitled Macroplan',
|
||||
features,
|
||||
milestones,
|
||||
}
|
||||
}
|
||||
|
||||
function asBlocks(value: unknown, key: string): Record<string, unknown>[] {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value)) {
|
||||
throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`)
|
||||
}
|
||||
return value as Record<string, unknown>[]
|
||||
}
|
||||
|
||||
function parseFeature(f: Record<string, unknown>, i: number): RawFeature {
|
||||
const where = f.name ? `feature "${String(f.name)}"` : `feature #${i + 1}`
|
||||
if (!f.name) throw new PlanParseError(`${where}: missing \`name\``)
|
||||
if (f.start == null) throw new PlanParseError(`${where}: missing \`start\` date`)
|
||||
if (f.original == null) throw new PlanParseError(`${where}: missing \`original\` estimate date`)
|
||||
if (f.status != null && !STATUSES.includes(f.status as StatusLevel)) {
|
||||
throw new PlanParseError(`${where}: \`status\` must be one of ${STATUSES.join(', ')}`)
|
||||
}
|
||||
if (f.reestimates != null && !Array.isArray(f.reestimates)) {
|
||||
throw new PlanParseError(`${where}: \`reestimates\` must be a list of dates`)
|
||||
}
|
||||
return {
|
||||
name: String(f.name),
|
||||
start: toYmdOr(where, 'start', f.start),
|
||||
original: toYmdOr(where, 'original', f.original),
|
||||
reestimates: ((f.reestimates as unknown[]) ?? []).map((d) => toYmdOr(where, 'reestimates', d)),
|
||||
delivered: f.delivered != null ? toYmdOr(where, 'delivered', f.delivered) : undefined,
|
||||
learning: f.learning != null ? String(f.learning) : undefined,
|
||||
status: f.status as StatusLevel | undefined,
|
||||
note: f.note != null ? String(f.note) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function parseMilestone(m: Record<string, unknown>, i: number): RawMilestone {
|
||||
const where = m.name ? `milestone "${String(m.name)}"` : `milestone #${i + 1}`
|
||||
if (!m.name) throw new PlanParseError(`${where}: missing \`name\``)
|
||||
if (m.week == null) throw new PlanParseError(`${where}: missing \`week\` date`)
|
||||
if (m.requires != null && !Array.isArray(m.requires)) {
|
||||
throw new PlanParseError(`${where}: \`requires\` must be a list of feature names`)
|
||||
}
|
||||
return {
|
||||
name: String(m.name),
|
||||
week: toYmdOr(where, 'week', m.week),
|
||||
requires: ((m.requires as unknown[]) ?? []).map(String),
|
||||
}
|
||||
}
|
||||
|
||||
function toYmdOr(where: string, field: string, value: unknown): string {
|
||||
try {
|
||||
return toYmd(value)
|
||||
} catch {
|
||||
throw new PlanParseError(`${where}: \`${field}\` must be a date (e.g. 2026-06-01)`)
|
||||
}
|
||||
}
|
||||
126
src/model/plan.test.ts
Normal file
126
src/model/plan.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseMacroplan, PlanParseError } from './parse'
|
||||
import { buildPlan } from './plan'
|
||||
import { mondayOf, weekRange } from './week'
|
||||
import type { FeatureRow } from './types'
|
||||
import { SAMPLE_PLAN } from '../data/sample'
|
||||
|
||||
const TODAY = '2026-06-17' // a Wednesday → week of Mon 2026-06-15
|
||||
|
||||
function rowOf(source: string, name: string): FeatureRow {
|
||||
const plan = buildPlan(parseMacroplan(source), TODAY)
|
||||
const row = plan.rows.find((r) => r.name === name)
|
||||
if (!row) throw new Error(`no row ${name}`)
|
||||
return row
|
||||
}
|
||||
|
||||
describe('week math', () => {
|
||||
it('snaps any day to its Monday', () => {
|
||||
expect(mondayOf('2026-06-17')).toBe('2026-06-15') // Wed → Mon
|
||||
expect(mondayOf('2026-06-15')).toBe('2026-06-15') // Mon → Mon
|
||||
expect(mondayOf('2026-06-21')).toBe('2026-06-15') // Sun → that week's Mon
|
||||
expect(mondayOf('2026-06-01')).toBe('2026-06-01') // Mon
|
||||
})
|
||||
|
||||
it('builds an inclusive contiguous Monday range', () => {
|
||||
expect(weekRange('2026-06-01', '2026-06-22')).toEqual([
|
||||
'2026-06-01',
|
||||
'2026-06-08',
|
||||
'2026-06-15',
|
||||
'2026-06-22',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('F2 — on-time / late classification (ADR-0001)', () => {
|
||||
const base = (extra: string) =>
|
||||
`[[feature]]\nname = "X"\nstart = 2026-06-01\noriginal = 2026-06-15\n${extra}\n`
|
||||
|
||||
it('delivered on the Original Estimate week → ◉, no ◯, onTime true', () => {
|
||||
const r = rowOf(base('delivered = 2026-06-15'), 'X')
|
||||
expect(r.onTime).toBe(true)
|
||||
expect(r.markers.map((m) => m.kind)).toEqual(['delivered-on-time'])
|
||||
expect(r.markers[0].week).toBe('2026-06-15')
|
||||
})
|
||||
|
||||
it('delivered earlier than the Original Estimate → still on-time ◉, bar ends at delivery', () => {
|
||||
const r = rowOf(base('delivered = 2026-06-08'), 'X')
|
||||
expect(r.onTime).toBe(true)
|
||||
expect(r.markers.map((m) => m.kind)).toEqual(['delivered-on-time'])
|
||||
expect(r.markers[0].week).toBe('2026-06-08')
|
||||
expect(r.barEndWeek).toBe('2026-06-08') // no ◯ dangling in the future
|
||||
})
|
||||
|
||||
it('delivered after the Original Estimate → ▲ late, ◯ baseline preserved', () => {
|
||||
const r = rowOf(base('delivered = 2026-06-29'), 'X')
|
||||
expect(r.onTime).toBe(false)
|
||||
const kinds = r.markers.map((m) => `${m.kind}@${m.week}`).sort()
|
||||
expect(kinds).toEqual(['delivered-late@2026-06-29', 'original@2026-06-15'])
|
||||
expect(r.barEndWeek).toBe('2026-06-29')
|
||||
})
|
||||
|
||||
it('late delivery with multiple slips keeps ◯ + every △ + ▲ (judged vs original, not re-estimate)', () => {
|
||||
const r = rowOf(
|
||||
base('reestimates = [2026-06-29, 2026-07-13]\ndelivered = 2026-07-20'),
|
||||
'X',
|
||||
)
|
||||
expect(r.onTime).toBe(false) // 07-20 > original 06-15, regardless of the 07-13 re-estimate
|
||||
expect(r.slipCount).toBe(2)
|
||||
const byKind = r.markers.reduce<Record<string, string[]>>((acc, m) => {
|
||||
;(acc[m.kind] ??= []).push(m.week)
|
||||
return acc
|
||||
}, {})
|
||||
expect(byKind.original).toEqual(['2026-06-15'])
|
||||
expect(byKind.reestimate?.sort()).toEqual(['2026-06-29', '2026-07-13'])
|
||||
expect(byKind['delivered-late']).toEqual(['2026-07-20'])
|
||||
})
|
||||
|
||||
it('in-flight (undelivered) → ◯ only, onTime null, bar ends at the furthest estimate', () => {
|
||||
const r = rowOf(base('reestimates = [2026-06-29]\nstatus = "red"'), 'X')
|
||||
expect(r.onTime).toBeNull()
|
||||
expect(r.delivered).toBe(false)
|
||||
expect(r.status).toBe('red')
|
||||
expect(r.barEndWeek).toBe('2026-06-29') // furthest open estimate
|
||||
expect(r.markers.some((m) => m.kind === 'original')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plan derivation', () => {
|
||||
it('derives a contiguous week range and places the now line', () => {
|
||||
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), TODAY)
|
||||
expect(plan.weeks[0]).toBe('2026-06-01') // earliest start
|
||||
expect(plan.weeks.at(-1)).toBe('2026-07-20') // latest marker (Payments delivery)
|
||||
// contiguous, weekly
|
||||
expect(plan.weeks).toContain('2026-06-29')
|
||||
expect(plan.nowWeek).toBe('2026-06-15')
|
||||
expect(plan.nowInRange).toBe(true)
|
||||
})
|
||||
|
||||
it('flags a Milestone’s unmet required Features (undelivered or delivered after the milestone)', () => {
|
||||
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), TODAY)
|
||||
const mvp = plan.milestones.find((m) => m.name === 'MVP go-live')!
|
||||
expect(mvp.week).toBe('2026-07-06')
|
||||
// Auth delivered 06-15 (met); Payments delivered 07-20 > 07-06 (unmet); Dashboard undelivered (unmet)
|
||||
expect(mvp.unmet.sort()).toEqual(['Dashboard', 'Payments'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parse validation', () => {
|
||||
it('rejects a feature missing its Original Estimate', () => {
|
||||
expect(() => parseMacroplan('[[feature]]\nname = "A"\nstart = 2026-06-01\n')).toThrow(
|
||||
PlanParseError,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an invalid status', () => {
|
||||
expect(() =>
|
||||
parseMacroplan('[[feature]]\nname="A"\nstart=2026-06-01\noriginal=2026-06-08\nstatus="blue"\n'),
|
||||
).toThrow(/status/)
|
||||
})
|
||||
|
||||
it('parses the bundled sample without error', () => {
|
||||
const raw = parseMacroplan(SAMPLE_PLAN)
|
||||
expect(raw.features).toHaveLength(5)
|
||||
expect(raw.milestones).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
79
src/model/plan.ts
Normal file
79
src/model/plan.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { mondayOf, weekRange, type WeekId } from './week'
|
||||
import type { RawPlan, RawFeature, Plan, FeatureRow, Marker, MilestoneLine } from './types'
|
||||
|
||||
/**
|
||||
* Derive the render-ready Plan from the raw model (component C2).
|
||||
*
|
||||
* On-time vs. late is judged ONLY against the Original Estimate, never a
|
||||
* Re-estimate (ADR-0001). The full slip history (`◯` + every `△`) is preserved.
|
||||
*/
|
||||
export function buildPlan(raw: RawPlan, today: Date | string = new Date()): Plan {
|
||||
const rows = raw.features.map(buildRow)
|
||||
const nowWeek = mondayOf(today)
|
||||
|
||||
const milestones = raw.milestones.map((m): MilestoneLine => {
|
||||
const week = mondayOf(m.week)
|
||||
const unmet = m.requires.filter((name) => {
|
||||
const row = rows.find((r) => r.name === name)
|
||||
if (!row) return true // references an unknown Feature → can't be met
|
||||
const delivery = row.markers.find(
|
||||
(x) => x.kind === 'delivered-on-time' || x.kind === 'delivered-late',
|
||||
)
|
||||
return !delivery || delivery.week > week // undelivered, or delivered after the milestone
|
||||
})
|
||||
return { name: m.name, week, requires: m.requires, unmet }
|
||||
})
|
||||
|
||||
// Range: earliest start to last marker/milestone (CONTEXT.md). Empty weeks drawn.
|
||||
const allWeeks: WeekId[] = []
|
||||
for (const r of rows) {
|
||||
allWeeks.push(r.startWeek, r.barEndWeek)
|
||||
for (const mk of r.markers) allWeeks.push(mk.week)
|
||||
}
|
||||
for (const m of milestones) allWeeks.push(m.week)
|
||||
|
||||
let weeks: WeekId[] = []
|
||||
if (allWeeks.length) {
|
||||
const start = allWeeks.reduce((a, b) => (a < b ? a : b))
|
||||
const end = allWeeks.reduce((a, b) => (a > b ? a : b))
|
||||
weeks = weekRange(start, end)
|
||||
}
|
||||
const nowInRange = weeks.length > 0 && nowWeek >= weeks[0] && nowWeek <= weeks[weeks.length - 1]
|
||||
|
||||
return { title: raw.title, weeks, rows, milestones, nowWeek, nowInRange }
|
||||
}
|
||||
|
||||
function buildRow(f: RawFeature): FeatureRow {
|
||||
const startWeek = mondayOf(f.start)
|
||||
const originalWeek = mondayOf(f.original)
|
||||
const deliveredWeek = f.delivered ? mondayOf(f.delivered) : undefined
|
||||
const delivered = deliveredWeek != null
|
||||
// ADR-0001: compare the Delivery week to the Original Estimate week only.
|
||||
const onTime = delivered ? deliveredWeek! <= originalWeek : null
|
||||
|
||||
const markers: Marker[] = []
|
||||
for (const re of f.reestimates) markers.push({ week: mondayOf(re), kind: 'reestimate' })
|
||||
if (delivered) {
|
||||
markers.push({ week: deliveredWeek!, kind: onTime ? 'delivered-on-time' : 'delivered-late' })
|
||||
}
|
||||
// The Original Estimate `◯` stands unless an on-time/early delivery already
|
||||
// occupies (or precedes) it — then the delivery marker speaks for it.
|
||||
if (!(delivered && onTime)) {
|
||||
markers.push({ week: originalWeek, kind: 'original' })
|
||||
}
|
||||
|
||||
const barEndWeek = [startWeek, ...markers.map((m) => m.week)].reduce((a, b) => (a > b ? a : b))
|
||||
|
||||
return {
|
||||
name: f.name,
|
||||
startWeek,
|
||||
barEndWeek,
|
||||
markers,
|
||||
delivered,
|
||||
onTime,
|
||||
status: f.status,
|
||||
note: f.note,
|
||||
learning: f.learning,
|
||||
slipCount: f.reestimates.length,
|
||||
}
|
||||
}
|
||||
70
src/model/types.ts
Normal file
70
src/model/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { WeekId } from './week'
|
||||
|
||||
export type StatusLevel = 'green' | 'orange' | 'red'
|
||||
|
||||
// ── Raw model: as authored, after TOML parse + validation, before derivation ──
|
||||
|
||||
export interface RawFeature {
|
||||
name: string
|
||||
start: string // yyyy-mm-dd
|
||||
original: string // yyyy-mm-dd — the Original Estimate
|
||||
reestimates: string[] // yyyy-mm-dd[]
|
||||
delivered?: string // yyyy-mm-dd
|
||||
learning?: string
|
||||
status?: StatusLevel
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface RawMilestone {
|
||||
name: string
|
||||
week: string // yyyy-mm-dd
|
||||
requires: string[] // Feature names
|
||||
}
|
||||
|
||||
export interface RawPlan {
|
||||
title: string
|
||||
features: RawFeature[]
|
||||
milestones: RawMilestone[]
|
||||
}
|
||||
|
||||
// ── Derived model: render-ready (C2 output) ──
|
||||
|
||||
export type MarkerKind =
|
||||
| 'original' // ◯ Original Estimate, not yet delivered
|
||||
| 'reestimate' // △ a slip to a later week
|
||||
| 'delivered-on-time' // ◉ delivered on/before the Original Estimate
|
||||
| 'delivered-late' // ▲ delivered after the Original Estimate
|
||||
|
||||
export interface Marker {
|
||||
week: WeekId
|
||||
kind: MarkerKind
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
name: string
|
||||
startWeek: WeekId
|
||||
barEndWeek: WeekId
|
||||
markers: Marker[]
|
||||
delivered: boolean
|
||||
onTime: boolean | null // null while in-flight
|
||||
status?: StatusLevel
|
||||
note?: string
|
||||
learning?: string
|
||||
slipCount: number // number of Re-estimates
|
||||
}
|
||||
|
||||
export interface MilestoneLine {
|
||||
name: string
|
||||
week: WeekId
|
||||
requires: string[]
|
||||
unmet: string[] // required Features not delivered on/before this week
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
title: string
|
||||
weeks: WeekId[]
|
||||
rows: FeatureRow[]
|
||||
milestones: MilestoneLine[]
|
||||
nowWeek: WeekId
|
||||
nowInRange: boolean
|
||||
}
|
||||
59
src/model/week.ts
Normal file
59
src/model/week.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Week math. A "Week" is identified by the ISO date (yyyy-mm-dd) of its Monday.
|
||||
// ISO yyyy-mm-dd strings sort lexicographically == chronologically, so plain
|
||||
// string comparison (`<`, `<=`) is valid week ordering — we lean on that.
|
||||
|
||||
export type WeekId = string // 'yyyy-mm-dd', always a Monday
|
||||
|
||||
/**
|
||||
* Extract a yyyy-mm-dd string from a TOML date (smol-toml's `TomlDate.toISOString()`
|
||||
* returns the authored local date, e.g. "2026-06-01") or a plain string.
|
||||
*/
|
||||
export function toYmd(value: unknown): string {
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10)
|
||||
if (typeof value === 'string') return value.slice(0, 10)
|
||||
throw new Error(`expected a date, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
// Anchor at UTC noon so day-of-week / day arithmetic never crosses a DST or
|
||||
// timezone boundary.
|
||||
function utcNoon(ymd: string): Date {
|
||||
const [y, m, d] = ymd.split('-').map(Number)
|
||||
return new Date(Date.UTC(y, m - 1, d, 12))
|
||||
}
|
||||
|
||||
function fmt(dt: Date): string {
|
||||
return dt.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** The Monday (yyyy-mm-dd) of the ISO week containing the given date. */
|
||||
export function mondayOf(value: unknown): WeekId {
|
||||
const dt = utcNoon(toYmd(value))
|
||||
const dow = dt.getUTCDay() // 0=Sun .. 6=Sat
|
||||
const shift = dow === 0 ? -6 : 1 - dow
|
||||
dt.setUTCDate(dt.getUTCDate() + shift)
|
||||
return fmt(dt)
|
||||
}
|
||||
|
||||
/** Add n weeks to a Monday WeekId (n may be negative). */
|
||||
export function addWeeks(week: WeekId, n: number): WeekId {
|
||||
const dt = utcNoon(week)
|
||||
dt.setUTCDate(dt.getUTCDate() + n * 7)
|
||||
return fmt(dt)
|
||||
}
|
||||
|
||||
/** Inclusive list of Monday WeekIds from start..end (both must already be Mondays). */
|
||||
export function weekRange(start: WeekId, end: WeekId): WeekId[] {
|
||||
if (start > end) return [start]
|
||||
const weeks: WeekId[] = []
|
||||
for (let w = start; w <= end; w = addWeeks(w, 1)) weeks.push(w)
|
||||
return weeks
|
||||
}
|
||||
|
||||
/** Short column label for a week, e.g. "Jun 15". */
|
||||
export function weekLabel(week: WeekId): string {
|
||||
return utcNoon(week).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user