Add dashboard, API routes, Olga route, and supporting files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Athena 2026-05-19 17:02:50 +02:00
parent b9f2b8175a
commit b95e4874f1
11 changed files with 2058 additions and 2 deletions

View file

@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"next": "16.2.4", "next": "16.2.4",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View file

@ -17,6 +17,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1)
devDependencies: devDependencies:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
@ -429,9 +432,26 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@reduxjs/toolkit@2.12.0':
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@ -530,6 +550,33 @@ packages:
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -550,6 +597,9 @@ packages:
'@types/react@19.2.14': '@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@typescript-eslint/eslint-plugin@8.59.1': '@typescript-eslint/eslint-plugin@8.59.1':
resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -841,6 +891,10 @@ packages:
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@ -861,6 +915,50 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8: damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@ -893,6 +991,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@ -958,6 +1059,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-toolkit@1.46.1:
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
escalade@3.2.0: escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1086,6 +1190,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -1235,6 +1342,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.8:
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
import-fresh@3.3.1: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1247,6 +1360,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-array-buffer@3.0.5: is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1677,10 +1794,38 @@ packages:
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react@19.2.4: react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1689,6 +1834,9 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -1842,6 +1990,9 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'} engines: {node: '>=6'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.16: tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -1913,6 +2064,14 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2303,8 +2462,24 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.8
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.4
react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -2383,6 +2558,30 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@ -2401,6 +2600,8 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@ -2706,6 +2907,8 @@ snapshots:
client-only@0.0.1: {} client-only@0.0.1: {}
clsx@2.1.1: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@ -2724,6 +2927,44 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
damerau-levenshtein@1.0.8: {} damerau-levenshtein@1.0.8: {}
data-view-buffer@1.0.2: data-view-buffer@1.0.2:
@ -2752,6 +2993,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js-light@2.5.1: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
define-data-property@1.1.4: define-data-property@1.1.4:
@ -2888,6 +3131,8 @@ snapshots:
is-date-object: 1.1.0 is-date-object: 1.1.0
is-symbol: 1.1.1 is-symbol: 1.1.1
es-toolkit@1.46.1: {}
escalade@3.2.0: {} escalade@3.2.0: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
@ -3097,6 +3342,8 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
eventemitter3@5.0.4: {}
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-glob@3.3.1: fast-glob@3.3.1:
@ -3241,6 +3488,10 @@ snapshots:
ignore@7.0.5: {} ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.8: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@ -3254,6 +3505,8 @@ snapshots:
hasown: 2.0.3 hasown: 2.0.3
side-channel: 1.1.0 side-channel: 1.1.0
internmap@2.0.3: {}
is-array-buffer@3.0.5: is-array-buffer@3.0.5:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@ -3664,8 +3917,43 @@ snapshots:
react-is@16.13.1: {} react-is@16.13.1: {}
react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
redux: 5.0.1
react@19.2.4: {} react@19.2.4: {}
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.46.1
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-is: 16.13.1
react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.4)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@ -3686,6 +3974,8 @@ snapshots:
gopd: 1.2.0 gopd: 1.2.0
set-function-name: 2.0.2 set-function-name: 2.0.2
reselect@5.1.1: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
@ -3898,6 +4188,8 @@ snapshots:
tapable@2.3.3: {} tapable@2.3.3: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.16: tinyglobby@0.2.16:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
@ -4013,6 +4305,27 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
react: 19.2.4
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
dependencies: dependencies:
is-bigint: 1.1.0 is-bigint: 1.1.0

View file

@ -1,3 +1,6 @@
ignoredBuiltDependencies: allowBuilds:
sharp: set this to true or false
unrs-resolver: set this to true or false
onlyBuiltDependencies:
- sharp - sharp
- unrs-resolver - unrs-resolver

BIN
public/olga.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

5
public/pm-kritisch.json Normal file
View file

@ -0,0 +1,5 @@
{
"stand": "2026-05-18T22:00:00Z",
"kritisch": [],
"allesOk": true
}

View file

@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
import { fetchAllTodos } from '@/lib/notion-pm'
export const revalidate = 300
export async function GET() {
try {
const todos = await fetchAllTodos()
return NextResponse.json({
todos,
timestamp: new Date().toISOString(),
count: todos.length,
})
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 })
}
}

View file

@ -0,0 +1,877 @@
'use client'
import { useState } from 'react'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Cell,
} from 'recharts'
import { Todo } from '@/lib/notion-pm'
import { KritischData } from '../page'
// ── MC CI ─────────────────────────────────────────────────────────────────────
const MC_BLUE = '#3B82F6'
const MC_PURPLE = '#8B5CF6'
const KAT_COLOR: Record<string, string> = {
'Kundenarbeit': '#3B82F6',
'Produkt & Launch': '#8B5CF6',
'Marketing & Content': '#EC4899',
'Vertrieb & Akquise': '#F97316',
'Technik & Tools': '#6B7280',
'Finanzen & Recht': '#EAB308',
'Organisation & Admin': '#A16207',
'Privat': '#22C55E',
'Diana': '#EF4444',
'Me Time': '#94A3B8',
}
function kc(k: string | null) { return k ? (KAT_COLOR[k] ?? MC_BLUE) : MC_BLUE }
// ── Capacity constants ────────────────────────────────────────────────────────
// Business: MoFr 7h/Tag × 5 = 35h + WE Standard 5h = 40h/Woche
// Privat: MoFr 1.5h/Tag × 5 = 7.5h + WE 7h (34h/Tag) = 14.5h/Woche
const BUSINESS_CAP = 40
const PRIVAT_CAP = 14.5
const REAL_CAP = BUSINESS_CAP + PRIVAT_CAP // 54.5h gesamt (für Machbarkeit)
const BUSINESS_KAT = new Set([
'Kundenarbeit', 'Produkt & Launch', 'Marketing & Content',
'Vertrieb & Akquise', 'Technik & Tools', 'Finanzen & Recht', 'Organisation & Admin',
])
const PRIVAT_KAT = new Set(['Privat', 'Diana', 'Me Time'])
// ── Helpers ───────────────────────────────────────────────────────────────────
const isErledigt = (t: Todo) => (t.status ?? '').toLowerCase().includes('erledigt')
const daysFromNow = (d: string) => {
const now = new Date(); now.setHours(0,0,0,0)
return Math.floor((new Date(d).getTime() - now.getTime()) / 86_400_000)
}
const round1 = (n: number) => Math.round(n * 10) / 10
// Wochenplan-Filter: Geplanter Tag muss in den nächsten 7 Tagen liegen.
// Kein Geplanter Tag aber Im Wochenplan = true → manuell gesetzt, zählt trotzdem.
const isThisWeek = (t: Todo) => {
if (!t.geplanterTag) return t.imWochenplan
const d = daysFromNow(t.geplanterTag)
return d >= 0 && d <= 6
}
// ── Analytics ─────────────────────────────────────────────────────────────────
function kapazitaet(todos: Todo[]) {
const week = todos.filter(t => isThisWeek(t) && !isErledigt(t))
const businessH = round1(week.filter(t => BUSINESS_KAT.has(t.kategorie ?? '')).reduce((s,t) => s + (t.zeitH ?? 0), 0))
const privatH = round1(week.filter(t => PRIVAT_KAT.has(t.kategorie ?? '')).reduce((s,t) => s + (t.zeitH ?? 0), 0))
const businessPct = Math.round((businessH / BUSINESS_CAP) * 100)
const privatPct = Math.round((privatH / PRIVAT_CAP) * 100)
const bColor = businessPct <= 80 ? '#22C55E' : businessPct <= 100 ? '#EAB308' : '#EF4444'
const pColor = privatPct <= 80 ? '#22C55E' : privatPct <= 100 ? '#EAB308' : '#EF4444'
// legacy compat for other functions that use .geplant/.pct/.color/.label
const geplant = round1(businessH + privatH)
const pct = Math.round((geplant / REAL_CAP) * 100)
const color = pct <= 80 ? '#22C55E' : pct <= 100 ? '#EAB308' : '#EF4444'
const label = pct <= 80 ? 'Im Rahmen' : pct <= 100 ? 'Grenzwertig' : 'Überlastet'
return { businessH, privatH, businessPct, privatPct, bColor, pColor, geplant, pct, color, label }
}
function machbarkeit(todos: Todo[]) {
const week = todos
.filter(t => isThisWeek(t) && !isErledigt(t))
.sort((a,b) => {
// höchste Prio zuerst
const prio = (x: Todo) => x.prioritaet === 'Hoch' ? 0 : x.prioritaet === 'Mittel' ? 1 : 2
return prio(a) - prio(b)
})
let budget = REAL_CAP
const machbar: Todo[] = []
const overflow: Todo[] = []
for (const t of week) {
const h = t.zeitH ?? 0.5
if (budget >= h) { machbar.push(t); budget -= h }
else overflow.push(t)
}
const score = week.length === 0 ? 100
: Math.round((machbar.length / week.length) * 100)
return { machbar, overflow, score, restH: round1(budget) }
}
function energiebloecke(todos: Todo[]) {
const open = todos.filter(t => !isErledigt(t) && isThisWeek(t))
const fokus = open.filter(t => (t.zeitH ?? 0) >= 2 || t.prioritaet === 'Hoch')
const leicht = open.filter(t => !fokus.includes(t) && (t.zeitH ?? 1) < 1)
const mittel = open.filter(t => !fokus.includes(t) && !leicht.includes(t))
return { fokus, mittel, leicht }
}
function carryoverRate(todos: Todo[]) {
// Todos mit vergangener Deadline die NICHT erledigt sind
const withDeadline = todos.filter(t => t.deadline)
const overdue = withDeadline.filter(t => !isErledigt(t) && daysFromNow(t.deadline!) < 0)
const rate = withDeadline.length === 0 ? 0
: Math.round((overdue.length / withDeadline.length) * 100)
return { rate, overdueCount: overdue.length, total: withDeadline.length }
}
function deadlineRisiko(todos: Todo[]) {
const projekte: Record<string, {riskCount: number; total: number}> = {}
for (const t of todos.filter(t => !isErledigt(t))) {
if (!projekte[t.projekt]) projekte[t.projekt] = { riskCount: 0, total: 0 }
projekte[t.projekt].total++
if (t.deadline) {
const d = daysFromNow(t.deadline)
if (d < 0 || d <= 7) projekte[t.projekt].riskCount++
}
}
return Object.entries(projekte)
.map(([p, v]) => ({
projekt: p,
risikoPct: v.total === 0 ? 0 : Math.round((v.riskCount / v.total) * 100),
riskCount: v.riskCount,
}))
.filter(r => r.riskCount > 0)
.sort((a,b) => b.risikoPct - a.risikoPct)
.slice(0, 5)
}
interface AthenaEmpfehlung {
icon: string
text: string
color: string
items?: string[] // aufklappbare Liste — eine Aufgabe pro Zeile
}
// Sortiert nach nächstem anstehendem Datum (geplanterTag → deadline → hinten)
function sortByNextDate(a: Todo, b: Todo): number {
const dateOf = (t: Todo) => t.geplanterTag ?? t.deadline ?? null
const da = dateOf(a), db = dateOf(b)
if (da && db) return new Date(da).getTime() - new Date(db).getTime()
if (da) return -1
if (db) return 1
return 0
}
function athenaEmpfehlungen(todos: Todo[]): AthenaEmpfehlung[] {
const tips: AthenaEmpfehlung[] = []
const kap = kapazitaet(todos)
const mb = machbarkeit(todos)
const cr = carryoverRate(todos)
// 1. Überlastung
if (kap.pct > 100) {
const overflow = mb.overflow
if (overflow.length > 0) {
tips.push({
icon: '↩',
text: `${overflow.length} Todo${overflow.length > 1 ? 's' : ''} auf nächste Woche verschieben`,
color: '#EF4444',
items: overflow.map(t => `${t.name.slice(0,50)} (${t.zeitH ?? '?'}h)`),
})
}
}
// 2. Hohe Carry-over Rate
if (cr.rate > 30) {
tips.push({
icon: '⚡',
text: `Carry-over Rate ${cr.rate}% — ${cr.overdueCount} überfällige Todos priorisieren`,
color: '#EAB308',
})
}
// 3. Kundenarbeit unterrepräsentiert
const weekH = (kat: string) => todos
.filter(t => isThisWeek(t) && !isErledigt(t) && t.kategorie === kat)
.reduce((s,t) => s + (t.zeitH ?? 0), 0)
const kundenH = weekH('Kundenarbeit')
const totalWeekH = todos.filter(t => isThisWeek(t) && !isErledigt(t))
.reduce((s,t) => s + (t.zeitH ?? 0), 0)
if (totalWeekH > 0 && kundenH / totalWeekH < 0.25) {
tips.push({
icon: '👥',
text: `Kundenarbeit nur ${Math.round((kundenH/totalWeekH)*100)}% — prüfe ob Kunden-Todos fehlen`,
color: MC_BLUE,
})
}
// 4. Fokus-Todos ohne feste Zeit
const fokusOhneZeit = todos.filter(t =>
isThisWeek(t) && !isErledigt(t) && t.prioritaet === 'Hoch' && !t.zeitH
)
if (fokusOhneZeit.length > 0) {
tips.push({
icon: '🎯',
text: `${fokusOhneZeit.length} Hoch-Prio Todos ohne Zeitschätzung`,
color: MC_PURPLE,
items: fokusOhneZeit.map(t => t.name.slice(0, 52)),
})
}
// 5. Me Time fehlt
const meTodos = todos.filter(t => !isErledigt(t) && t.kategorie === 'Me Time')
if (meTodos.length === 0) {
tips.push({
icon: '🧘',
text: `Me Time Zeitslot noch offen — bitte definieren`,
color: '#94A3B8',
})
}
// 6. Business-Puffer: nächstanstehende Todos vorschlagen
const businessRest = round1(BUSINESS_CAP - kap.businessH)
if (businessRest >= 1) {
const kandidaten = todos
.filter(t => !isErledigt(t) && !isThisWeek(t) && BUSINESS_KAT.has(t.kategorie ?? '') && (t.zeitH ?? 0) <= businessRest)
.sort(sortByNextDate)
.slice(0, 8)
if (kandidaten.length > 0) {
tips.push({
icon: '',
text: `Business: ${businessRest}h Puffer frei — nächstanstehende Vorschläge`,
color: '#22C55E',
items: kandidaten.map(t => {
const datum = t.geplanterTag ?? t.deadline
const datumStr = datum ? ` · ${datum.slice(5)}` : ''
return `${t.name.slice(0,50)} (${t.zeitH ?? '?'}h${datumStr})`
}),
})
}
}
// 7. Privat-Puffer: nächstanstehende Todos vorschlagen
const privatRest = round1(PRIVAT_CAP - kap.privatH)
if (privatRest >= 0.5) {
const kandidaten = todos
.filter(t => !isErledigt(t) && !isThisWeek(t) && PRIVAT_KAT.has(t.kategorie ?? '') && (t.zeitH ?? 0) <= privatRest)
.sort(sortByNextDate)
.slice(0, 6)
if (kandidaten.length > 0) {
tips.push({
icon: '🏠',
text: `Privat: ${privatRest}h Puffer frei — nächstanstehende Vorschläge`,
color: '#8B5CF6',
items: kandidaten.map(t => {
const datum = t.geplanterTag ?? t.deadline
const datumStr = datum ? ` · ${datum.slice(5)}` : ''
return `${t.name.slice(0,50)} (${t.zeitH ?? '?'}h${datumStr})`
}),
})
}
}
// 8. Alles gut
if (tips.length === 0) {
tips.push({ icon: '✓', text: `Alles im Rahmen — Kapazität ${kap.pct}%, Carry-over ${cr.rate}%. Weiter so!`, color: '#22C55E' })
}
return tips
}
function kategorienData(todos: Todo[]) {
const map: Record<string, number> = {}
for (const t of todos) {
if (!t.kategorie || isErledigt(t)) continue
map[t.kategorie] = (map[t.kategorie] ?? 0) + (t.zeitH ?? 0)
}
return Object.entries(map)
.map(([name, stunden]) => ({ name, stunden: round1(stunden) }))
.sort((a,b) => b.stunden - a.stunden)
}
function top5(todos: Todo[]) {
return todos
.filter(t => !isErledigt(t) && t.deadline)
.sort((a,b) => new Date(a.deadline!).getTime() - new Date(b.deadline!).getTime())
.slice(0, 5)
.map(t => ({ ...t, daysLeft: daysFromNow(t.deadline!) }))
}
function fruehwarnung(todos: Todo[]) {
const active = todos.filter(t => !isErledigt(t) && t.deadline)
return {
overdue: active.filter(t => daysFromNow(t.deadline!) < 0),
upcoming: active.filter(t => { const d = daysFromNow(t.deadline!); return d >= 0 && d <= 3 }),
}
}
function zeitdifferenz(todos: Todo[]) {
const map: Record<string, {geplant: number; tatsaechlich: number}> = {}
for (const t of todos) {
if (!map[t.projekt]) map[t.projekt] = { geplant: 0, tatsaechlich: 0 }
map[t.projekt].geplant += t.zeitH ?? 0
map[t.projekt].tatsaechlich += t.tatsaechlicheZeitH ?? 0
}
return Object.entries(map)
.map(([p, v]) => ({
projekt: p,
geplant: round1(v.geplant),
tatsaechlich: round1(v.tatsaechlich),
diff: round1(v.geplant - v.tatsaechlich),
}))
.filter(r => r.geplant > 0 || r.tatsaechlich > 0)
}
function meilensteinKetten(todos: Todo[]) {
const map: Record<string, {projekt: string; todos: Todo[]; overdueCount: number}> = {}
for (const t of todos.filter(t => !isErledigt(t) && t.meilenstein)) {
const key = `${t.projekt} ${t.meilenstein}`
if (!map[key]) map[key] = { projekt: t.projekt, todos: [], overdueCount: 0 }
map[key].todos.push(t)
if (t.deadline && daysFromNow(t.deadline) < 0) map[key].overdueCount++
}
return Object.entries(map)
.map(([label, v]) => ({ label, ...v }))
.sort((a,b) => b.overdueCount - a.overdueCount)
.slice(0, 6)
}
// ── UI Primitives ─────────────────────────────────────────────────────────────
function Card({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={className} style={{ background:'#1e293b', border:'1px solid #334155', borderRadius:'0.75rem', padding:'1.25rem' }}>
{children}
</div>
)
}
function Label({ children }: { children: React.ReactNode }) {
return (
<p style={{ fontSize:'0.65rem', fontWeight:700, letterSpacing:'0.1em', textTransform:'uppercase', color:'#475569', marginBottom:'0.75rem', margin:'0 0 0.75rem' }}>
{children}
</p>
)
}
function Badge({ color, children }: { color: string; children: React.ReactNode }) {
return (
<span style={{ background: color+'22', color, fontSize:'0.65rem', fontWeight:700, padding:'0.1rem 0.4rem', borderRadius:'0.25rem', letterSpacing:'0.05em' }}>
{children}
</span>
)
}
// ── Section: Kapazität + Puffer ───────────────────────────────────────────────
function KapazitaetsAmpel({ todos }: { todos: Todo[] }) {
const { businessH, privatH, businessPct, privatPct, bColor, pColor } = kapazitaet(todos)
function Bar({ label, geplant, cap, pct, color, hint }: {
label: string; geplant: number; cap: number; pct: number; color: string; hint: string
}) {
const barW = Math.min(pct, 100)
const statusLabel = pct <= 80 ? 'Im Rahmen' : pct <= 100 ? 'Grenzwertig' : 'Überlastet'
return (
<div style={{ marginBottom:'0.875rem' }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', marginBottom:'0.2rem' }}>
<span style={{ fontSize:'0.68rem', fontWeight:700, color:'#475569', textTransform:'uppercase', letterSpacing:'0.08em' }}>{label}</span>
<span style={{ fontSize:'0.65rem', color:'#475569' }}>{hint}</span>
</div>
<div style={{ display:'flex', alignItems:'baseline', gap:'0.4rem', marginBottom:'0.3rem' }}>
<span style={{ fontSize:'1.75rem', fontWeight:800, color }}>{geplant}h</span>
<span style={{ color:'#475569', fontSize:'0.8rem' }}>/ {cap}h</span>
</div>
<div style={{ background:'#0f172a', borderRadius:'9999px', height:'7px', marginBottom:'0.25rem', overflow:'hidden' }}>
<div style={{ width:`${barW}%`, height:'100%', background:color, borderRadius:'9999px', transition:'width 0.5s ease' }} />
</div>
<div style={{ display:'flex', justifyContent:'space-between' }}>
<span style={{ fontSize:'0.7rem', color, fontWeight:600 }}>{statusLabel} ({pct}%)</span>
<Badge color={color}>{pct <= 80 ? '🟢' : pct <= 100 ? '🟡' : '🔴'}</Badge>
</div>
</div>
)
}
return (
<Card>
<Label>Kapazität diese Woche</Label>
<Bar label="Business" geplant={businessH} cap={BUSINESS_CAP} pct={businessPct} color={bColor} hint="MoFr 35h + WE 5h" />
<Bar label="Privat" geplant={privatH} cap={PRIVAT_CAP} pct={privatPct} color={pColor} hint="MoFr 7.5h + WE 7h" />
<div style={{ background:'#0f172a', borderRadius:'0.5rem', padding:'0.4rem 0.75rem', fontSize:'0.68rem', color:'#475569', marginTop:'0.25rem' }}>
<div style={{ display:'grid', gridTemplateColumns:'1fr auto', gap:'0.2rem' }}>
<span>Business (35h MoFr + 5h WE)</span><span style={{ color:'#3B82F6' }}>{BUSINESS_CAP}h</span>
<span>Privat (7.5h MoFr + 7h WE)</span><span style={{ color:'#8B5CF6' }}>{PRIVAT_CAP}h</span>
<span>3 Wochenendtage/Monat frei</span><span style={{ color:'#64748b' }}>blockiert</span>
</div>
</div>
</Card>
)
}
// ── Section: Machbarkeitsanalyse ──────────────────────────────────────────────
function Machbarkeit({ todos }: { todos: Todo[] }) {
const { machbar, overflow, score } = machbarkeit(todos)
const scoreColor = score >= 80 ? '#22C55E' : score >= 50 ? '#EAB308' : '#EF4444'
const scoreLabel = score >= 80 ? 'Machbar' : score >= 50 ? 'Knapp' : 'Nicht machbar'
return (
<Card>
<Label>Machbarkeitsanalyse</Label>
<div style={{ display:'flex', alignItems:'center', gap:'1rem', marginBottom:'0.75rem' }}>
<div style={{ textAlign:'center' }}>
<div style={{ fontSize:'2rem', fontWeight:800, color:scoreColor }}>{score}%</div>
<div style={{ fontSize:'0.7rem', color:scoreColor, fontWeight:600 }}>{scoreLabel}</div>
</div>
<div style={{ flex:1 }}>
<div style={{ fontSize:'0.75rem', color:'#94a3b8', marginBottom:'0.25rem' }}>
<span style={{ color:'#22C55E', fontWeight:600 }}>{machbar.length} Todos</span> passen rein
{overflow.length > 0 && <span> · <span style={{ color:'#EF4444', fontWeight:600 }}>{overflow.length} fallen raus</span></span>}
</div>
<div style={{ background:'#0f172a', borderRadius:'9999px', height:'6px', overflow:'hidden' }}>
<div style={{ width:`${score}%`, height:'100%', background:scoreColor }} />
</div>
</div>
</div>
{overflow.length > 0 && (
<div>
<p style={{ fontSize:'0.65rem', color:'#EF4444', fontWeight:700, marginBottom:'0.375rem', textTransform:'uppercase', letterSpacing:'0.05em' }}>
Passt diese Woche nicht rein:
</p>
{overflow.slice(0, 4).map(t => (
<div key={t.id} style={{ display:'flex', justifyContent:'space-between', background:'#0f172a', borderRadius:'0.375rem', padding:'0.3rem 0.5rem', marginBottom:'0.25rem', fontSize:'0.75rem' }}>
<span style={{ color:'#f1f5f9' }}>{t.name.slice(0,42)}</span>
<span style={{ color:'#EF4444', fontWeight:600, marginLeft:'0.5rem', whiteSpace:'nowrap' }}>{t.zeitH ?? '?'}h</span>
</div>
))}
{overflow.length > 4 && <p style={{ fontSize:'0.7rem', color:'#475569', marginTop:'0.25rem' }}>+{overflow.length-4} weitere</p>}
</div>
)}
</Card>
)
}
// ── Section: ATHENA Empfehlungen ──────────────────────────────────────────────
function AthenaEmpfehlungen({ todos }: { todos: Todo[] }) {
const tips = athenaEmpfehlungen(todos)
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
const SHOW = 3
return (
<Card>
<Label>ATHENA Empfehlungen</Label>
<div style={{ display:'flex', flexDirection:'column', gap:'0.5rem' }}>
{tips.map((tip, i) => {
const hasItems = tip.items && tip.items.length > 0
const isOpen = expanded[i] ?? false
const visible = hasItems ? (isOpen ? tip.items! : tip.items!.slice(0, SHOW)) : []
const hiddenCount = hasItems ? tip.items!.length - SHOW : 0
return (
<div key={i} style={{ borderLeft:`3px solid ${tip.color}`, background:'#0f172a', borderRadius:'0 0.375rem 0.375rem 0', padding:'0.5rem 0.625rem 0.5rem 0.75rem' }}>
<div style={{ display:'flex', gap:'0.5rem', alignItems:'flex-start' }}>
<span style={{ fontSize:'0.9rem', flexShrink:0 }}>{tip.icon}</span>
<span style={{ fontSize:'0.78rem', color:'#cbd5e1', lineHeight:1.4, flex:1 }}>{tip.text}</span>
</div>
{hasItems && (
<div style={{ marginTop:'0.375rem', paddingLeft:'1.5rem' }}>
{visible.map((item, j) => (
<div key={j} style={{ fontSize:'0.72rem', color:'#94a3b8', padding:'0.2rem 0', borderTop:'1px solid #1e293b', lineHeight:1.4 }}>
{item}
</div>
))}
{hiddenCount > 0 && !isOpen && (
<button
onClick={() => setExpanded(e => ({ ...e, [i]: true }))}
style={{ fontSize:'0.68rem', color:tip.color, background:'none', border:'none', cursor:'pointer', padding:'0.2rem 0', marginTop:'0.1rem' }}
>
+{hiddenCount} weitere anzeigen
</button>
)}
{isOpen && tip.items!.length > SHOW && (
<button
onClick={() => setExpanded(e => ({ ...e, [i]: false }))}
style={{ fontSize:'0.68rem', color:'#475569', background:'none', border:'none', cursor:'pointer', padding:'0.2rem 0', marginTop:'0.1rem' }}
>
weniger anzeigen
</button>
)}
</div>
)}
</div>
)
})}
</div>
</Card>
)
}
// ── Section: Predictive Analytics ────────────────────────────────────────────
function PredictiveAnalytics({ todos }: { todos: Todo[] }) {
const cr = carryoverRate(todos)
const risks = deadlineRisiko(todos)
const trend = cr.rate === 0 ? { label:'Kein Rückstand', color:'#22C55E', icon:'↗' }
: cr.rate < 20 ? { label:'Stabil', color:'#22C55E', icon:'→' }
: cr.rate < 40 ? { label:'Leichter Rückstand', color:'#EAB308', icon:'↘' }
: { label:'Kritischer Rückstand', color:'#EF4444', icon:'↓' }
return (
<Card>
<Label>Predictive Analytics</Label>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:'0.75rem', marginBottom:'0.75rem' }}>
{/* Carry-over Rate */}
<div style={{ background:'#0f172a', borderRadius:'0.5rem', padding:'0.625rem' }}>
<p style={{ fontSize:'0.65rem', color:'#475569', margin:'0 0 0.25rem', textTransform:'uppercase', letterSpacing:'0.05em' }}>Carry-over Rate</p>
<p style={{ fontSize:'1.5rem', fontWeight:800, color: cr.rate === 0 ? '#22C55E' : cr.rate < 30 ? '#EAB308' : '#EF4444', margin:0 }}>{cr.rate}%</p>
<p style={{ fontSize:'0.65rem', color:'#64748b', margin:'0.1rem 0 0' }}>{cr.overdueCount} / {cr.total} Todos überfällig</p>
</div>
{/* Trend */}
<div style={{ background:'#0f172a', borderRadius:'0.5rem', padding:'0.625rem' }}>
<p style={{ fontSize:'0.65rem', color:'#475569', margin:'0 0 0.25rem', textTransform:'uppercase', letterSpacing:'0.05em' }}>Trend</p>
<p style={{ fontSize:'1.5rem', fontWeight:800, color:trend.color, margin:0 }}>{trend.icon}</p>
<p style={{ fontSize:'0.65rem', color:trend.color, margin:'0.1rem 0 0', fontWeight:600 }}>{trend.label}</p>
</div>
</div>
{/* Deadline-Risiko je Projekt */}
{risks.length > 0 && (
<>
<p style={{ fontSize:'0.65rem', color:'#475569', textTransform:'uppercase', letterSpacing:'0.05em', margin:'0 0 0.375rem' }}>Deadline-Risiko je Projekt</p>
{risks.map(r => (
<div key={r.projekt} style={{ display:'flex', justifyContent:'space-between', alignItems:'center', background:'#0f172a', borderRadius:'0.375rem', padding:'0.3rem 0.5rem', marginBottom:'0.25rem' }}>
<span style={{ fontSize:'0.75rem', color:'#cbd5e1' }}>{r.projekt}</span>
<div style={{ display:'flex', alignItems:'center', gap:'0.375rem' }}>
<div style={{ background:'#334155', borderRadius:'9999px', width:'60px', height:'5px', overflow:'hidden' }}>
<div style={{ width:`${r.risikoPct}%`, height:'100%', background: r.risikoPct > 50 ? '#EF4444' : '#EAB308' }} />
</div>
<span style={{ fontSize:'0.7rem', color: r.risikoPct > 50 ? '#EF4444' : '#EAB308', fontWeight:700, width:'2.5rem', textAlign:'right' }}>{r.risikoPct}%</span>
</div>
</div>
))}
</>
)}
</Card>
)
}
// ── Section: Energieblöcke ────────────────────────────────────────────────────
function Energiebloecke({ todos }: { todos: Todo[] }) {
const { fokus, mittel, leicht } = energiebloecke(todos)
function Block({ label, color, items, hint }: { label:string; color:string; items:Todo[]; hint:string }) {
return (
<div style={{ flex:1 }}>
<div style={{ display:'flex', alignItems:'center', gap:'0.375rem', marginBottom:'0.375rem' }}>
<div style={{ width:'10px', height:'10px', borderRadius:'50%', background:color, flexShrink:0 }} />
<span style={{ fontSize:'0.7rem', fontWeight:700, color, textTransform:'uppercase', letterSpacing:'0.08em' }}>{label}</span>
<span style={{ fontSize:'0.65rem', color:'#475569' }}>({items.length})</span>
</div>
<div style={{ fontSize:'0.65rem', color:'#475569', marginBottom:'0.375rem' }}>{hint}</div>
{items.slice(0,3).map(t => (
<div key={t.id} style={{ background:'#0f172a', borderRadius:'0.375rem', padding:'0.25rem 0.5rem', marginBottom:'0.2rem', display:'flex', justifyContent:'space-between', gap:'0.5rem' }}>
<span style={{ fontSize:'0.72rem', color:'#94a3b8' }}>{t.name.slice(0,32)}</span>
<span style={{ fontSize:'0.65rem', color:'#475569', whiteSpace:'nowrap' }}>{t.zeitH ? `${t.zeitH}h` : '?h'}</span>
</div>
))}
{items.length > 3 && <p style={{ fontSize:'0.65rem', color:'#334155', margin:'0.2rem 0 0 0.5rem' }}>+{items.length-3} weitere</p>}
</div>
)
}
return (
<Card>
<Label>Energieblöcke (Wochenplan)</Label>
<div style={{ display:'flex', gap:'0.75rem' }}>
<Block label="Fokus" color="#EF4444" items={fokus} hint="≥2h · Hoch-Prio · morgens" />
<Block label="Mittel" color="#EAB308" items={mittel} hint="12h · Mittel-Prio" />
<Block label="Leicht" color="#22C55E" items={leicht} hint="<1h · flexibel" />
</div>
</Card>
)
}
// ── Section: Abhängigkeits-Tracker ────────────────────────────────────────────
function AbhaengigkeitsTracker({ todos }: { todos: Todo[] }) {
const ketten = meilensteinKetten(todos)
return (
<Card>
<Label>Abhängigkeits-Tracker (Meilenstein-Ketten)</Label>
{ketten.length === 0
? <p style={{ color:'#475569', fontSize:'0.875rem' }}>Keine offenen Meilensteine</p>
: ketten.map(k => (
<div key={k.label} style={{ marginBottom:'0.625rem' }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:'0.25rem' }}>
<span style={{ fontSize:'0.75rem', color:'#94a3b8', fontWeight:600 }}>{k.label}</span>
{k.overdueCount > 0 && <Badge color="#EF4444">{k.overdueCount} überfällig</Badge>}
</div>
<div style={{ paddingLeft:'0.5rem', borderLeft:'2px solid #334155' }}>
{k.todos.slice(0,3).map(t => (
<div key={t.id} style={{ display:'flex', alignItems:'center', gap:'0.375rem', marginBottom:'0.2rem' }}>
<span style={{ width:'6px', height:'6px', borderRadius:'50%', background: t.deadline && daysFromNow(t.deadline) < 0 ? '#EF4444' : '#334155', flexShrink:0, display:'block' }} />
<span style={{ fontSize:'0.72rem', color:'#64748b' }}>{t.name.slice(0,44)}</span>
</div>
))}
{k.todos.length > 3 && <p style={{ fontSize:'0.65rem', color:'#334155', margin:'0.1rem 0 0 0.875rem' }}>+{k.todos.length-3} weitere</p>}
</div>
</div>
))
}
</Card>
)
}
// ── Section: Kategorien-Chart ─────────────────────────────────────────────────
function KategorienChart({ todos }: { todos: Todo[] }) {
const data = kategorienData(todos)
return (
<Card>
<Label>Stunden je Kategorie (offen)</Label>
{data.length === 0
? <p style={{ color:'#475569', fontSize:'0.875rem' }}>Keine Daten</p>
: <ResponsiveContainer width="100%" height={240}>
<BarChart data={data} margin={{ top:4, right:8, left:-16, bottom:60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#0f172a" />
<XAxis dataKey="name" tick={{ fontSize:9, fill:'#64748b' }} angle={-40} textAnchor="end" interval={0} />
<YAxis tick={{ fontSize:9, fill:'#64748b' }} unit="h" />
<Tooltip contentStyle={{ background:'#1e293b', border:'1px solid #334155', borderRadius:'0.5rem' }} labelStyle={{ color:'#f1f5f9', fontWeight:600 }} itemStyle={{ color:'#94a3b8' }} formatter={(v) => [`${v}h`, 'Stunden']} />
<Bar dataKey="stunden" radius={[4,4,0,0]}>
{data.map(e => <Cell key={e.name} fill={kc(e.name)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
}
</Card>
)
}
// ── Section: Top 5 Deadlines ──────────────────────────────────────────────────
function Top5({ todos }: { todos: Todo[] }) {
const items = top5(todos)
function dl(days: number) {
if (days < 0) return { text:`${Math.abs(days)}d überfällig`, color:'#EF4444' }
if (days === 0) return { text:'Heute', color:'#EAB308' }
if (days <= 3) return { text:`in ${days}d`, color:'#EAB308' }
return { text:`in ${days}d`, color:'#22C55E' }
}
return (
<Card>
<Label>Top 5 nächste Deadlines</Label>
{items.length === 0
? <p style={{ color:'#475569', fontSize:'0.875rem' }}>Keine offenen Todos mit Deadline</p>
: items.map((t,i) => {
const d = dl(t.daysLeft)
return (
<div key={t.id} style={{ display:'flex', gap:'0.625rem', background:'#0f172a', borderRadius:'0.5rem', padding:'0.5rem 0.625rem', marginBottom:'0.375rem' }}>
<span style={{ fontSize:'0.7rem', fontWeight:700, color:'#475569', width:'1rem', flexShrink:0 }}>{i+1}.</span>
<div style={{ flex:1, minWidth:0 }}>
<p style={{ color:'#f1f5f9', fontSize:'0.78rem', fontWeight:600, margin:0, lineHeight:1.3 }}>{t.name.slice(0,48)}</p>
<p style={{ color:'#475569', fontSize:'0.7rem', margin:'0.15rem 0 0' }}>{t.projekt}{t.meilenstein ? ` · ${t.meilenstein}` : ''}</p>
</div>
<span style={{ fontSize:'0.7rem', fontWeight:700, color:d.color, flexShrink:0, whiteSpace:'nowrap' }}>{d.text}</span>
</div>
)
})
}
</Card>
)
}
// ── Section: Frühwarnsystem ───────────────────────────────────────────────────
function Fruehwarnsystem({ todos }: { todos: Todo[] }) {
const { overdue, upcoming } = fruehwarnung(todos)
const kap = kapazitaet(todos)
const warnings = []
if (overdue.length > 0) warnings.push({ text:`${overdue.length} überfällige Todos`, color:'#EF4444' })
if (upcoming.length > 0) warnings.push({ text:`${upcoming.length} Todos fällig in ≤ 3 Tagen`, color:'#EAB308' })
if (kap.pct > 100) warnings.push({ text:`Überlastet: ${kap.pct}% der Kapazität`, color:'#EF4444' })
else if (kap.pct > 80) warnings.push({ text:`Grenzwertig: ${kap.pct}% der Kapazität`, color:'#EAB308' })
return (
<Card>
<Label>Frühwarnsystem</Label>
{warnings.length === 0
? <p style={{ color:'#22C55E', fontSize:'0.875rem' }}> Alles im Rahmen</p>
: <div style={{ display:'flex', flexDirection:'column', gap:'0.375rem', marginBottom:'0.75rem' }}>
{warnings.map((w,i) => (
<div key={i} style={{ borderLeft:`3px solid ${w.color}`, paddingLeft:'0.625rem', background:'#0f172a', borderRadius:'0 0.375rem 0.375rem 0', padding:'0.375rem 0.5rem 0.375rem 0.625rem', fontSize:'0.78rem', color:w.color }}>
{w.text}
</div>
))}
</div>
}
{overdue.slice(0,3).map(t => (
<p key={t.id} style={{ fontSize:'0.72rem', color:'#EF4444', margin:'0.1rem 0' }}>· {t.name.slice(0,46)} ({t.projekt})</p>
))}
</Card>
)
}
// ── Section: Diana ────────────────────────────────────────────────────────────
function DianaSection({ todos }: { todos: Todo[] }) {
const diana = todos.filter(t => t.kategorie === 'Diana' && !isErledigt(t))
if (diana.length === 0) return null
return (
<Card>
<Label>Diana</Label>
{diana.map(t => (
<div key={t.id} style={{ display:'flex', justifyContent:'space-between', background:'#0f172a', borderRadius:'0.375rem', padding:'0.3rem 0.5rem', marginBottom:'0.25rem', fontSize:'0.75rem' }}>
<span style={{ color:'#f1f5f9' }}>{t.name.slice(0,48)}</span>
{t.deadline && <span style={{ color: daysFromNow(t.deadline) < 0 ? '#EF4444' : '#94a3b8', fontSize:'0.7rem', whiteSpace:'nowrap', marginLeft:'0.5rem' }}>{t.deadline.slice(5)}</span>}
</div>
))}
</Card>
)
}
// ── Section: Me Time ──────────────────────────────────────────────────────────
function MeTime({ todos }: { todos: Todo[] }) {
const meTodos = todos.filter(t => t.kategorie === 'Me Time' && !isErledigt(t))
return (
<Card>
<Label>Me Time</Label>
<div style={{ background:'#EAB30822', border:'1px solid #EAB30866', borderRadius:'0.5rem', padding:'0.625rem 0.75rem', marginBottom:'0.5rem' }}>
<p style={{ fontSize:'0.85rem', fontWeight:700, color:'#EAB308', margin:'0 0 0.2rem' }}> Noch nicht definiert</p>
<p style={{ fontSize:'0.7rem', color:'#92700a', margin:0 }}>Zeitslot für Me Time ist noch offen bitte planen</p>
</div>
{meTodos.length === 0
? <p style={{ fontSize:'0.72rem', color:'#475569' }}>Keine Me-Time-Todos diese Woche</p>
: <p style={{ fontSize:'0.72rem', color:'#22C55E' }}>{meTodos.length} Me-Time-Todos eingetragen</p>
}
</Card>
)
}
// ── Section: Zeitdifferenz ────────────────────────────────────────────────────
function Zeitdifferenz({ todos }: { todos: Todo[] }) {
const rows = zeitdifferenz(todos).sort((a,b) => Math.abs(b.diff) - Math.abs(a.diff))
return (
<Card>
<Label>Zeitdifferenz je Projekt</Label>
{rows.map(r => {
const dc = r.diff >= 0 ? '#22C55E' : '#EF4444'
const ds = r.diff >= 0 ? '+' : ''
return (
<div key={r.projekt} style={{ display:'grid', gridTemplateColumns:'1fr auto auto auto', gap:'0.5rem', alignItems:'center', background:'#0f172a', borderRadius:'0.375rem', padding:'0.3rem 0.5rem', marginBottom:'0.2rem', fontSize:'0.72rem' }}>
<span style={{ color:'#cbd5e1', fontWeight:500 }}>{r.projekt}</span>
<span style={{ color:'#475569' }}>{r.geplant}h</span>
<span style={{ color:'#475569' }}>{r.tatsaechlich}h</span>
<span style={{ color:dc, fontWeight:700, textAlign:'right' }}>{ds}{r.diff}h</span>
</div>
)
})}
<div style={{ display:'grid', gridTemplateColumns:'1fr auto auto auto', gap:'0.5rem', padding:'0.2rem 0.5rem', fontSize:'0.62rem', color:'#334155' }}>
<span /><span>Geplant</span><span>Tatsächl.</span><span>Diff</span>
</div>
</Card>
)
}
// ── Section: Kritische Stellen ────────────────────────────────────────────────
function KritischeStellen({ data }: { data: KritischData }) {
const uhrzeit = new Date(data.stand).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
})
if (data.allesOk) {
return (
<div style={{ background:'#052e16', border:'1px solid #166534', borderRadius:'0.75rem', padding:'0.875rem 1.25rem', display:'flex', justifyContent:'space-between', alignItems:'center' }}>
<div style={{ display:'flex', alignItems:'center', gap:'0.625rem' }}>
<span style={{ fontSize:'1rem' }}></span>
<span style={{ fontSize:'0.85rem', fontWeight:700, color:'#22C55E' }}>Alles im Rahmen keine kritischen Stellen</span>
</div>
<span style={{ fontSize:'0.65rem', color:'#166534' }}>Stand: {uhrzeit} Uhr</span>
</div>
)
}
const farbenMap: Record<string, string> = { rot: '#EF4444', gelb: '#EAB308' }
const typIcon: Record<string, string> = {
ueberfallig: '🔴', deadline: '⚠', meilenstein: '🎯', kapazitaet: '📊'
}
return (
<div style={{ background:'#1a0a0a', border:'1px solid #7f1d1d', borderRadius:'0.75rem', padding:'0.875rem 1.25rem' }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:'0.625rem' }}>
<span style={{ fontSize:'0.72rem', fontWeight:700, letterSpacing:'0.1em', textTransform:'uppercase', color:'#EF4444' }}>
Kritische Stellen ({data.kritisch.length})
</span>
<span style={{ fontSize:'0.65rem', color:'#7f1d1d' }}>Stand: {uhrzeit} Uhr</span>
</div>
<div style={{ display:'flex', flexDirection:'column', gap:'0.375rem' }}>
{data.kritisch.map((e, i) => {
const color = farbenMap[e.farbe] ?? '#EF4444'
return (
<div key={i} style={{ display:'flex', alignItems:'flex-start', gap:'0.625rem', background:'#0f172a', borderRadius:'0.375rem', padding:'0.4rem 0.625rem', borderLeft:`3px solid ${color}` }}>
<span style={{ fontSize:'0.8rem', flexShrink:0 }}>{typIcon[e.typ] ?? '⚠'}</span>
<div style={{ flex:1, minWidth:0 }}>
<span style={{ fontSize:'0.72rem', color:'#94a3b8', fontWeight:600, marginRight:'0.375rem' }}>{e.projekt}</span>
<span style={{ fontSize:'0.72rem', color:'#cbd5e1' }}>{e.text}</span>
</div>
</div>
)
})}
</div>
</div>
)
}
// ── Main ──────────────────────────────────────────────────────────────────────
export default function DashboardClient({ todos, fetchedAt, kritischData }: { todos: Todo[]; fetchedAt: string; kritischData: KritischData }) {
const totalOpen = todos.filter(t => !isErledigt(t)).length
const uhrzeit = new Date(fetchedAt).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })
return (
<div style={{ minHeight:'100vh', background:'#0f172a', fontFamily:'var(--font-geist-sans, system-ui, sans-serif)', color:'#f1f5f9' }}>
{/* Header */}
<div style={{ background:`linear-gradient(135deg, ${MC_BLUE} 0%, ${MC_PURPLE} 100%)`, padding:'1.25rem 2rem' }}>
<div style={{ maxWidth:'1400px', margin:'0 auto', display:'flex', justifyContent:'space-between', alignItems:'flex-end', flexWrap:'wrap', gap:'0.5rem' }}>
<div>
<h1 style={{ fontSize:'1.4rem', fontWeight:900, color:'#fff', margin:0, letterSpacing:'-0.02em' }}>ATHENA PM Dashboard</h1>
<p style={{ color:'rgba(255,255,255,0.7)', fontSize:'0.75rem', margin:'0.2rem 0 0' }}>Market Compass · Live Notion PM</p>
</div>
<div style={{ textAlign:'right' }}>
<p style={{ color:'rgba(255,255,255,0.6)', fontSize:'0.7rem', margin:0 }}>Aktualisiert: {uhrzeit} Uhr</p>
<p style={{ color:'rgba(255,255,255,0.6)', fontSize:'0.7rem', margin:'0.1rem 0 0' }}>{totalOpen} offene Todos · 10 Projekte</p>
</div>
</div>
</div>
{/* Grid */}
<div style={{ maxWidth:'1400px', margin:'0 auto', padding:'1.5rem 2rem', display:'flex', flexDirection:'column', gap:'1rem' }}>
{/* Row 0: Kritische Stellen */}
<KritischeStellen data={kritischData} />
{/* Row 1: Kapazität + Machbarkeit + ATHENA Empfehlungen */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:'1rem' }}>
<KapazitaetsAmpel todos={todos} />
<Machbarkeit todos={todos} />
<AthenaEmpfehlungen todos={todos} />
</div>
{/* Row 2: Predictive Analytics + Energieblöcke + Me Time */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:'1rem' }}>
<PredictiveAnalytics todos={todos} />
<Energiebloecke todos={todos} />
<div style={{ display:'flex', flexDirection:'column', gap:'1rem' }}>
<MeTime todos={todos} />
<DianaSection todos={todos} />
</div>
</div>
{/* Row 3: Kategorien-Chart + Top 5 */}
<div style={{ display:'grid', gridTemplateColumns:'2fr 1fr', gap:'1rem' }}>
<KategorienChart todos={todos} />
<Top5 todos={todos} />
</div>
{/* Row 4: Frühwarnsystem + Zeitdifferenz + Abhängigkeiten */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:'1rem' }}>
<Fruehwarnsystem todos={todos} />
<Zeitdifferenz todos={todos} />
<AbhaengigkeitsTracker todos={todos} />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,42 @@
import { readFileSync } from 'fs'
import { join } from 'path'
import { fetchAllTodos } from '@/lib/notion-pm'
import DashboardClient from './components/DashboardClient'
export const revalidate = 300
export const metadata = {
title: 'ATHENA PM Dashboard | Market Compass',
}
export interface KritischEintrag {
typ: 'deadline' | 'meilenstein' | 'kapazitaet' | 'ueberfallig'
projekt: string
text: string
farbe: 'rot' | 'gelb'
}
export interface KritischData {
stand: string
kritisch: KritischEintrag[]
allesOk: boolean
}
function loadKritischData(): KritischData {
try {
const filePath = join(process.cwd(), 'public', 'pm-kritisch.json')
const raw = readFileSync(filePath, 'utf-8')
return JSON.parse(raw) as KritischData
} catch {
return { stand: new Date().toISOString(), kritisch: [], allesOk: true }
}
}
export default async function DashboardPage() {
const [todos, kritischData] = await Promise.all([
fetchAllTodos(),
Promise.resolve(loadKritischData()),
])
const timestamp = new Date().toISOString()
return <DashboardClient todos={todos} fetchedAt={timestamp} kritischData={kritischData} />
}

50
src/app/olga/layout.tsx Normal file
View file

@ -0,0 +1,50 @@
import type { Metadata } from "next"
import { Cormorant_Garamond, DM_Sans } from "next/font/google"
const cormorant = Cormorant_Garamond({
variable: "--font-cormorant",
subsets: ["latin"],
weight: ["300", "400", "500", "600"],
style: ["normal", "italic"],
})
const dmSans = DM_Sans({
variable: "--font-dm-sans",
subsets: ["latin"],
weight: ["300", "400", "500", "600"],
})
export const metadata: Metadata = {
title: "Skin Signal Deep Scan — Olga Izieva",
description:
"In 90 Minuten weißt du endlich, was dein Körper dir die ganze Zeit sagen wollte. Skin Signal Deep Scan von Olga Izieva — systemische Körperanalyse für Frauen 35+.",
}
export default function OlgaLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div
className={`${cormorant.variable} ${dmSans.variable}`}
style={{
fontFamily: "var(--font-dm-sans, system-ui, sans-serif)",
background: "#F0EBE1",
minHeight: "100vh",
}}
>
<style>{`
.olga-bio-grid {
grid-template-columns: 200px 1fr !important;
}
@media (max-width: 600px) {
.olga-bio-grid {
grid-template-columns: 1fr !important;
}
}
`}</style>
{children}
</div>
)
}

640
src/app/olga/page.tsx Normal file
View file

@ -0,0 +1,640 @@
"use client"
import { useState } from "react"
import Image from "next/image"
const C = {
dark: "#1C2420",
green: "#2D4A3E",
greenLight: "#3D5C4E",
cognac: "#A06040",
cognacLight: "#C4845A",
parchment: "#F0EBE1",
parchmentMid: "#E8E0D4",
parchmentDark: "#DDD4C4",
brown: "#1A1210",
textDark: "#2A2218",
textMuted: "#7A6E62",
white: "#FDFAF6",
sage: "#EBF0ED",
}
function StrataDecor({ color = C.cognac, opacity = 0.2 }: { color?: string; opacity?: number }) {
return (
<svg width="280" height="40" viewBox="0 0 280 40" fill="none" aria-hidden="true">
<path d="M0 10 Q70 5 140 12 Q210 19 280 8" stroke={color} strokeWidth="1.5" fill="none" opacity={opacity + 0.08} />
<path d="M0 20 Q70 15 140 22 Q210 29 280 18" stroke={color} strokeWidth="1" fill="none" opacity={opacity} />
<path d="M0 30 Q70 25 140 32 Q210 39 280 28" stroke={color} strokeWidth="0.75" fill="none" opacity={opacity - 0.08} />
</svg>
)
}
const body: React.CSSProperties = {
fontFamily: "var(--font-dm-sans, system-ui, sans-serif)",
color: C.textDark,
lineHeight: 1.7,
}
const serif: React.CSSProperties = {
fontFamily: "var(--font-cormorant, Georgia, serif)",
}
export default function OlgaDeepScanPage() {
const [formData, setFormData] = useState({ name: "", email: "", phone: "", message: "" })
const [submitted, setSubmitted] = useState(false)
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }))
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSubmitted(true)
}
return (
<main style={body}>
{/* ── HEADER ─────────────────────────────────────────────── */}
<header style={{ background: C.dark, padding: "14px 32px" }}>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
<p style={{
...serif,
color: C.parchment,
fontSize: "0.95rem",
letterSpacing: "0.14em",
textTransform: "uppercase",
margin: 0,
opacity: 0.75,
}}>
Olga Izieva &nbsp;·&nbsp; Skin Signal Method
</p>
</div>
</header>
{/* ── HERO ───────────────────────────────────────────────── */}
<section style={{ background: C.green, padding: "88px 32px 80px" }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<span style={{
display: "inline-block",
background: C.cognac,
color: C.white,
fontSize: "0.7rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
padding: "5px 14px",
borderRadius: 2,
marginBottom: 28,
}}>
Nur 5 Plätze verfügbar
</span>
<h1 style={{
...serif,
color: C.white,
fontSize: "clamp(2rem, 5vw, 3rem)",
fontWeight: 500,
lineHeight: 1.2,
marginBottom: 24,
maxWidth: 600,
}}>
In 90 Minuten weißt du endlich, was dein Körper dir die ganze Zeit sagen wollte
</h1>
<p style={{
color: C.parchment,
fontSize: "1.05rem",
lineHeight: 1.75,
marginBottom: 12,
maxWidth: 540,
opacity: 0.88,
}}>
Der Skin Signal Deep Scan eine systemische Körperanalyse, die Haut, Hormone, Darm, Schlaf und Stress als ein zusammenhängendes Bild liest.
</p>
<p style={{
color: C.parchment,
fontSize: "0.85rem",
marginBottom: 44,
opacity: 0.6,
}}>
Olga Izieva &nbsp;·&nbsp; 33 Jahre klinische Erfahrung &nbsp;·&nbsp; Heilpraktikerin &nbsp;·&nbsp; Medical Cosmetology
</p>
<a
href="#termin"
style={{
display: "inline-block",
background: C.cognac,
color: C.white,
padding: "15px 38px",
fontSize: "0.95rem",
fontWeight: 600,
textDecoration: "none",
borderRadius: 3,
letterSpacing: "0.04em",
}}
>
Meinen Platz sichern
</a>
<p style={{ color: C.parchment, fontSize: "0.8rem", marginTop: 14, opacity: 0.55 }}>
Pilotpreis 197 &nbsp;·&nbsp; persönlich bei Olga &nbsp;·&nbsp; 5 Plätze
</p>
<div style={{ marginTop: 48, opacity: 0.7 }}>
<StrataDecor color={C.parchment} opacity={0.12} />
</div>
</div>
</section>
{/* ── PROBLEM ────────────────────────────────────────────── */}
<section style={{ background: C.white, padding: "80px 32px" }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 16 }}>
Kommt dir das bekannt vor?
</p>
<h2 style={{
...serif,
color: C.dark,
fontSize: "clamp(1.7rem, 3.5vw, 2.4rem)",
fontWeight: 500,
lineHeight: 1.25,
marginBottom: 48,
maxWidth: 560,
}}>
Du tust alles richtig und trotzdem stimmt irgendetwas nicht.
</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 24 }}>
{[
["Haut die müde wirkt", "Trotz Pflege, Supplements und clean eating — sie strahlt einfach nicht mehr so wie früher."],
["Ärzte sagen: alles normal", "Die Blutwerte sind unauffällig. Aber du fühlst, dass etwas nicht stimmt — und niemand kann dir sagen was."],
["Du hast schon alles probiert", "Keto, Yoga, Schlaftracking, Vitamin D, Kollagen. Jedes Mal kurze Besserung. Dann wieder Ausgangszustand."],
["Niemand sieht das Gesamtbild", "Jeder schaut auf seinen Bereich. Haut oder Hormone oder Darm. Nie alles zusammen. Nie du."],
].map(([title, text], i) => (
<div key={i} style={{ padding: "24px 0", borderTop: `1.5px solid ${C.parchmentDark}` }}>
<div style={{
...serif,
color: C.cognac,
fontSize: "1.6rem",
fontWeight: 400,
marginBottom: 10,
opacity: 0.5,
}}>
{String(i + 1).padStart(2, "0")}
</div>
<p style={{ fontWeight: 600, color: C.textDark, fontSize: "0.95rem", marginBottom: 6 }}>{title}</p>
<p style={{ color: C.textMuted, fontSize: "0.88rem", lineHeight: 1.65 }}>{text}</p>
</div>
))}
</div>
</div>
</section>
{/* ── EPIPHANY ───────────────────────────────────────────── */}
<section style={{ background: C.sage, padding: "80px 32px" }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<StrataDecor color={C.green} opacity={0.16} />
<div style={{ height: 40 }} />
<blockquote style={{
...serif,
color: C.green,
fontSize: "clamp(1.6rem, 4vw, 2.6rem)",
fontStyle: "italic",
fontWeight: 400,
lineHeight: 1.35,
marginBottom: 40,
borderLeft: `3px solid ${C.cognac}`,
paddingLeft: 32,
maxWidth: 600,
}}>
Die Haut ist keine Krankheit. Sie ist die Botschaft."
</blockquote>
<p style={{ color: C.textMuted, fontSize: "1rem", lineHeight: 1.8, marginBottom: 16, maxWidth: 560 }}>
Olga Izieva sieht seit 33 Jahren, was Ärzte nicht sehen wollen: Hautprobleme sind keine Oberflächenfrage. Sie zeigen, was im Körper wirklich passiert.
</p>
<p style={{ color: C.textMuted, fontSize: "1rem", lineHeight: 1.8, maxWidth: 560 }}>
Als ihre eigene Haut durch Stress zusammenbrach und niemand helfen konnte, entwickelte sie die Skin Signal Method. Ein systemischer Ansatz, der Haut als das liest, was sie ist: das ehrlichste Diagnoseinstrument, das wir haben.
</p>
</div>
</section>
{/* ── VALUE STACK ────────────────────────────────────────── */}
<section style={{ background: C.white, padding: "80px 32px" }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 16 }}>
Was du bekommst
</p>
<h2 style={{
...serif,
color: C.dark,
fontSize: "clamp(1.7rem, 3.5vw, 2.2rem)",
fontWeight: 500,
marginBottom: 8,
}}>
Skin Signal Deep Scan
</h2>
<p style={{ color: C.textMuted, fontSize: "0.95rem", marginBottom: 48 }}>
90 Minuten. Vollständige systemische Analyse. Alles schriftlich.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 0, marginBottom: 40, borderTop: `1.5px solid ${C.parchmentDark}` }}>
{[
["Visuelle Körperanalyse", "Haut, Zunge, Nägel, Haare, Lippen — Olga liest alle äußeren Signale deines Körpers", "150 €"],
["Systemische Anamnese", "Hormone, Darm, Schlaf, Stress, Ernährung, Bewegung, Medikamente — beide vollständigen Fragebögen", "180 €"],
["Schriftliche System-Hypothese", "Olgas Einschätzung was systemisch hinter deinen Symptomen steckt — benannt, nicht vermutet", "220 €"],
["7-Tage-Sofortplan", "Drei bis fünf konkrete Schritte die heute noch beginnen können — schriftlich", "102 €"],
["Klare Einschätzung nächster Schritt", "Du weißt danach genau was als nächstes kommt — Olga schlägt die konkrete Richtung vor", "50 €"],
].map(([title, desc, value], i) => (
<div key={i} style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 20,
padding: "20px 0",
borderBottom: `1px solid ${C.parchmentDark}`,
}}>
<div style={{ flex: 1 }}>
<p style={{ fontWeight: 600, color: C.textDark, fontSize: "0.93rem", marginBottom: 3 }}>{title}</p>
<p style={{ color: C.textMuted, fontSize: "0.84rem", lineHeight: 1.55 }}>{desc}</p>
</div>
<p style={{ color: C.cognac, fontWeight: 600, fontSize: "0.9rem", whiteSpace: "nowrap", paddingTop: 2 }}>
{value}
</p>
</div>
))}
</div>
{/* Preisblock */}
<div style={{
background: C.green,
borderRadius: 6,
padding: "32px 36px",
marginBottom: 20,
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
<p style={{ color: C.parchment, fontSize: "0.88rem", opacity: 0.7 }}>Gesamtwert</p>
<p style={{ ...serif, color: C.parchment, fontSize: "1.4rem", textDecoration: "line-through", opacity: 0.4 }}>702 </p>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", flexWrap: "wrap", gap: 8 }}>
<p style={{ color: C.parchment, fontSize: "1rem", fontWeight: 600 }}>Pilotpreis für 5 Bestandskundinnen</p>
<p style={{ ...serif, color: C.white, fontSize: "2.6rem", fontWeight: 600, lineHeight: 1 }}>197 </p>
</div>
</div>
<p style={{ color: C.textMuted, fontSize: "0.83rem", lineHeight: 1.65, fontStyle: "italic", marginBottom: 40 }}>
Das ist der erste strukturierte Durchlauf der Skin Signal Method. Du bekommst die vollständige Analyse. Im Gegenzug nimmt sich Olga die Zeit, die sie im regulären Praxiskontext nicht hätte.
</p>
<div style={{ textAlign: "center" }}>
<a
href="#termin"
style={{
display: "inline-block",
background: C.cognac,
color: C.white,
padding: "15px 42px",
fontSize: "0.95rem",
fontWeight: 600,
textDecoration: "none",
borderRadius: 3,
letterSpacing: "0.04em",
}}
>
Meinen Platz sichern
</a>
</div>
</div>
</section>
{/* ── TRANSFORMATION ─────────────────────────────────────── */}
<section style={{ background: C.parchmentMid, padding: "80px 32px" }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 24 }}>
Was danach anders ist
</p>
<div style={{ borderLeft: `3px solid ${C.green}`, paddingLeft: 32 }}>
<p style={{ ...serif, color: C.dark, fontSize: "clamp(1.2rem, 2.5vw, 1.5rem)", lineHeight: 1.7, marginBottom: 20, fontStyle: "italic" }}>
Nach diesem Gespräch weißt du endlich, was dein Körper dir die ganze Zeit sagen wollte."
</p>
<p style={{ color: C.textMuted, fontSize: "0.97rem", lineHeight: 1.8, marginBottom: 16 }}>
Die Haut die müde wirkt, die Energie die fehlt, das Gewicht das sich trotz allem nicht bewegt du verstehst jetzt das Muster dahinter. Olga hat dich als Gesamtbild gelesen: Haut, Zunge, Nägel, Haare, Darm, Hormone, Schlaf, Stress alles zusammen.
</p>
<p style={{ color: C.textMuted, fontSize: "0.97rem", lineHeight: 1.8 }}>
Du verlässt das Gespräch nicht mit einem weiteren Ratschlag. Sondern mit einer klaren Einschätzung was systemisch hinter deinen Symptomen steckt und einem 7-Tage-Plan der heute noch startet.{" "}
<strong style={{ color: C.dark }}>Dein Körper war nie dein Feind. Er hat nur auf Klarheit gewartet.</strong>
</p>
</div>
</div>
</section>
{/* ── ÜBER OLGA ──────────────────────────────────────────── */}
<section style={{ background: C.white, padding: "80px 32px" }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 40 }}>
Über Olga Izieva
</p>
<div style={{
display: "grid",
gridTemplateColumns: "200px 1fr",
gap: 48,
alignItems: "start",
}}
className="olga-bio-grid"
>
{/* Foto */}
<div style={{ position: "relative" }}>
<div style={{
position: "relative",
width: 200,
height: 240,
borderRadius: 4,
overflow: "hidden",
boxShadow: "0 8px 32px rgba(28,36,32,0.12)",
}}>
<Image
src="/olga.jpg"
alt="Olga Izieva"
fill
style={{ objectFit: "cover", objectPosition: "center top" }}
priority
/>
</div>
<div style={{
position: "absolute",
bottom: -12,
right: -12,
width: 80,
height: 80,
borderRadius: "50%",
background: C.parchment,
border: `3px solid ${C.cognac}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
...serif,
color: C.cognac,
fontSize: "0.65rem",
letterSpacing: "0.06em",
textTransform: "uppercase",
textAlign: "center",
lineHeight: 1.4,
fontWeight: 500,
padding: 4,
}}>
33 Jahre<br />Erfahrung
</div>
</div>
{/* Text */}
<div>
<h2 style={{
...serif,
color: C.dark,
fontSize: "clamp(1.6rem, 3vw, 2.2rem)",
fontWeight: 500,
marginBottom: 20,
lineHeight: 1.2,
}}>
Die Frau, die deinen Körper liest wie niemand sonst
</h2>
<p style={{ color: C.textMuted, fontSize: "0.95rem", lineHeight: 1.8, marginBottom: 16 }}>
Olga Izieva hat 33 Jahre als Kosmetikerin gearbeitet und dabei täglich gesehen, was Ärzte nicht sehen wollten: Die Haut ist kein kosmetisches Problem. Sie ist ein systemisches Signal.
</p>
<p style={{ color: C.textMuted, fontSize: "0.95rem", lineHeight: 1.8, marginBottom: 28 }}>
Sie ist Heilpraktikerin, hat Zertifikate in Ernährungswissenschaft, Psychologie und Medical Cosmetology. Sie koordiniert Ärzte, Spezialisten und Coaches der einzige Hub, der den visuellen Diagnosezugang der Kosmetik mit systemischer Medizin verbindet.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{[
"33 Jahre klinische Praxis als Kosmetikerin",
"Ausgebildete Heilpraktikerin",
"Zertifikate in Ernährungswissenschaft & Psychologie",
"Medical Cosmetology",
"Skin Signal Method™ — aus der eigenen Praxis heraus entwickelt",
].map((point, i) => (
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
<span style={{ color: C.cognac, marginTop: 2, fontSize: "0.9rem" }}></span>
<p style={{ color: C.textDark, fontSize: "0.88rem", lineHeight: 1.5 }}>{point}</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
{/* ── GARANTIE ───────────────────────────────────────────── */}
<section style={{ background: C.parchment, padding: "80px 32px" }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 16 }}>
Olgas Versprechen
</p>
<h2 style={{
...serif,
color: C.dark,
fontSize: "clamp(1.6rem, 3.5vw, 2.2rem)",
fontWeight: 500,
marginBottom: 48,
}}>
Ohne Wenn und Aber.
</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 24 }}>
{[
{
label: "Klarheits-Garantie",
text: "Wenn du nach unserem Gespräch nicht weißt, was dein nächster konkreter Schritt ist — wir machen es nochmal. Kostenlos.",
},
{
label: "7-Tage-Garantie",
text: "Setz den 7-Tage-Sofortplan um. Wenn du nach einer Woche keine spürbare Veränderung merkst, bekommst du ein kostenloses 20-Minuten-Nachgespräch dazu.",
},
].map((g, i) => (
<div key={i} style={{
background: C.white,
borderRadius: 4,
padding: "28px 28px",
borderTop: `3px solid ${C.cognac}`,
}}>
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 14 }}>
{g.label}
</p>
<p style={{ ...serif, color: C.dark, fontSize: "1.05rem", lineHeight: 1.65, fontStyle: "italic" }}>
{g.text}"
</p>
</div>
))}
</div>
</div>
</section>
{/* ── ORDER FORM ─────────────────────────────────────────── */}
<section id="termin" style={{ background: C.sage, padding: "88px 32px 96px" }}>
<div style={{ maxWidth: 560, margin: "0 auto" }}>
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 16 }}>
Platz sichern
</p>
<h2 style={{
...serif,
color: C.dark,
fontSize: "clamp(1.7rem, 3.5vw, 2.4rem)",
fontWeight: 500,
marginBottom: 8,
lineHeight: 1.2,
}}>
Dein Platz im Skin Signal Deep Scan
</h2>
<p style={{ color: C.textMuted, fontSize: "0.93rem", marginBottom: 6, lineHeight: 1.65 }}>
Füll das Formular aus Olga meldet sich persönlich bei dir für die Terminvereinbarung.
</p>
<p style={{ color: C.cognac, fontSize: "0.85rem", fontWeight: 600, marginBottom: 44 }}>
Pilotpreis 197 &nbsp;·&nbsp; 90 Minuten &nbsp;·&nbsp; 5 Plätze
</p>
{submitted ? (
<div style={{
background: C.white,
borderRadius: 6,
padding: "40px 36px",
textAlign: "center",
borderTop: `3px solid ${C.green}`,
}}>
<p style={{
...serif,
color: C.green,
fontSize: "1.9rem",
fontStyle: "italic",
marginBottom: 12,
}}>
Anfrage eingegangen.
</p>
<p style={{ color: C.textMuted, fontSize: "0.95rem", lineHeight: 1.7 }}>
Olga meldet sich innerhalb von 24 Stunden persönlich bei dir mit allen Details zum Termin.
</p>
</div>
) : (
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 18 }}>
{[
{ name: "name", label: "Dein Name", type: "text", placeholder: "Vorname Nachname", required: true },
{ name: "email", label: "E-Mail-Adresse", type: "email", placeholder: "deine@email.de", required: true },
{ name: "phone", label: "Telefonnummer (optional)", type: "tel", placeholder: "+49 ...", required: false },
].map((field) => (
<div key={field.name}>
<label style={{
display: "block",
color: C.textDark,
fontSize: "0.85rem",
fontWeight: 600,
marginBottom: 7,
letterSpacing: "0.02em",
}}>
{field.label}
</label>
<input
type={field.type}
name={field.name}
value={formData[field.name as keyof typeof formData]}
onChange={handleChange}
placeholder={field.placeholder}
required={field.required}
style={{
width: "100%",
padding: "12px 16px",
border: `1.5px solid ${C.parchmentDark}`,
borderRadius: 3,
background: C.white,
color: C.textDark,
fontSize: "0.95rem",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
))}
<div>
<label style={{
display: "block",
color: C.textDark,
fontSize: "0.85rem",
fontWeight: 600,
marginBottom: 7,
letterSpacing: "0.02em",
}}>
Was beschäftigt dich gerade am meisten? (optional)
</label>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
rows={4}
placeholder="Beschreib kurz was dich beschäftigt — Olga bereitet sich damit auf das Gespräch vor."
style={{
width: "100%",
padding: "12px 16px",
border: `1.5px solid ${C.parchmentDark}`,
borderRadius: 3,
background: C.white,
color: C.textDark,
fontSize: "0.95rem",
outline: "none",
resize: "vertical",
boxSizing: "border-box",
fontFamily: "var(--font-dm-sans, system-ui, sans-serif)",
}}
/>
</div>
<button
type="submit"
style={{
background: C.cognac,
color: C.white,
padding: "15px 36px",
fontSize: "0.95rem",
fontWeight: 600,
border: "none",
borderRadius: 3,
cursor: "pointer",
letterSpacing: "0.04em",
marginTop: 4,
alignSelf: "flex-start",
}}
>
Anfrage absenden
</button>
<p style={{ color: C.textMuted, fontSize: "0.79rem", lineHeight: 1.55 }}>
Kein automatischer Kauf. Olga meldet sich persönlich. Zahlung erfolgt nach Terminbestätigung.
</p>
</form>
)}
</div>
</section>
{/* ── FOOTER ─────────────────────────────────────────────── */}
<footer style={{ background: C.dark, padding: "24px 32px" }}>
<div style={{ maxWidth: 720, margin: "0 auto", display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 10 }}>
<p style={{ ...serif, color: C.parchment, fontSize: "0.95rem", opacity: 0.65, letterSpacing: "0.06em" }}>
Olga Izieva · Skin Signal Method
</p>
<p style={{ color: C.parchment, fontSize: "0.75rem", opacity: 0.35 }}>
Impressum · Datenschutz
</p>
</div>
</footer>
</main>
)
}

108
src/lib/notion-pm.ts Normal file
View file

@ -0,0 +1,108 @@
const NOTION_VERSION = '2022-06-28'
function getHeaders() {
const token = process.env.PAI_NOTION_TOKEN
if (!token) throw new Error('PAI_NOTION_TOKEN not set')
return {
Authorization: `Bearer ${token}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json',
}
}
export const DBS: Record<string, string> = {
'GSO Gruppencoaching': '3641a317-e544-81f0-aa71-dc214949b532',
'OWV': '3641a317-e544-8105-84e8-d1bbcf6940a6',
'OSD': '3641a317-e544-8102-b677-ec4a77266d1c',
'Womenmatic': '3641a317-e544-8163-a10f-f2aa2669b2c6',
'Olga Izieva': '3641a317-e544-81ea-8f53-e21d20afda3f',
'Tecnoclean': '3641a317-e544-8113-a047-cb227d16d5ac',
'Dennis Alter': '3641a317-e544-810f-8a4c-e628e1d636fb',
'Interessenten': '3641a317-e544-8144-a67c-f39dab33b49c',
'Urlaub Kroatien': '3641a317-e544-819b-b28b-e6a49cf54dcb',
'Alltags ToDos': '3641a317-e544-812e-af33-e1912fe95416',
}
export interface Todo {
id: string
name: string
projekt: string
status: string | null
prioritaet: string | null
deadline: string | null
startdatum: string | null
geplanterTag: string | null
meilenstein: string | null
zeitH: number | null
tatsaechlicheZeitH: number | null
zeitdifferenzH: number | null
kategorie: string | null
imWochenplan: boolean
}
async function fetchDbPages(dbId: string): Promise<any[]> {
const headers = getHeaders()
const pages: any[] = []
let cursor: string | undefined
while (true) {
const body: Record<string, unknown> = { page_size: 100 }
if (cursor) body.start_cursor = cursor
const res = await fetch(`https://api.notion.com/v1/databases/${dbId}/query`, {
method: 'POST',
headers,
body: JSON.stringify(body),
next: { revalidate: 300 },
})
if (!res.ok) break
const data = await res.json()
pages.push(...(data.results ?? []))
if (!data.has_more) break
cursor = data.next_cursor
}
return pages
}
function parseTodo(page: any, projektName: string): Todo {
const p = page.properties ?? {}
const name = (p['Name']?.title ?? [])
.map((t: any) => t.plain_text as string)
.join('')
const zeitH = p['Zeit (h)']?.number ?? null
const tatsaechlicheZeitH = p['Tatsächliche Zeit (h)']?.number ?? null
const zdRaw = p['Zeitdifferenz (h)']?.formula
const zeitdifferenzH =
zdRaw?.type === 'number' ? (zdRaw.number ?? null) : null
return {
id: page.id,
name,
projekt: projektName,
status: p['Status']?.status?.name ?? null,
prioritaet: p['Priorität']?.select?.name ?? null,
deadline: p['Deadline']?.date?.start ?? null,
startdatum: p['Startdatum']?.date?.start ?? null,
geplanterTag: p['Geplanter Tag']?.date?.start ?? null,
meilenstein: p['Meilenstein']?.select?.name ?? null,
zeitH,
tatsaechlicheZeitH,
zeitdifferenzH,
kategorie: p['Kategorie']?.select?.name ?? null,
imWochenplan: p['Im Wochenplan']?.checkbox ?? false,
}
}
export async function fetchAllTodos(): Promise<Todo[]> {
const results = await Promise.all(
Object.entries(DBS).map(async ([name, id]) => {
const pages = await fetchDbPages(id)
return pages.map((p) => parseTodo(p, name))
})
)
return results.flat()
}