Compare commits

...

6 Commits

Author SHA1 Message Date
Julien Calixte
7fcae34c60 style: pin the light DaisyUI theme 2026-06-17 00:38:16 +02:00
Julien Calixte
1535c829ca test(ui): cover grid rendering of the sample plan 2026-06-17 00:38:16 +02:00
Julien Calixte
c62bdde727 feat(ui): render the Macroplan grid, editor and app shell 2026-06-17 00:38:16 +02:00
Julien Calixte
a7a4df52a9 test(model): cover week math, F2 classification and milestones 2026-06-17 00:38:16 +02:00
Julien Calixte
c1d72184eb feat(model): parse TOML plans and derive the Plan model
On-time vs. late is judged against the Original Estimate only, never a
re-estimate (ADR-0001); weeks are keyed by their Monday.
2026-06-17 00:38:16 +02:00
Julien Calixte
2c0e9c360f chore(deps): add smol-toml, vitest and component-test tooling 2026-06-17 00:38:16 +02:00
14 changed files with 1706 additions and 16 deletions

View File

@@ -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" />

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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>

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

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

View 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">Cant 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>

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