diff --git a/.gitignore b/.gitignore index 16c0f0a..da7a6ec 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ Thumbs.db .vscode/ .idea/ *.swp - calendar-period-week-notes.code-workspace +calendar-period-week-notes.code-workspace #Local Scripts deploy-to-obsidian.sh diff --git a/manifest.json b/manifest.json index 4b7831b..1b28371 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "calendar-period-week-notes", "name": "Calendar Period Week Notes", - "version": "1.4.2", + "version": "1.5.0", "minAppVersion": "0.15.0", "description": "A calendar grid plugin with a scratchpad area, notes list, pinned notes, tasks and asset views.", "author": "Tim Williams", diff --git a/package-lock.json b/package-lock.json index 1b72601..85989c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-period-week-notes", - "version": "1.4.2", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-period-week-notes", - "version": "1.4.2", + "version": "1.5.0", "license": "MIT", "dependencies": { "ical.js": "^2.2.1", @@ -21,15 +21,11 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.9", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", - "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", "dev": true, "license": "MIT", "dependencies": { @@ -55,8 +51,6 @@ }, "node_modules/@rollup/plugin-json": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", "dev": true, "license": "MIT", "dependencies": { @@ -76,8 +70,6 @@ }, "node_modules/@rollup/plugin-node-resolve": { "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", - "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", "dev": true, "license": "MIT", "dependencies": { @@ -101,8 +93,6 @@ }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -122,38 +112,8 @@ } } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", "cpu": [ "arm64" ], @@ -164,297 +124,23 @@ "darwin" ] }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/resolve": { "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "dev": true, "license": "MIT" }, "node_modules/commondir": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true, "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, "license": "MIT", "engines": { @@ -463,15 +149,11 @@ }, "node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, "license": "MIT" }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -488,10 +170,7 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -503,8 +182,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", "funding": { @@ -513,8 +190,6 @@ }, "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -526,14 +201,10 @@ }, "node_modules/ical.js": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz", - "integrity": "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==", "license": "MPL-2.0" }, "node_modules/is-core-module": { "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { @@ -548,15 +219,11 @@ }, "node_modules/is-module": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "dev": true, "license": "MIT" }, "node_modules/is-reference": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -565,8 +232,6 @@ }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -575,8 +240,6 @@ }, "node_modules/moment": { "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", "engines": { "node": "*" @@ -584,18 +247,13 @@ }, "node_modules/path-parse": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true, "license": "MIT" }, "node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -605,8 +263,6 @@ }, "node_modules/resolve": { "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -626,11 +282,8 @@ }, "node_modules/rollup": { "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -669,8 +322,6 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index d3e4ea5..4c0d8b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "calendar-period-week-notes", - "version": "1.4.2", + "version": "1.5.0", "description": "A calendar based Obsidian plugin.", "main": "main.js", "type": "module", diff --git a/src/data/constants.js b/src/data/constants.js index 0f47865..8d7f4d1 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -3,13 +3,17 @@ export const VIEW_TYPE_PERIOD = "calendar-period-week"; export const DASHBOARDWIDGETS = { tasks: { + weeklyGoalPoints: { name: 'Weekly points score (Full)' }, + goalStatusList: { name: 'Daily goals (Full)' }, + goalStatusListCondensed: { name: 'Daily goals (Half-width)', default: false }, + weeklyGoalPointsCondensed: { name: 'Weekly momentum (Half-width)', default: false }, today: { name: 'Today' }, tomorrow: { name: 'Tomorrow' }, next7days: { name: 'Next 7 days' }, futureNoDue: { name: 'Future / No due' }, upcomingoverdue: { name: 'Upcoming & overdue tasks' }, taskstatusoverview: { name: 'Task status overview' }, - taskcompletionheatmap: { name: 'Task completion activity' } + taskcompletionheatmap: { name: 'Task completion activity' }, }, creation: { allfilesheatmap: { name: 'All files' }, @@ -253,7 +257,8 @@ export const DEFAULT_SETTINGS = { regularnotesheatmap: true, assetsheatmap: true, }, - tasksDashboardOrder: ['today', 'tomorrow', 'next7days', 'futureNoDue', 'upcomingoverdue', 'taskstatusoverview', 'taskcompletionheatmap'], + + tasksDashboardOrder: ['Today\'s goals', 'Weekly points score', 'today', 'tomorrow', 'next7days', 'futureNoDue', 'upcomingoverdue', 'taskstatusoverview', 'taskcompletionheatmap'], tasksDashboardWidgets: { today: true, tomorrow: true, @@ -269,9 +274,38 @@ export const DEFAULT_SETTINGS = { isHeatmapGuideCollapsed: true, heatmapLinkConfig: {}, heatmapTotalClickOpenAction: 'new-tab', - customHeatmaps: [], + goals: [ + { + id: 'core-daily-note', + name: 'Daily note created', + type: 'note-created', + target: 1, + points: 60, + core: true, + enabled: true + }, + { + id: 'core-words-written', + name: 'Words written', + type: 'word-count', + target: 200, + points: 24, + core: true, + enabled: true + }, + { + id: 'core-tasks-completed', + name: 'Tasks completed', + type: 'task-count', + target: 1, + points: 30, + core: true, + enabled: true + } + ], + goalScoreHistory: {}, + vacationHistory: {}, - // In your DEFAULT_SETTINGS tasksStatsWidgets: [ { "widgetKey": "custom-completed-line-30d", diff --git a/src/data/templates.js b/src/data/templates.js index d47b2ef..cf9d5f9 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -13,7 +13,6 @@ export const BUNDLED_TEMPLATES = [ line, pie, radar, - // Add other imported templates to this array ]; diff --git a/src/logic/GoalTracker.js b/src/logic/GoalTracker.js new file mode 100644 index 0000000..e4aa778 --- /dev/null +++ b/src/logic/GoalTracker.js @@ -0,0 +1,393 @@ +import { moment, TFile } from 'obsidian'; + +export class GoalTracker { + constructor(app, settings) { + this.app = app; + this.settings = settings; + + if (!this.settings.goalScoreHistory) { + this.settings.goalScoreHistory = {}; + } + if (!this.settings.vacationHistory) { + this.settings.vacationHistory = {}; + } + } + + /** + * Checks a single goal and returns its status, including current progress. + * @param {object} goal - The goal configuration object. + * @param {moment.Moment} date - The date to check against. + * @param {Array} allTasks - The complete list of tasks. + * @returns {Promise<{isMet: boolean, current: number}>} + */ + async checkGoal(goal, date, allTasks) { + let result = { isMet: false, current: 0 }; + + switch (goal.type) { + case 'note-created': + result = await this.checkNoteCreated(date, goal.folder); + break; + case 'word-count': + result = await this.checkWordCount(date, goal.folder, goal.target); + break; + case 'task-count': + result = this.checkTaskCount(date, allTasks, goal.query, goal.target); + break; + } + return result; + } + + /** + * Calculates the total score for a day with bonuses, active-day rules, + * vacation mode, and persistent caching. + * @param {moment.Moment} date - The date to score. + * @param {Array} allTasks - The complete list of tasks. + * @returns {Promise<{date: string, totalPoints: number, results: Array, allMet: boolean, level: number, levelTitle: string, levelIcon: string}>} + */ + async calculateDailyScore(date, allTasks = []) { + + if (this.settings.vacationModeGlobal) { + // Vacation applies to all goals, skip scoring + return { + date: date.format('YYYY-MM-DD'), + totalPoints: 0, + results: [], + allMet: false, + level: 0, + levelTitle: '', + levelIcon: '' + }; + } + + const isoKey = date.format('YYYY-MM-DD'); + + const todayStart = moment().startOf('day'); + const dateStart = date.clone().startOf('day'); + const canUseCache = dateStart.isBefore(todayStart, 'day'); + + // 0. Return cached snapshot if available + if (canUseCache && + this.settings.goalScoreHistory && + this.settings.goalScoreHistory[isoKey]) { + return this.settings.goalScoreHistory[isoKey]; + } + + // Ensure containers exist + if (!this.settings.goalScoreHistory) this.settings.goalScoreHistory = {}; + if (!this.settings.vacationHistory) this.settings.vacationHistory = {}; + + const vacationForDay = this.settings.vacationHistory[isoKey] || {}; + + let totalPoints = 0; + const results = []; + + const goals = this.settings.goals || []; + const weekdayIndex = date.isoWeekday() - 1; // 0=Mon .. 6=Sun + + let activeGoalsCount = 0; + let metGoalsCount = 0; + + const activeGoals = (this.settings.goals || []).filter(g => !!g.enabled); + + for (const goal of activeGoals) { + const activeDays = Array.isArray(goal.activeDays) && goal.activeDays.length > 0 + ? goal.activeDays + : [0, 1, 2, 3, 4, 5, 6]; + + const isActiveToday = activeDays.includes(weekdayIndex); + + // Historical vacation: if this goal was ever on vacation for this date, + // keep that regardless of the current toggle state. + const isVacationToday = + !!vacationForDay[goal.id] || !!goal.vacationMode === true; + + // If the goal is in vacationMode *now* and the day is being scored, + // persist that into vacationHistory so future recalcs remember it. + if (isActiveToday && goal.vacationMode) { + if (!this.settings.vacationHistory[isoKey]) { + this.settings.vacationHistory[isoKey] = {}; + } + this.settings.vacationHistory[isoKey][goal.id] = true; + } + + let result = { isMet: false, current: 0 }; + if (isActiveToday && !isVacationToday) { + result = await this.checkGoal(goal, date, allTasks); + activeGoalsCount++; + } else if (isActiveToday && isVacationToday) { + // still "active" for display purposes but we won't penalise + result = await this.checkGoal(goal, date, allTasks); + activeGoalsCount++; + } + + let goalPoints = 0; + + if (isActiveToday && result.isMet && !isVacationToday) { + metGoalsCount++; + + const baseValue = parseInt(goal.points || 0); + goalPoints = baseValue; + + if (goal.type === 'task-count') { + const extra = Math.max(0, result.current - goal.target); + if (extra > 0) { + goalPoints += extra * 2; + } + } + } else if (isActiveToday && !result.isMet && !isVacationToday) { + // PENALTY for missing a goal, unless vacation is recorded + goalPoints = -10; + } else { + // Not active today or vacation → neutral + goalPoints = 0; + } + + totalPoints += goalPoints; + results.push({ + id: goal.id, + name: goal.name, + isMet: isActiveToday ? result.isMet : false, + isActiveToday, + isVacationToday, + points: goalPoints, + target: goal.target, + current: result.current, + type: goal.type + }); + } + + // 2. "All Goals Met" Daily Bonus (only active goals count) + const allGoalsMet = activeGoalsCount > 0 && metGoalsCount === activeGoalsCount; + if (allGoalsMet) { + totalPoints += 100; + } + + // 3. Small Goal Count Multiplier (1–2 active goals) + if (activeGoalsCount > 0 && activeGoalsCount < 3) { + totalPoints = Math.floor(totalPoints * 1.5); + } + + // 4. Streak Bonus (Check Yesterday Only, using active goals) + if (allGoalsMet) { + const yesterday = date.clone().subtract(1, 'days'); + const yesterdayResults = await this.calculateDailyScoreSimple(yesterday, allTasks); + if (yesterdayResults.allMet) { + totalPoints += 50; + } + } + + // 5. Get Achievement Level for this day's score + const achievement = this.getAchievementLevel(totalPoints); + + const resultObj = { + date: isoKey, + totalPoints, + results, + allMet: allGoalsMet, + level: achievement.level, + levelTitle: achievement.title, + levelIcon: achievement.icon + }; + + // 6. Persist snapshot in cache for past days + if (canUseCache) { + if (!this.settings.goalScoreHistory) { + this.settings.goalScoreHistory = {}; + } + this.settings.goalScoreHistory[isoKey] = { + ...resultObj, + cachedAt: Date.now() + }; + } + + return resultObj; + } + + + // Simplified calculation for "Yesterday" to avoid infinite loops/deep recursion + async calculateDailyScoreSimple(date, allTasks) { + const activeGoals = (this.settings.goals || []).filter(g => !!g.enabled); + + if (activeGoals.length === 0) return { allMet: false }; + + let met = 0; + for (const goal of activeGoals) { + const res = await this.checkGoal(goal, date, allTasks); + if (res.isMet) met++; + } + return { allMet: met === activeGoals.length }; + } + + // --- HELPER TO GET DAILY NOTE PATH --- + getDailyNotePath(date, overrideFolder) { + let folder = overrideFolder || ''; + let format = 'YYYY-MM-DD'; + + // 1. Try Periodic Notes (Community Plugin) + // Attempt to detect Periodic Notes plugin (community plugin) + const periodicNotes = this.app.plugins.plugins['periodic-notes']; + if (periodicNotes && periodicNotes.settings?.daily?.enabled) { + const daily = periodicNotes.settings.daily; + if (!folder) folder = daily.folder || ''; + format = daily.format || 'YYYY-MM-DD'; + } + else { + // 2. Try Core Daily Notes Plugin + const dailyNotes = this.app.internalPlugins.getPluginById('daily-notes'); + if (dailyNotes && dailyNotes.instance && dailyNotes.instance.options) { + const options = dailyNotes.instance.options; + if (!folder) folder = options.folder || ''; + format = options.format || 'YYYY-MM-DD'; + } + } + + // 3. Fallback + if (!format) format = 'YYYY-MM-DD'; + + // 4. Construct Path + const filename = date.format(format); + const normalizedFolder = folder ? folder.trim().replace(/^\/|\/$/g, '') : ''; + const fullPath = normalizedFolder ? `${normalizedFolder}/${filename}.md` : `${filename}.md`; + + return fullPath; + } + + /** + * Checks if a daily note exists. + */ + async checkNoteCreated(date, folderPath) { + const path = this.getDailyNotePath(date, folderPath); + const file = this.app.vault.getAbstractFileByPath(path); + const exists = file instanceof TFile; + return { isMet: exists, current: exists ? 1 : 0 }; + } + + /** + * Checks word count in a daily note. + */ + async checkWordCount(date, folderPath, target) { + const path = this.getDailyNotePath(date, folderPath); + const file = this.app.vault.getAbstractFileByPath(path); + + if (!file || !(file instanceof TFile)) { + return { isMet: false, current: 0 }; + } + + const content = await this.app.vault.read(file); + const contentBody = content.replace(/^---[\s\S]+?---/, ''); // Strip frontmatter + const words = contentBody.trim().split(/\s+/).filter(w => w.length > 0).length; + + return { isMet: words >= target, current: words }; + } + + /** + * Checks completed task count. + */ + checkTaskCount(date, allTasks, tagQuery, target) { + if (!allTasks) return { isMet: false, current: 0 }; + + const targetDateStart = date.clone().startOf('day'); + const targetDateEnd = date.clone().endOf('day'); + + const completed = allTasks.filter(t => { + const isDone = t.status === 'x' || t.status?.type === 'DONE'; + if (!isDone) return false; + + const completionDate = t.done?.moment || (t.completionDate ? moment(t.completionDate) : null); + if (!completionDate) return false; + + const isSameDay = completionDate.isBetween(targetDateStart, targetDateEnd, null, '[]'); + + const matchesTag = tagQuery + ? (t.tags && t.tags.some(tag => tag.includes(tagQuery.replace('#', '')))) + : true; + + return isSameDay && matchesTag; + }); + + return { isMet: completed.length >= target, current: completed.length }; + } + + getAchievementLevel(points) { + const LEVEL_BANDS = [ + { frac: 1.00, title: "Grandmaster of Flow", icon: "infinity" }, + { frac: 0.90, title: "Legendary Leader", icon: "crown" }, + { frac: 0.80, title: "Relentless Result", icon: "trophy" }, + { frac: 0.70, title: "Task Titan", icon: "medal" }, + { frac: 0.60, title: "Output Overlord", icon: "award" }, + { frac: 0.50, title: "High Performer", icon: "star" }, + { frac: 0.40, title: "Productivity Pro", icon: "zap" }, + { frac: 0.30, title: "Goal Getter", icon: "target" }, + { frac: 0.22, title: "Focused Ninja", icon: "crosshair" }, + { frac: 0.16, title: "Efficiency Enthusiast", icon: "gauge" }, + { frac: 0.10, title: "Daily Driver", icon: "calendar-check" }, + { frac: 0.06, title: "Momentum Maker", icon: "trending-up" }, + { frac: 0.03, title: "Task Tinkerer", icon: "check-circle-2" }, + { frac: 0.01, title: "Routine Rookie", icon: "clipboard-list" }, + { frac: 0.00, title: "Starter Spark", icon: "sprout" } + ]; + + //const target = this.getLifetimeTargetPoints(); + const target = this.getLifetimeTargetPoints() || 300000; + if (!target) { + // Fallback: no goals yet + return { min: 0, title: "Starter Spark", level: 1, icon: "sprout" }; + } + + const levels = LEVEL_BANDS + .map((band, idx) => ({ + min: Math.round(band.frac * target), + title: band.title, + level: LEVEL_BANDS.length - idx, + icon: band.icon + })) + // IMPORTANT: sort from highest min to lowest + .sort((a, b) => b.min - a.min); + + // Find the highest band whose min you meet + const match = levels.find(l => points >= l.min); + return match || levels[levels.length - 1]; + + } + + static async getLifetimePoints(app, settings, allTasks) { + const history = settings.goalScoreHistory || {}; + let total = 0; + for (const dateKey in history) { + total += history[dateKey].totalPoints || 0; + } + return total; + } + + getLifetimeTargetPoints() { + const idealDaily = this.getIdealDailyMax(); + if (!idealDaily) return 0; + + // Assume a typical user hits ~80% of ideal on average + const typicalDaily = idealDaily * 0.8; + + const daysForMaxLevel = 3 * 365; // 3 years + return Math.round(typicalDaily * daysForMaxLevel); + } + + getIdealDailyMax() { + const goals = this.settings.goals || []; + if (goals.length === 0) return 0; + + // Sum configured points for all goals + let base = goals.reduce((acc, g) => acc + (parseInt(g.points || 0) || 0), 0); + + // Assume "all goals met" bonus + base += 100; + + // If user has only 1–2 goals, they still get the small multiplier + if (goals.length < 3) { + base = Math.floor(base * 1.5); + } + + // Assume they're in a streak on a good day + base += 50; + + return base; // this is "max-ish" points for a really good day + } +} diff --git a/src/main.js b/src/main.js index 2997b6b..96ad7c2 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,4 @@ -/* --cpwn-- Obsidian Plugin: Periodic Notes Calendar View --cpwn-- - Developer: Tim Williams - This plugin uses library: https://app.unpkg.com/ical.js@2.2.1/files/dist/ical.es5.min.cjs (c) 2024 Chris Lawley, MIT License -*/ +/* -- Obsidian Plugin: Calendar Periodic Week Notes -- */ import { Plugin, debounce } from 'obsidian'; import { PeriodMonthView, VIEW_TYPE_PERIOD } from './views/PeriodMonthView.js'; @@ -21,6 +18,50 @@ export default class PeriodMonthPlugin extends Plugin { async onload() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + + const CORE_GOALS = [ + { + id: 'core-daily-note', + name: 'Daily note created', + type: 'note-created', + target: 1, + points: 60, + core: true, + enabled: true + }, + { + id: 'core-words-written', + name: 'Words written', + type: 'word-count', + target: 200, + points: 24, + core: true, + enabled: true + }, + { + id: 'core-tasks-completed', + name: 'Tasks completed', + type: 'task-count', + target: 2, + points: 60, + core: true, + enabled: true + } + ]; + + // Make sure the goals property exists + if (!this.settings.goals || this.settings.goals.length === 0) { + this.settings.goals = [...CORE_GOALS]; + } + + // Always check and inject missing core goals before showing UI + CORE_GOALS.forEach(coreGoal => { + const found = this.settings.goals.some(g => g.id === coreGoal.id && g.core); + if (!found) { + this.settings.goals.unshift({ ...coreGoal }); + } + }); + this.updateStyles(); this.registerView(VIEW_TYPE_PERIOD, leaf => new PeriodMonthView(leaf, this)); this.addRibbonIcon("calendar-check", "Open Calendar Period Week Notes", () => this.activateView()); @@ -57,16 +98,6 @@ export default class PeriodMonthPlugin extends Plugin { await this.saveData(this.settings); } - /** - * Injects the plugin's CSS into the document head. - */ - addStyle() { - const styleEl = document.createElement('style'); - styleEl.id = 'cpwn-period-month-plugin-styles'; - styleEl.textContent = PLUGIN_STYLES; - document.head.appendChild(styleEl); - } - /** * Updates CSS custom properties (variables) based on the current plugin settings. */ @@ -192,7 +223,7 @@ export default class PeriodMonthPlugin extends Plugin { } async loadTemplates() { - + return Promise.resolve(BUNDLED_TEMPLATES); } diff --git a/src/modals/GoalEditModal.js b/src/modals/GoalEditModal.js new file mode 100644 index 0000000..6c57c0a --- /dev/null +++ b/src/modals/GoalEditModal.js @@ -0,0 +1,252 @@ +import { Modal, Setting, Notice, AbstractInputSuggest, TFolder } from 'obsidian'; + +// --- 1. Define FolderSuggest Helper Class --- +class FolderSuggest extends AbstractInputSuggest { + constructor(app, textInputEl) { + super(app, textInputEl); + this.app = app; + } + + getSuggestions(inputStr) { + const abstractFiles = this.app.vault.getAllLoadedFiles(); + const folders = []; + const lowerCaseInputStr = inputStr.toLowerCase(); + + for (const file of abstractFiles) { + if (file instanceof TFolder) { + if (file.path.toLowerCase().includes(lowerCaseInputStr)) { + folders.push(file); + } + } + } + return folders; + } + + renderSuggestion(file, el) { + el.setText(file.path); + } + + selectSuggestion(file) { + this.inputEl.value = file.path; + this.inputEl.trigger("input"); + this.close(); + } +} + +// --- 2. Your GoalEditModal Class --- +export class GoalEditModal extends Modal { + constructor(app, existingGoal, onSave) { + super(app); + this.goal = existingGoal || { + id: crypto.randomUUID(), + name: '', + type: 'task-count', + target: 1, + points: 10, + query: '', + folder: '', + activeDays: [0, 1, 2, 3, 4, 5, 6], + vacationMode: false + }; + + this.tempGoal = { ...this.goal }; + + // Ensure new fields exist on older goals + if (!Array.isArray(this.tempGoal.activeDays) || this.tempGoal.activeDays.length === 0) { + this.tempGoal.activeDays = [0, 1, 2, 3, 4, 5, 6]; + } + if (typeof this.tempGoal.vacationMode !== 'boolean') { + this.tempGoal.vacationMode = false; + } + + this.onSave = onSave; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl('h2', { text: this.goal.name ? 'Edit goal' : 'Create new goal' }); + + new Setting(contentEl) + .setDesc('If you change a folder location, #tags or goal values to achieve it\'s recommended to perform a \'Reset cache\' to recalculate your progress.') + + // 1. Goal Name + new Setting(contentEl) + .setName('Goal name') + .setDesc('e.g., "Morning writing" or "Daily tasks"') + .addText(text => text + .setValue(this.tempGoal.name) + .onChange((value) => { + this.tempGoal.name = value; + })); + + // 2. Goal Type Dropdown + new Setting(contentEl) + .setName('Goal type') + .addDropdown(drop => drop + .addOption('task-count', 'Daily tasks') + .addOption('note-created', 'Daily note created') + .addOption('word-count', 'Daily word count') + .setValue(this.tempGoal.type) + .onChange(value => { + this.tempGoal.type = value; + + // Set sensible defaults + if (value === 'note-created') { + this.tempGoal.target = 1; + } else if (value === 'word-count' && this.tempGoal.target < 100) { + this.tempGoal.target = 500; + } else if (value === 'task-count' && this.tempGoal.target > 50) { + this.tempGoal.target = 3; + } + + this.updatePointsPreview(); + this.display(); + })); + + // 3. Conditional Input: Tag Query + if (this.tempGoal.type === 'task-count') { + new Setting(contentEl) + .setName('Filter by tag (optional)') + .setDesc('Only count tasks with this tag (e.g., #gym). Leave blank for all tasks.') + .addText(text => text + .setPlaceholder('#tag') + .setValue(this.tempGoal.query) + .onChange(value => this.tempGoal.query = value)); + } + + // 4. Conditional Input: Folder Picker WITH DROPDOWN + if (this.tempGoal.type === 'word-count' || this.tempGoal.type === 'note-created') { + new Setting(contentEl) + .setName("Folder location (optional)") + .setDesc("Leave blank to use your default Daily Notes folder, or specify a folder.") + .addText(text => { + text.setPlaceholder("Example: Journals") + .setValue(this.tempGoal.folder || "") + .onChange(value => { + this.tempGoal.folder = value; + }); + + // --- ATTACH FOLDER SUGGESTER HERE --- + new FolderSuggest(this.app, text.inputEl); + }); + } + + // 5. Conditional Input: Target Value + if (this.tempGoal.type !== 'note-created') { + new Setting(contentEl) + .setName("Target value") + .setDesc(this.tempGoal.type === 'word-count' ? "How many words?" : "How many tasks?") + .addText(text => text + .setValue(String(this.tempGoal.target)) + .onChange((value) => { + this.tempGoal.target = Number(value) || 0; + this.updatePointsPreview(); + })); + } + + // 5.5 Active days picker + const daysSetting = new Setting(contentEl) + .setName("Active days") + .setDesc("Points and penalties only apply on these days."); + + const daysContainer = daysSetting.controlEl.createDiv({ + cls: 'cpwn-goal-days-picker' + }); + + const dayLabels = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + + dayLabels.forEach((label, idx) => { + const dayEl = daysContainer.createSpan({ + text: label, + cls: 'cpwn-goal-day' + }); + + if (this.tempGoal.activeDays.includes(idx)) { + dayEl.addClass('is-active'); + } + + dayEl.addEventListener('click', () => { + const active = this.tempGoal.activeDays; + const pos = active.indexOf(idx); + + if (pos === -1) { + active.push(idx); + dayEl.addClass('is-active'); + } else { + if (active.length > 1) { + active.splice(pos, 1); + dayEl.removeClass('is-active'); + } + } + }); + }); + + // 5.6 Vacation mode toggle + new Setting(contentEl) + .setName("Vacation mode") + .setDesc("When enabled, missed days for this goal give no penalties.") + .addToggle(toggle => { + toggle + .setValue(!!this.tempGoal.vacationMode) + .onChange((value) => { + this.tempGoal.vacationMode = value; + }); + }); + + // 6. Points Display + const pointsSetting = new Setting(contentEl) + .setName("Points reward details") + .setDesc("Points are calculated automatically based on your target."); + + this.pointsInfoEl = pointsSetting.controlEl.createDiv({ cls: 'cpwn-points-info' }); + this.pointsInfoEl.style.textAlign = 'right'; + this.pointsInfoEl.style.fontSize = '0.9em'; + this.pointsInfoEl.style.color = 'var(--text-accent)'; + + this.updatePointsPreview(); + + // 7. Save Button + new Setting(contentEl) + .addButton(btn => btn + .setButtonText("Save goal") + .setCta() + .onClick(() => { + if (!this.tempGoal.name) { + new Notice('Please enter a goal name.'); + return; + } + this.onSave(this.tempGoal); + this.close(); + })); + } + + updatePointsPreview() { + let points = 0; + let explanation = ""; + + if (this.tempGoal.type === 'note-created') { + points = 60; + explanation = "Reward: 60 pts\n(Penalty: -10 pts if missed)"; + } else if (this.tempGoal.type === 'task-count') { + const count = this.tempGoal.target || 0; + points = count * 30; + explanation = `${count} tasks × 30 pts = ${points} pts\n(+2 pts per extra task)\n(Penalty: -10 pts if target not met)`; + } else if (this.tempGoal.type === 'word-count') { + const count = this.tempGoal.target || 0; + points = Math.floor((count / 250) * 30); + explanation = `${count} words ≈ ${points} pts\n(30 pts per 250 words)\n(Penalty: -10 pts if target not met)`; + } + + if (this.pointsInfoEl) { + this.pointsInfoEl.innerText = explanation; + this.pointsInfoEl.style.whiteSpace = 'pre-line'; + } + + this.tempGoal.points = points; + } + + display() { + this.onOpen(); + } +} diff --git a/src/modals/SectionHeaderModal.js b/src/modals/SectionHeaderModal.js index ecb5a7a..76e5701 100644 --- a/src/modals/SectionHeaderModal.js +++ b/src/modals/SectionHeaderModal.js @@ -26,7 +26,7 @@ export class SectionHeaderModal extends Modal { : defaultConfig; } - async onOpen() { + onOpen() { const { contentEl } = this; contentEl.empty(); contentEl.createEl('h2', { text: 'Create section header' }); @@ -44,7 +44,7 @@ export class SectionHeaderModal extends Modal { new Setting(contentEl) .setName('Visible') - .setDesc('Toggle whether this section header is visible on the dashboard. Having the section header enabled but hidden allows you to use it for grouping widgets without affecting if a section header that\s above is being toggled to show and hide over time, i.e. this section of grouped widgets will always be visible.') + .setDesc('Toggle whether this section header is visible on the dashboard. Having the section header enabled but hidden allows you to use it for grouping widgets without affecting if a section header that\'s above is being toggled to show and hide over time, i.e. this section of grouped widgets will always be visible.') .addToggle(toggle => toggle .setValue(this.config.isVisible ?? true) // Default to true if not set .onChange(value => { diff --git a/src/modals/TemplatePickerModal.js b/src/modals/TemplatePickerModal.js index db123a2..6a30869 100644 --- a/src/modals/TemplatePickerModal.js +++ b/src/modals/TemplatePickerModal.js @@ -95,7 +95,7 @@ export class TemplatePickerModal extends Modal { ctx.clearRect(0, 0, width, height); // Clear canvas before drawing ctx.font = "48px sans-serif"; // Font size and family - ctx.fillStyle = template.previewData.datasets[0].color || "#4caf50";; // Text color (KPI color if you wish) + ctx.fillStyle = template.previewData.datasets[0].color || "#4caf50"; ctx.textAlign = "center"; // Center horizontally ctx.textBaseline = "middle"; // Center vertically diff --git a/src/modals/WidgetBuilderModal.js b/src/modals/WidgetBuilderModal.js index 40480f2..777bfe1 100644 --- a/src/modals/WidgetBuilderModal.js +++ b/src/modals/WidgetBuilderModal.js @@ -71,7 +71,7 @@ export class WidgetBuilderModal extends Modal { } // Clear and add the correct options - legendPositionDropdown.selectEl.innerHTML = ''; + llegendPositionDropdown.selectEl.empty(); legendPositionDropdown.addOptions(newOptions); // Reset position if the old one is invalid diff --git a/src/settings/PeriodSettingsTab.js b/src/settings/PeriodSettingsTab.js index 8046c46..d343d5f 100644 --- a/src/settings/PeriodSettingsTab.js +++ b/src/settings/PeriodSettingsTab.js @@ -14,6 +14,8 @@ import { PeriodMonthView } from '../views/PeriodMonthView.js'; import { WidgetBuilderModal } from '../modals/WidgetBuilderModal.js'; import { SectionHeaderModal } from '../modals/SectionHeaderModal.js'; import { TemplatePickerModal } from '../modals/TemplatePickerModal.js'; +import { GoalEditModal } from '../modals/GoalEditModal.js'; +import { GoalTracker } from '../logic/GoalTracker'; /** * The settings tab class for the plugin, responsible for building the settings UI. @@ -1103,6 +1105,7 @@ export class PeriodSettingsTab extends PluginSettingTab { assets: "Assets tab", tasks: "Tasks tab", dashboard: 'Dashboard tab', + goals: 'Goal configuration', themes: "Themes", importExport: "Import / Export", startHere: "Help", @@ -1252,6 +1255,9 @@ export class PeriodSettingsTab extends PluginSettingTab { case 'dashboard': this.renderDashboardSettings(); break; + case 'goals': + this.renderGoalsSettings(); + break; case 'themes': this.renderThemesSettings(); break; @@ -2404,6 +2410,296 @@ export class PeriodSettingsTab extends PluginSettingTab { tasksBtn.click(); } + renderGoalsSettings() { + const containerEl = this.contentEl; + containerEl.empty(); + containerEl.createEl("h1", { text: "Goal configuration" }); + containerEl.createEl("p", { text: "Define goals to track your progress and earn points. Drag to reorder." }); + + const goalsList = containerEl.createDiv({ cls: 'cpwn-goals-list' }); + this.enableGoalSorting(goalsList); + + const allGoals = this.plugin.settings.goals || []; + const coreGoals = allGoals.filter(g => g.core); + const customGoals = allGoals.filter(g => !g.core); + + // --- Render core goals first --- + coreGoals.forEach((goal, index) => + this.renderGoalItem(goalsList, goal, allGoals.indexOf(goal), true) + ); + + // --- Render Custom Goals --- + customGoals.forEach((goal, index) => { + this.renderGoalItem(goalsList, goal, allGoals.indexOf(goal), false); + }); + + // --- Add New Goal Button --- + new Setting(containerEl) + .addButton(btn => { + btn.setButtonText("Add new goal") + .setCta() + .onClick(() => { + new GoalEditModal(this.app, null, async (newGoal) => { + if (!this.plugin.settings.goals) this.plugin.settings.goals = []; + this.plugin.settings.goals.push(newGoal); + await this.saveAndUpdate(); + this.renderGoalsSettings(); + }).open(); + }); + }); + + + // --- Global Vacation Toggle --- + new Setting(containerEl) + .setName("Global Vacation Mode") + .setDesc("When enabled, all goals are paused — no points or penalties are tracked. Note: you can also pause goals individually. To do this enter the goal settings to toggle on/off.") + .addToggle(toggle => { + toggle.setValue(!!this.plugin.settings.vacationModeGlobal) + .onChange(async (value) => { + this.plugin.settings.vacationModeGlobal = value; + await this.saveAndUpdate(); + }); + }); + + + containerEl.createEl('h3', { text: 'Level ladder' }); + + const tracker = new GoalTracker(this.app, this.plugin.settings); + const lifetimeTargetRaw = + tracker.getLifetimeTargetPoints && tracker.getLifetimeTargetPoints(); + const effectiveTarget = + lifetimeTargetRaw && lifetimeTargetRaw > 0 ? lifetimeTargetRaw : 300000; + + // Same bands you use in the widget / GoalTracker + const LEVEL_BANDS = [ + { frac: 1.0, title: 'Grandmaster of Flow', icon: 'infinity' }, + { frac: 0.9, title: 'Legendary Leader', icon: 'crown' }, + { frac: 0.8, title: 'Relentless Result', icon: 'trophy' }, + { frac: 0.7, title: 'Task Titan', icon: 'medal' }, + { frac: 0.6, title: 'Output Overlord', icon: 'award' }, + { frac: 0.5, title: 'High Performer', icon: 'star' }, + { frac: 0.4, title: 'Productivity Pro', icon: 'zap' }, + { frac: 0.3, title: 'Goal Getter', icon: 'target' }, + { frac: 0.22, title: 'Focused Ninja', icon: 'crosshair' }, + { frac: 0.16, title: 'Efficiency Enthusiast', icon: 'gauge' }, + { frac: 0.10, title: 'Daily Driver', icon: 'calendar-check' }, + { frac: 0.06, title: 'Momentum Maker', icon: 'trending-up' }, + { frac: 0.03, title: 'Task Tinkerer', icon: 'check-circle-2' }, + { frac: 0.01, title: 'Routine Rookie', icon: 'clipboard-list' }, + { frac: 0.0, title: 'Starter Spark', icon: 'sprout' } + ]; + + const levels = LEVEL_BANDS + .map((band, idx) => ({ + min: Math.round(band.frac * effectiveTarget), + title: band.title, + icon: band.icon, + level: LEVEL_BANDS.length - idx + })) + // for display, show from lowest -> highest points + .sort((a, b) => a.min - b.min); + + const table = containerEl.createEl('table', { cls: 'cpwn-level-table' }); + + const headerRow = table.createEl('tr'); + headerRow.createEl('th', { text: 'Level' }); + headerRow.createEl('th', { text: 'Name' }); + headerRow.createEl('th', { text: 'Icon' }); + headerRow.createEl('th', { text: 'Points needed' }); + + for (const lvl of levels) { + const row = table.createEl('tr'); + + row.createEl('td', { text: String(lvl.level) }); + row.createEl('td', { text: lvl.title }); + + const iconCell = row.createEl('td'); + const iconDiv = iconCell.createDiv({ cls: 'cpwn-level-icon' }); + setIcon(iconDiv, lvl.icon); + + row.createEl('td', { text: lvl.min.toLocaleString() }); + } + + containerEl.createEl('div', { cls: 'cpwn-setting-spacer' }); + + // --- Reset Scoring Cache --- + new Setting(containerEl) + .setName("Reset scoring cache") + .setDesc("Clears cached daily scores and recomputes them from your tasks and notes. Vacation history is kept, but past totals may change.") + .addButton(btn => btn + .setButtonText("Reset cache") + .setWarning() + .onClick(async () => { + if (!confirm("Are you sure you want to reset the scoring cache? This will delete all cached daily scores and rebuild them from your tasks and notes.")) { + return; + } + this.plugin.settings.goalScoreHistory = {}; + await this.plugin.saveData(this.plugin.settings); + new Notice("Goal scoring cache cleared. It will be rebuilt as you view dashboards.", 4000); + this.triggerDashboardRefresh(); + })); + + // --- Reset Vacation History --- + new Setting(containerEl) + .setName("Reset vacation history") + .setDesc("Clears stored vacation days for all goals. After resetting the scoring cache, past days will be recomputed without vacation applied.") + .addButton(btn => btn + .setButtonText("Reset vacation days") + .setWarning() + .onClick(async () => { + if (!confirm("Are you sure you want to reset all vacation history? All days will be treated as normal working days on the next rebuild.")) { + return; + } + this.plugin.settings.vacationHistory = {}; + await this.plugin.saveData(this.plugin.settings); + new Notice("Vacation history cleared. Reset the scoring cache if you want past days recomputed without vacation.", 5000); + })); + } + + getGoalDisplayName(goal) { + // If the user named it "Tasks completed" (default), rename it to "Core: Tasks" + // Or check by type if you want to force it + if (goal.type === 'task-count') return "Task count"; + if (goal.type === 'word-count') return "Words count"; + if (goal.type === 'note-created') return "Create note"; + + // Fallback for custom names + return goal.type; + } + + renderGoalItem(goalsList, goal, index, isCore) { + const goalEl = goalsList.createDiv({ cls: 'cpwn-goal-item setting-item' }); + goalEl.setAttribute('draggable', isCore ? 'false' : 'true'); + goalEl.dataset.index = index; + + // Drag handle (disabled for core) + const handle = goalEl.createDiv({ cls: 'cpwn-pm-drag-handle' }); + setIcon(handle, 'grip-vertical'); + handle.style.marginRight = '10px'; + handle.style.cursor = isCore ? 'not-allowed' : 'grab'; + if (isCore) handle.style.opacity = '0.5'; + + const info = goalEl.createDiv({ cls: 'setting-item-info' }); + + + + info.createEl('div', { text: goal.name, cls: 'setting-item-name' }); + info.createEl('div', { + text: `${this.getGoalDisplayName(goal)} | Target: ${goal.target} | ${goal.points} pts`, + cls: 'setting-item-description' + }); + + const controls = goalEl.createDiv({ cls: 'setting-item-control' }); + + // EDIT (always) + new Setting(controls) + .addButton(btn => btn + .setIcon('pencil') + .onClick(() => { + new GoalEditModal(this.app, goal, async (updatedGoal) => { + this.plugin.settings.goals[index] = updatedGoal; + await this.saveAndUpdate(); + this.renderGoalsSettings(); + }).open(); + })); + + // TOGGLE (always) + new Setting(controls) + .addToggle(toggle => { + toggle.setValue(!!goal.enabled) + .onChange(async value => { + goal.enabled = value; + await this.saveAndUpdate(); + this.renderGoalsSettings(); + }); + }); + + // DELETE (only for custom) + if (!isCore) { + new Setting(controls) + .addButton(btn => btn + .setIcon('trash') + .setWarning() + .onClick(async () => { + if (confirm(`Delete goal "${goal.name}"?`)) { + this.plugin.settings.goals.splice(index, 1); + await this.saveAndUpdate(); + this.renderGoalsSettings(); + } + })); + } + + } + + // Helper method to handle Drag & Drop logic + enableGoalSorting(container) { + let draggedItem = null; + + container.addEventListener('dragstart', (e) => { + draggedItem = e.target.closest('.cpwn-goal-item'); + if (draggedItem) { + draggedItem.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + // Firefox requires data to be set + e.dataTransfer.setData('text/plain', draggedItem.dataset.index); + } + }); + + container.addEventListener('dragend', async (e) => { + if (draggedItem) { + draggedItem.classList.remove('dragging'); + + // Update settings based on DOM order + const newOrder = []; + const children = Array.from(container.children); + + children.forEach(child => { + const oldIndex = parseInt(child.dataset.index); + if (!isNaN(oldIndex) && this.plugin.settings.goals[oldIndex]) { + newOrder.push(this.plugin.settings.goals[oldIndex]); + } + }); + + this.plugin.settings.goals = newOrder; + await this.saveAndUpdate(); + + // Re-render to update indices + this.renderGoalsSettings(); + } + draggedItem = null; + }); + + container.addEventListener('dragover', (e) => { + e.preventDefault(); + const afterElement = this.getDragAfterElement(container, e.clientY); + if (draggedItem) { + if (afterElement == null) { + container.appendChild(draggedItem); + } else { + container.insertBefore(draggedItem, afterElement); + } + } + }); + + + } + + // Reusing your existing helper, or defining it here if it's private + getDragAfterElement(container, y) { + const draggableElements = [...container.querySelectorAll('.cpwn-goal-item:not(.dragging)')]; + + return draggableElements.reduce((closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) { + return { offset: offset, element: child }; + } else { + return closest; + } + }, { offset: Number.NEGATIVE_INFINITY }).element; + } + + renderGeneralDashboardSettings(container) { container.empty(); diff --git a/src/utils/dateUtils.js b/src/utils/dateUtils.js index eb4567f..fe32569 100644 --- a/src/utils/dateUtils.js +++ b/src/utils/dateUtils.js @@ -1,5 +1,5 @@ // src/utils/dateUtils.js -import moment from 'moment'; +import { moment } from 'obsidian'; /** * Calculates the custom period and week number based on a given start date. diff --git a/src/utils/suggesters.js b/src/utils/suggesters.js index 157afa4..99359cc 100644 --- a/src/utils/suggesters.js +++ b/src/utils/suggesters.js @@ -10,10 +10,13 @@ export class TagSuggest extends AbstractInputSuggest { this.inputEl = inputEl; this.view = view; this.allTags = new Set(); + + this.refreshAllTags(); + } getSuggestions(query) { - this.refreshAllTags(); + //this.refreshAllTags(); // If the query is empty, show all tags, sorted alphabetically. if (!query) { diff --git a/src/views/PeriodMonthView.js b/src/views/PeriodMonthView.js index e3ee40f..894c9f3 100644 --- a/src/views/PeriodMonthView.js +++ b/src/views/PeriodMonthView.js @@ -33,12 +33,13 @@ import { // Widgets import { CssChartRenderer } from '../widgets/CssChartRenderer.js'; -import { TaskDataAggregator } from '../widgets/TaskDataAggregator.js'; // Modals import { ActionChoiceModal } from '../modals/ActionChoiceModal.js'; import { ConfirmationModal } from '../modals/ConfirmationModal.js'; import { FilterSuggest, TagSuggest } from '../utils/suggesters.js'; +import { GoalDataAggregator } from '../widgets/GoalDataAggregator'; +import { GoalTracker } from "../logic/GoalTracker.js"; export { VIEW_TYPE_PERIOD }; @@ -63,14 +64,8 @@ export class PeriodMonthView extends ItemView { this.fileToHeatmapCache = new Map(); this.statusBarObserver = null; this.heatmapRefreshDebounceTimer = null; - - //pull down to fresh - this.taskPullStartY = 0; - this.taskPullDistance = 0; - this.isTaskPulling = false; this.lastTasksDashboardState = ''; // Used to detect changes in tasks for heatmap refreshes. - this.mobileBlurTimeout = null; this.activeEditingTimeout = null; this.isActivelyEditing = false; @@ -2601,10 +2596,7 @@ export class PeriodMonthView extends ItemView { this.refreshUI({ updateType: 'creation', file }); }, 200); } - })); - - - + })) // Handles CREATING a new file this.registerEvent(this.app.vault.on("create", (file) => { @@ -2713,59 +2705,7 @@ export class PeriodMonthView extends ItemView { // This re-uses the same helper function from the swipe fix. this.blurActiveInput(); } - }, true); // 'true' enables the capture phase, which is crucial for reliability. - - - const PULL_THRESHOLD = 70; // How far the user needs to pull down to trigger a refresh - - const resetPullStyles = () => { - this.taskRefreshIndicator.removeClass('is-ready', 'is-pulling', 'is-refreshing'); - - this.tasksContentEl.classList.add('cpwn-pull-indicator'); - this.tasksContentEl.style.setProperty('--pull-transform', 'translateY(0px)'); - this.tasksContentEl.style.setProperty('--pull-transform', 'translateY(-50px) scale(0.7)'); - - setIcon(this.taskRefreshIndicator, 'arrow-down'); - }; - - this.registerDomEvent(this.tasksContentEl, 'touchstart', (e) => { - if (this.tasksContentEl.scrollTop === 0) { - this.isTaskPulling = true; - this.taskPullStartY = e.touches[0].clientY; - } - }, { passive: false }); - - this.registerDomEvent(this.tasksContentEl, 'touchmove', (e) => { - if (!this.isTaskPulling) return; - - const currentY = e.touches[0].clientY; - // We only care about the downward pull distance from the start - this.taskPullDistance = Math.max(0, currentY - this.taskPullStartY); - - // Only interfere if the list is scrolled to the top AND the user is pulling down. - if (this.tasksContentEl.scrollTop === 0 && this.taskPullDistance > 0) { - // Prevent the browser from scrolling the page - e.preventDefault(); - - const pullRatio = Math.min(this.taskPullDistance, PULL_THRESHOLD); - this.tasksContentEl.style.transform = `translateY(${pullRatio}px)`; - - this.taskRefreshIndicator.addClass('is-pulling'); - this.taskRefreshIndicator.style.transform = `translateY(${pullRatio - 50}px) scale(0.7)`; - - if (this.taskPullDistance > PULL_THRESHOLD) { - this.taskRefreshIndicator.addClass('is-ready'); - setIcon(this.taskRefreshIndicator, 'check'); - } else { - this.taskRefreshIndicator.removeClass('is-ready'); - setIcon(this.taskRefreshIndicator, 'arrow-down'); - } - } else { - // If we've started scrolling down the list, stop tracking the pull - this.isTaskPulling = false; - } - - }, { passive: false }); + }, true) // 'true' enables the capture phase, which is crucial for reliability. this.app.workspace.onLayoutReady(() => { const statusBar = document.querySelector('.status-bar'); @@ -2777,34 +2717,7 @@ export class PeriodMonthView extends ItemView { this.statusBarObserver.observe(statusBar); this.updateDynamicPadding(); // Run once on initial load } - }); - - this.registerDomEvent(this.tasksContentEl, 'touchend', async (e) => { - if (!this.isTaskPulling) return; - this.isTaskPulling = false; - - if (this.taskPullDistance > PULL_THRESHOLD) { - - this.blurActiveInput(); - // Trigger the refresh - this.taskRefreshIndicator.addClass('is-refreshing', 'is-ready'); - setIcon(this.taskRefreshIndicator, 'loader'); - - await this.populateTasks(); - - // Reset styles after refresh is complete - window.setTimeout(() => { - resetPullStyles(); - }, 300); // Small delay for user to see the end of the spin - - } else { - // Snap back if not pulled far enough - resetPullStyles(); - } - - this.taskPullDistance = 0; - - }); + }) this.scheduleDailyRefresh(); } @@ -6964,6 +6877,1140 @@ export class PeriodMonthView extends ItemView { await this.populateSingleHeatmapWidget(widgetContainer, "Task completion activity", completionMap, gradient, { completionMap }, 'taskcompletionheatmap'); break; } + + case 'goalStatusListCondensed': { + // 1. Create Container (Medium/Half-Width) + const widgetContainer = widgetGrid.createDiv({ + cls: 'cpwn-pm-widget-container cpwn-pm-widget-small' + }); + + // 2. Determine Settings + const showTitle = typeof this.plugin.settings.showTaskWidgetTitles === 'boolean' ? this.plugin.settings.showTaskWidgetTitles : true; + const showChevron = typeof this.plugin.settings.showTaskWidgetChevrons === 'boolean' ? this.plugin.settings.showTaskWidgetChevrons : true; + + // 3. Check Collapsed State + if (!this.plugin.settings.collapsedWidgets) this.plugin.settings.collapsedWidgets = {}; + const isCollapsed = this.plugin.settings.collapsedWidgets[widgetKey] || false; + if (isCollapsed) widgetContainer.addClass('collapsed'); + + // 4. Render Header + const header = widgetContainer.createDiv({ cls: 'cpwn-pm-widget-header' }); + const headerTitle = header.createDiv({ cls: 'cpwn-pm-widget-header-title' }); + + let toggleIcon = null; + if (showChevron) { + toggleIcon = headerTitle.createDiv({ cls: 'cpwn-widget-toggle-icon' }); + setIcon(toggleIcon, isCollapsed ? 'chevron-right' : 'chevron-down'); + } + + if (showTitle) { + header.createDiv({ cls: 'cpwn-widget-title' }).setText('Daily goals'); + } + + // 5. Toggle Logic + header.addEventListener('click', async () => { + const currentlyCollapsed = widgetContainer.classList.toggle('collapsed'); + this.plugin.settings.collapsedWidgets[widgetKey] = currentlyCollapsed; + await this.plugin.saveSettings(); + if (showChevron && toggleIcon) setIcon(toggleIcon, currentlyCollapsed ? 'chevron-right' : 'chevron-down'); + }); + + // 6. Render Content + const contentWrapper = widgetContainer.createDiv({ cls: 'cpwn-widget-content' }); + + // --- DATA FETCHING --- + const goals = await GoalDataAggregator.getTodaysGoals(this.app, this.plugin.settings, this.allTasks); + const historySettings = { ...this.plugin.settings, vacationModeGlobal: false }; + const rawHistory = await GoalDataAggregator.getPointsHistory(this.app, historySettings, this.allTasks); + + let calculatedTotal = 0; + if (Array.isArray(rawHistory)) { + calculatedTotal = rawHistory.reduce((acc, day) => acc + (day.totalPoints || 0), 0); + } else if (rawHistory && rawHistory.datasets && rawHistory.datasets[0]) { + calculatedTotal = rawHistory.datasets[0].data.reduce((acc, val) => acc + (Number(val) || 0), 0); + } + const totalPoints = calculatedTotal; + + // --- LEVEL CALCULATION --- + const trackerForLevels = new GoalTracker(this.app, this.plugin.settings); + const lifetimeTargetRaw = trackerForLevels.getLifetimeTargetPoints && trackerForLevels.getLifetimeTargetPoints(); + const effectiveTarget = lifetimeTargetRaw && lifetimeTargetRaw > 0 ? lifetimeTargetRaw : 300000; + + const LEVEL_BANDS = [ + { frac: 1.0, title: 'Grandmaster of Flow', icon: 'infinity' }, + { frac: 0.9, title: 'Legendary Leader', icon: 'crown' }, + { frac: 0.8, title: 'Relentless Result', icon: 'trophy' }, + { frac: 0.7, title: 'Task Titan', icon: 'medal' }, + { frac: 0.6, title: 'Output Overlord', icon: 'award' }, + { frac: 0.5, title: 'High Performer', icon: 'star' }, + { frac: 0.4, title: 'Productivity Pro', icon: 'zap' }, + { frac: 0.3, title: 'Goal Getter', icon: 'target' }, + { frac: 0.22, title: 'Focused Ninja', icon: 'crosshair' }, + { frac: 0.16, title: 'Efficiency Enthusiast', icon: 'gauge' }, + { frac: 0.10, title: 'Daily Driver', icon: 'calendar-check' }, + { frac: 0.06, title: 'Momentum Maker', icon: 'trending-up' }, + { frac: 0.03, title: 'Task Tinkerer', icon: 'check-circle-2' }, + { frac: 0.01, title: 'Routine Rookie', icon: 'clipboard-list' }, + { frac: 0.0, title: 'Starter Spark', icon: 'sprout' } + ]; + + const levelsDesc = LEVEL_BANDS.map((band, idx) => ({ + min: Math.round(band.frac * effectiveTarget), + title: band.title, + icon: band.icon, + level: LEVEL_BANDS.length - idx + })).sort((a, b) => b.min - a.min); + + const currentLevel = levelsDesc.find(l => Number(totalPoints) >= l.min) || levelsDesc[levelsDesc.length - 1]; + const currentIndex = levelsDesc.indexOf(currentLevel); + const nextLevel = currentIndex > 0 ? levelsDesc[currentIndex - 1] : { min: currentLevel.min * 1.5, title: 'Max Level' }; + const percentage = Math.min(100, Math.max(0, ((totalPoints - currentLevel.min) / (nextLevel.min - currentLevel.min)) * 100)); + + // --- GENERATE PROJECTION TEXT & HEADLINE (Required for Tooltip) --- + let projectionText = ''; + { + let dailyAverage = 0; + if (Array.isArray(rawHistory) && rawHistory.length > 0) { + const recent = rawHistory.slice(-7); + const sum = recent.reduce((acc, d) => acc + (d.totalPoints||0), 0); + dailyAverage = sum / (recent.length || 1); + } else if (rawHistory && rawHistory.datasets && rawHistory.datasets[0]) { + // Quick check for chart data format too + const d = rawHistory.datasets[0].data || []; + if (d.length >= 2) { + // Basic diff logic if needed, or just take the last value if cumulative + // Assuming simple average for now to match logic + dailyAverage = Number(d[d.length-1]) || 0; + } + } + + const pointsRemaining = Math.max(0, nextLevel.min - totalPoints); + if (dailyAverage > 0) { + const daysToNext = Math.ceil(pointsRemaining / dailyAverage); + const projectedDate = moment().add(daysToNext, 'days').format('MMM Do'); + const avgRounded = Math.round(dailyAverage); + + let headline; + if (avgRounded < 75) { + headline = `Nice and steady. Averaging ~${avgRounded} pts/day.`; + } else if (avgRounded < 175) { + headline = `Good momentum! Averaging ~${avgRounded} pts/day.`; + } else if (avgRounded < 275) { + headline = `You're on fire! Averaging ~${avgRounded} pts/day.`; + } else { + headline = `Absolutely crushing it at ~${avgRounded} pts/day.`; + } + + const paceLine = projectedDate ? `At this pace, you'll level up on ${projectedDate}.` : `Keep going—every day moves you closer.`; + const distanceLine = pointsRemaining > 0 ? `You're only ${pointsRemaining.toLocaleString()} points away!` : `You've already reached this tier—enjoy the win!`; + + projectionText = + `Current: ${totalPoints.toLocaleString()} pts\n` + + `Next: ${nextLevel.title} (${nextLevel.min.toLocaleString()} pts)\n\n` + + `${headline}\n` + + `${paceLine}\n` + + `${distanceLine}`; + } else { + projectionText = `Current: ${totalPoints.toLocaleString()} pts\nNext: ${nextLevel.title}\n\nComplete some tasks to see your estimated level-up date!`; + } + } + + // --- RENDER CONDENSED LAYOUT --- + const splitContainer = contentWrapper.createDiv({ cls: 'cpwn-goal-split-container condensed' }); + + // Left Summary + const listEl = splitContainer.createDiv({ cls: 'cpwn-goal-list-left' }); + + if (this.plugin.settings.vacationModeGlobal) { + // Show Vacation Icon + const iconContainer = listEl.createDiv({ + cls: 'cpwn-summary-icon-rest' // Reuse the same large icon class + }); + setIcon(iconContainer, 'plane'); // Palm tree icon + + listEl.createDiv({ + cls: 'cpwn-summary-sub', + text: 'On Vacation' + }); + } else { + // 1. Analyze Active vs Off goals + const totalGoals = goals.length; + const offGoalsCount = goals.filter(g => !g.isActiveToday).length; + const activeGoalsCount = totalGoals - offGoalsCount; + + // LEFT: Summary Text + const listEl = splitContainer.createDiv({ cls: 'cpwn-goal-list-left' }); + + if (this.plugin.settings.vacationModeGlobal) { + // Show Vacation Icon + const iconContainer = listEl.createDiv({ + cls: 'cpwn-summary-icon-rest' // Reuse the same large icon class + }); + setIcon(iconContainer, 'tree-plam'); // Palm tree icon + + listEl.createDiv({ + cls: 'cpwn-summary-sub', + text: 'On Vacation' + }); + } else { + const totalGoals = goals.length; + const offGoalsCount = goals.filter(g => !g.isActiveToday).length; + const metCount = goals.filter(g => g.isActiveToday && g.isMet).length; + + if (offGoalsCount === totalGoals && totalGoals > 0) { + // CASE A: Rest Day (All Off) -> Show Coffee Icon + const iconContainer = listEl.createDiv({ + cls: 'cpwn-summary-icon-rest' + }); + setIcon(iconContainer, 'coffee'); // Uses the standard Obsidian 'coffee' icon + + listEl.createDiv({ + cls: 'cpwn-summary-sub', + text: 'All goals off' + }); + + } else { + // CASE B: Normal Counting + listEl.createDiv({ + cls: 'cpwn-summary-large', + text: `${metCount}/${totalGoals}` + }); + + // Dynamic Subtext + let subText = 'goals met'; + if (offGoalsCount > 0) { + subText = `${offGoalsCount} off today`; + } + + listEl.createDiv({ + cls: 'cpwn-summary-sub', + text: subText + }); + } + } + + } + + // Right Ring + const levelCol = splitContainer.createDiv({ cls: 'cpwn-goal-level-right' }); + const size = 60; + const stroke = 4; + const r = (size - stroke) / 2; + const circ = 2 * Math.PI * r; + const offset = circ - (percentage / 100) * circ; + + const svgWrapper = levelCol.createDiv({ cls: 'cpwn-level-circle-wrapper' }); + const svgNs = 'http://www.w3.org/2000/svg'; + const svgEl = document.createElementNS(svgNs, 'svg'); + svgEl.setAttribute('width', String(size)); + svgEl.setAttribute('height', String(size)); + svgEl.style.transform = 'rotate(-90deg)'; + + const bgCircle = document.createElementNS(svgNs, 'circle'); + bgCircle.setAttribute('cx', String(size/2)); bgCircle.setAttribute('cy', String(size/2)); bgCircle.setAttribute('r', String(r)); + bgCircle.setAttribute('stroke', 'var(--background-modifier-border)'); bgCircle.setAttribute('stroke-width', String(stroke)); bgCircle.setAttribute('fill', 'none'); + svgEl.appendChild(bgCircle); + + const progCircle = document.createElementNS(svgNs, 'circle'); + progCircle.setAttribute('cx', String(size/2)); progCircle.setAttribute('cy', String(size/2)); progCircle.setAttribute('r', String(r)); + progCircle.setAttribute('stroke', 'var(--text-accent)'); progCircle.setAttribute('stroke-width', String(stroke)); progCircle.setAttribute('fill', 'none'); + progCircle.style.strokeDasharray = `${circ}`; progCircle.style.strokeDashoffset = `${offset}`; + svgEl.appendChild(progCircle); + svgWrapper.appendChild(svgEl); + + const iconInner = svgWrapper.createDiv({ cls: 'cpwn-level-icon-inner' }); + setIcon(iconInner, currentLevel.icon || 'sprout'); + levelCol.createDiv({ text: currentLevel.title, cls: 'cpwn-level-title-text' }); + + // --- FULL TOOLTIP LOGIC (With Mini-Ring and Styling) --- + let goalTooltipEl = null; + + const buildTooltipContent = () => { + // CLEANUP + document.querySelectorAll('.cpwn-goal-tooltip').forEach(el => el.remove()); + + if (!goalTooltipEl) { + goalTooltipEl = document.body.createDiv({ cls: 'cpwn-goal-tooltip' }); + } + goalTooltipEl.empty(); + + const header = goalTooltipEl.createDiv({ cls: 'cpwn-goal-tooltip-header' }); + + // Mini Ring in Tooltip + const ringWrapper = header.createDiv({ cls: 'cpwn-goal-tooltip-ring-wrapper' }); + const miniSize = 28; const miniStroke = 3; + const miniR = (miniSize - miniStroke) / 2; + const miniCirc = 2 * Math.PI * miniR; + const miniOffset = miniCirc - (percentage / 100) * miniCirc; + + const miniSvg = document.createElementNS(svgNs, 'svg'); + miniSvg.setAttribute('width', String(miniSize)); miniSvg.setAttribute('height', String(miniSize)); + miniSvg.style.transform = 'rotate(-90deg)'; + + const miniBg = document.createElementNS(svgNs, 'circle'); + miniBg.setAttribute('cx', String(miniSize/2)); miniBg.setAttribute('cy', String(miniSize/2)); + miniBg.setAttribute('r', String(miniR)); miniBg.setAttribute('stroke', 'var(--background-modifier-border)'); + miniBg.setAttribute('stroke-width', String(miniStroke)); miniBg.setAttribute('fill', 'var(--background-primary)'); + miniSvg.appendChild(miniBg); + + const miniProg = document.createElementNS(svgNs, 'circle'); + miniProg.setAttribute('cx', String(miniSize/2)); miniProg.setAttribute('cy', String(miniSize/2)); + miniProg.setAttribute('r', String(miniR)); miniProg.setAttribute('stroke', 'var(--text-accent)'); + miniProg.setAttribute('stroke-width', String(miniStroke)); miniProg.setAttribute('fill', 'none'); + miniProg.style.strokeDasharray = `${miniCirc}`; miniProg.style.strokeDashoffset = `${miniOffset}`; + miniSvg.appendChild(miniProg); + ringWrapper.appendChild(miniSvg); + + const ringIcon = ringWrapper.createDiv({ cls: 'cpwn-goal-tooltip-ring-icon' }); + setIcon(ringIcon, currentLevel.icon || 'sprout'); + + header.createDiv({ cls: 'cpwn-goal-tooltip-title', text: currentLevel.title }); + + const body = goalTooltipEl.createDiv({ cls: 'cpwn-goal-tooltip-body' }); + + // Render full projection text lines + projectionText.split('\n').forEach(line => { + const trimmed = line.trim(); + if (!trimmed) { + body.createDiv({ cls: 'cpwn-goal-tooltip-spacer' }); + return; + } + if (trimmed.startsWith('At this pace')) { + body.createDiv({ cls: 'cpwn-goal-tooltip-spacer-large' }); + } + + const isEmphasis = trimmed.startsWith('Current:') || trimmed.startsWith('Next:') || trimmed.startsWith('Nice and steady') || trimmed.startsWith('Good momentum') || trimmed.startsWith("You're on fire") || trimmed.startsWith('Absolutely crushing'); + + body.createDiv({ + cls: isEmphasis ? 'cpwn-goal-tooltip-line-strong' : 'cpwn-goal-tooltip-line', + text: trimmed + }); + }); + }; + + const positionTooltip = (evt) => { + if (!goalTooltipEl) return; + const padding = 12; + let x = evt.clientX + padding; + let y = evt.clientY + padding; + + // Measure tooltip + const rect = goalTooltipEl.getBoundingClientRect(); + if (x + rect.width + padding > window.innerWidth) x = evt.clientX - rect.width - padding; + if (y + rect.height + padding > window.innerHeight) y = evt.clientY - rect.height - padding; + + goalTooltipEl.style.left = `${x}px`; + goalTooltipEl.style.top = `${y}px`; + }; + + svgWrapper.addEventListener('mouseenter', (evt) => { + buildTooltipContent(); + requestAnimationFrame(() => { + if (!goalTooltipEl) return; + goalTooltipEl.style.opacity = '1'; + goalTooltipEl.style.pointerEvents = 'none'; + positionTooltip(evt); + }); + }); + + svgWrapper.addEventListener('mousemove', (evt) => { + positionTooltip(evt); + }); + + svgWrapper.addEventListener('mouseleave', () => { + if (goalTooltipEl) { + goalTooltipEl.style.opacity = '0'; + goalTooltipEl.remove(); + goalTooltipEl = null; + } + }); + + break; + } + + + + case 'weeklyGoalPointsCondensed': { + // 1. Create Container (Medium/Half-Width) + const widgetContainer = widgetGrid.createDiv({ + cls: 'cpwn-pm-widget-container cpwn-pm-widget-small' + }); + + // 2. Determine Settings + const showTitle = + typeof this.plugin.settings.showTaskWidgetTitles === 'boolean' + ? this.plugin.settings.showTaskWidgetTitles + : true; + const showChevron = + typeof this.plugin.settings.showTaskWidgetChevrons === 'boolean' + ? this.plugin.settings.showTaskWidgetChevrons + : true; + + // 3. Check Collapsed State + if (!this.plugin.settings.collapsedWidgets) { + this.plugin.settings.collapsedWidgets = {}; + } + const isCollapsed = this.plugin.settings.collapsedWidgets[widgetKey] || false; + if (isCollapsed) widgetContainer.addClass('collapsed'); + + // 4. Render Header + const header = widgetContainer.createDiv({ cls: 'cpwn-pm-widget-header' }); + const headerTitle = header.createDiv({ cls: 'cpwn-pm-widget-header-title' }); + + let toggleIcon = null; + if (showChevron) { + toggleIcon = headerTitle.createDiv({ cls: 'cpwn-widget-toggle-icon' }); + setIcon(toggleIcon, isCollapsed ? 'chevron-right' : 'chevron-down'); + } + + if (showTitle) { + header.createDiv({ cls: 'cpwn-widget-title' }).setText('Weekly momentum'); + } + + // 5. Toggle Logic + header.addEventListener('click', async () => { + const currentlyCollapsed = widgetContainer.classList.toggle('collapsed'); + this.plugin.settings.collapsedWidgets[widgetKey] = currentlyCollapsed; + await this.plugin.saveSettings(); + if (showChevron && toggleIcon) { + setIcon(toggleIcon, currentlyCollapsed ? 'chevron-right' : 'chevron-down'); + } + }); + + // 6. Render Content + const contentWrapper = widgetContainer.createDiv({ + cls: 'cpwn-widget-content' + }); + + // --- DATA FETCHING LOGIC (Identical to full widget) --- + const beforeCount = + Object.keys(this.plugin.settings.goalScoreHistory || {}).length; + + // FIX: Force vacation mode OFF for history retrieval + const historySettings = { ...this.plugin.settings, vacationModeGlobal: false }; + + const fullHistory = await GoalDataAggregator.getPointsHistory( + this.app, + historySettings, + this.allTasks + ); + + const afterCount = + Object.keys(this.plugin.settings.goalScoreHistory || {}).length; + if (afterCount > beforeCount) { + await this.plugin.saveSettings(); + } + + let lifetimeTotal = 0; + if (Array.isArray(fullHistory)) { + lifetimeTotal = fullHistory.reduce( + (acc, day) => acc + (day.totalPoints || 0), + 0 + ); + } else if ( + fullHistory && + fullHistory.datasets && + fullHistory.datasets[0] && + Array.isArray(fullHistory.datasets[0].data) + ) { + lifetimeTotal = fullHistory.datasets[0].data.reduce( + (acc, val) => acc + (Number(val) || 0), + 0 + ); + } + + // Generate Weekly Data + const labels = []; + const dataPoints = []; + + // FIX: Initialize GoalTracker with 'historySettings' + const tracker = new GoalTracker(this.app, historySettings); + + let currentRunningTotal = lifetimeTotal; + const today = moment(); + + for (let i = 0; i < 7; i++) { + const date = today.clone().subtract(i, 'days'); + + labels.unshift(date.format('dd')); + dataPoints.unshift(currentRunningTotal); + + const dailyRes = await tracker.calculateDailyScore(date, this.allTasks); + currentRunningTotal -= dailyRes.totalPoints; + } + + // Construct chartDataObj + const chartDataObj = { + labels: labels, + datasets: [ + { + label: 'Weekly Momentum', + data: dataPoints, + borderColor: 'var(--text-accent)', + backgroundColor: 'rgba(var(--text-accent-rgb), 0.1)', + borderWidth: 2, + fill: true, + tension: 0.4, + pointRadius: 3 + } + ] + }; + + // 7. Render (The Chart Renderer will adapt to the narrower 'medium' container) + const renderer = new CssChartRenderer(contentWrapper); + renderer.renderWeeklyGoalMomentum(chartDataObj, { labelClass: 'cpwn-axis-label-large' }); + + break; + } + + + case 'weeklyGoalPoints': + case 'goalStatusList': { + + // 1. Create Container + const widgetContainer = widgetGrid.createDiv({ + cls: 'cpwn-pm-widget-container cpwn-pm-widget-medium' + }); + + // 2. Determine Settings + const showTitle = + typeof this.plugin.settings.showTaskWidgetTitles === 'boolean' + ? this.plugin.settings.showTaskWidgetTitles + : true; + const showChevron = + typeof this.plugin.settings.showTaskWidgetChevrons === 'boolean' + ? this.plugin.settings.showTaskWidgetChevrons + : true; + + // 3. Check Collapsed State + if (!this.plugin.settings.collapsedWidgets) { + this.plugin.settings.collapsedWidgets = {}; + } + const isCollapsed = this.plugin.settings.collapsedWidgets[widgetKey] || false; + if (isCollapsed) widgetContainer.addClass('collapsed'); + + // 4. Render Header + const header = widgetContainer.createDiv({ cls: 'cpwn-pm-widget-header' }); + const headerTitle = header.createDiv({ cls: 'cpwn-pm-widget-header-title' }); + + let toggleIcon = null; + if (showChevron) { + toggleIcon = headerTitle.createDiv({ cls: 'cpwn-widget-toggle-icon' }); + setIcon(toggleIcon, isCollapsed ? 'chevron-right' : 'chevron-down'); + } + + if (showTitle) { + const titleText = + widgetKey === 'weeklyGoalPoints' ? 'Weekly momentum' : 'Daily goals'; + header.createDiv({ cls: 'cpwn-widget-title' }).setText(titleText); + } + + // 5. Toggle Logic + header.addEventListener('click', async () => { + const currentlyCollapsed = widgetContainer.classList.toggle('collapsed'); + this.plugin.settings.collapsedWidgets[widgetKey] = currentlyCollapsed; + await this.plugin.saveSettings(); + if (showChevron && toggleIcon) { + setIcon(toggleIcon, currentlyCollapsed ? 'chevron-right' : 'chevron-down'); + } + }); + + // 6. Render Content + const contentWrapper = widgetContainer.createDiv({ + cls: 'cpwn-widget-content' + }); + + // --------------------------------------------------------------------- + // WEEKLY MOMENTUM WIDGET + // --------------------------------------------------------------------- + if (widgetKey === 'weeklyGoalPoints') { + const beforeCount = + Object.keys(this.plugin.settings.goalScoreHistory || {}).length; + + // FIX 1: Create a settings object that forces vacation mode OFF + const historySettings = { ...this.plugin.settings, vacationModeGlobal: false }; + + // FIX 2: Use 'historySettings' here to get the correct Lifetime Total + const fullHistory = await GoalDataAggregator.getPointsHistory( + this.app, + historySettings, + this.allTasks + ); + + const afterCount = + Object.keys(this.plugin.settings.goalScoreHistory || {}).length; + if (afterCount > beforeCount) { + await this.plugin.saveSettings(); + } + + let lifetimeTotal = 0; + if (Array.isArray(fullHistory)) { + lifetimeTotal = fullHistory.reduce( + (acc, day) => acc + (day.totalPoints || 0), + 0 + ); + } else if ( + fullHistory && + fullHistory.datasets && + fullHistory.datasets[0] && + Array.isArray(fullHistory.datasets[0].data) + ) { + lifetimeTotal = fullHistory.datasets[0].data.reduce( + (acc, val) => acc + (Number(val) || 0), + 0 + ); + } + + // 2. Generate Weekly Data (Backwards from Total) + const labels = []; + const dataPoints = []; + + // FIX 3: Initialize GoalTracker with 'historySettings' + // This ensures it can calculate past days' scores even if Vacation Mode is ON today. + const tracker = new GoalTracker(this.app, historySettings); + + let currentRunningTotal = lifetimeTotal; + const today = moment(); + + for (let i = 0; i < 7; i++) { + const date = today.clone().subtract(i, 'days'); + + labels.unshift(date.format('ddd')); + dataPoints.unshift(currentRunningTotal); + + const dailyRes = await tracker.calculateDailyScore(date, this.allTasks); + currentRunningTotal -= dailyRes.totalPoints; + } + + // 3. Construct chartDataObj + const chartDataObj = { + labels: labels, + datasets: [ + { + label: 'Weekly Momentum', + data: dataPoints, + borderColor: 'var(--text-accent)', + backgroundColor: 'rgba(var(--text-accent-rgb), 0.1)', + borderWidth: 2, + fill: true, + tension: 0.4, + pointRadius: 3 + } + ] + }; + + // 4. Render + const renderer = new CssChartRenderer(contentWrapper); + renderer.renderWeeklyGoalMomentum(chartDataObj); + } + + // --------------------------------------------------------------------- + // DAILY GOALS + LEVEL RING + // --------------------------------------------------------------------- + else if (widgetKey === 'goalStatusList') { + const goals = await GoalDataAggregator.getTodaysGoals( + this.app, + this.plugin.settings, + this.allTasks + ); + + // --- Get Points History --- + const beforeCount = + Object.keys(this.plugin.settings.goalScoreHistory || {}).length; + + const historySettings = { ...this.plugin.settings, vacationModeGlobal: false }; + + const rawHistory = await GoalDataAggregator.getPointsHistory( + this.app, + historySettings, // Use the temporary settings here + this.allTasks + ); + + const afterCount = + Object.keys(this.plugin.settings.goalScoreHistory || {}).length; + if (afterCount > beforeCount) { + await this.plugin.saveSettings(); + } + + let calculatedTotal = 0; + + // Array format (ideal): [{ date, totalPoints }, ...] + if (Array.isArray(rawHistory)) { + calculatedTotal = rawHistory.reduce( + (acc, day) => acc + (day.totalPoints || 0), + 0 + ); + } + // Chart object format: { labels: [], datasets: [{ data: [...] }] } + else if ( + rawHistory && + rawHistory.datasets && + rawHistory.datasets[0] && + Array.isArray(rawHistory.datasets[0].data) + ) { + calculatedTotal = rawHistory.datasets[0].data.reduce( + (acc, val) => acc + (Number(val) || 0), + 0 + ); + } else { + console.warn( + 'Daily Goals: Could not parse history data for total points', + rawHistory + ); + } + + const totalPoints = calculatedTotal; + + // ------------------------------- + // Dynamic level thresholds + // ------------------------------- + const trackerForLevels = new GoalTracker(this.app, this.plugin.settings); + const lifetimeTargetRaw = + trackerForLevels.getLifetimeTargetPoints && + trackerForLevels.getLifetimeTargetPoints(); + const effectiveTarget = + lifetimeTargetRaw && lifetimeTargetRaw > 0 ? lifetimeTargetRaw : 300000; + + const LEVEL_BANDS = [ + { frac: 1.0, title: 'Grandmaster of Flow', icon: 'infinity' }, + { frac: 0.9, title: 'Legendary Leader', icon: 'crown' }, + { frac: 0.8, title: 'Relentless Result', icon: 'trophy' }, + { frac: 0.7, title: 'Task Titan', icon: 'medal' }, + { frac: 0.6, title: 'Output Overlord', icon: 'award' }, + { frac: 0.5, title: 'High Performer', icon: 'star' }, + { frac: 0.4, title: 'Productivity Pro', icon: 'zap' }, + { frac: 0.3, title: 'Goal Getter', icon: 'target' }, + { frac: 0.22, title: 'Focused Ninja', icon: 'crosshair' }, + { frac: 0.16, title: 'Efficiency Enthusiast', icon: 'gauge' }, + { frac: 0.10, title: 'Daily Driver', icon: 'calendar-check' }, + { frac: 0.06, title: 'Momentum Maker', icon: 'trending-up' }, + { frac: 0.03, title: 'Task Tinkerer', icon: 'check-circle-2' }, + { frac: 0.01, title: 'Routine Rookie', icon: 'clipboard-list' }, + { frac: 0.0, title: 'Starter Spark', icon: 'sprout' } + ]; + + const levelsDesc = LEVEL_BANDS + .map((band, idx) => ({ + min: Math.round(band.frac * effectiveTarget), + title: band.title, + icon: band.icon, + level: LEVEL_BANDS.length - idx + })) + .sort((a, b) => b.min - a.min); + + // 2. Find current level + const currentLevel = + levelsDesc.find(l => Number(totalPoints) >= l.min) || + levelsDesc[levelsDesc.length - 1]; + + // 3. Find next level + const currentIndex = levelsDesc.indexOf(currentLevel); + const nextLevel = + currentIndex > 0 + ? levelsDesc[currentIndex - 1] + : { min: currentLevel.min * 1.5, title: 'Max Level' }; + + // 4. Calculate Percentage within current level + const pointsInLevel = totalPoints - currentLevel.min; + const pointsNeededForLevel = nextLevel.min - currentLevel.min; + const percentage = Math.min( + 100, + Math.max(0, (pointsInLevel / pointsNeededForLevel) * 100) + ); + + // 5. Daily average (last 7 days) + let dailyAverage = 0; + let projectionText = 'Start completing tasks to generate a projection!'; + + if (Array.isArray(rawHistory) && rawHistory.length > 0) { + const recentHistory = rawHistory.slice(-7); + const sumRecent = recentHistory.reduce( + (acc, day) => acc + (day.totalPoints || 0), + 0 + ); + dailyAverage = sumRecent / (recentHistory.length || 1); + } else if ( + rawHistory && + rawHistory.datasets && + rawHistory.datasets[0] + ) { + const data = rawHistory.datasets[0].data || []; + + if (data.length >= 2) { + const recentCumulative = data.slice(-8); + const dailyDiffs = []; + for (let i = 1; i < recentCumulative.length; i++) { + const todayTotal = Number(recentCumulative[i]) || 0; + const yesterdayTotal = Number(recentCumulative[i - 1]) || 0; + dailyDiffs.push(todayTotal - yesterdayTotal); + } + const sumRecent = dailyDiffs.reduce((acc, val) => acc + val, 0); + dailyAverage = sumRecent / (dailyDiffs.length || 1); + } else if (data.length === 1) { + dailyAverage = Number(data[0]) || 0; + } else { + dailyAverage = 0; + } + } + + // 6. Generate Motivating Message (tiered) + let tooltipIcon = 'circle'; + let tooltipIconColor = 'var(--text-muted)'; + + { + const pointsRemaining = Math.max(0, nextLevel.min - totalPoints); + + if (dailyAverage > 0) { + const daysToNext = Math.ceil(pointsRemaining / dailyAverage); + const projectedDate = moment() + .add(daysToNext, 'days') + .format('MMM Do'); + + const avgRounded = Math.round(dailyAverage); + + let headline; + if (avgRounded < 75) { + headline = `Nice and steady. Averaging ~${avgRounded} pts/day.`; + tooltipIcon = 'circle'; + tooltipIconColor = 'var(--text-muted)'; + } else if (avgRounded < 175) { + headline = `Good momentum! Averaging ~${avgRounded} pts/day.`; + tooltipIcon = 'trending-up'; + tooltipIconColor = 'var(--text-accent)'; + } else if (avgRounded < 275) { + headline = `You're on fire! Averaging ~${avgRounded} pts/day.`; + tooltipIcon = 'flame'; + tooltipIconColor = 'var(--color-orange)'; + } else { + headline = `Absolutely crushing it at ~${avgRounded} pts/day.`; + tooltipIcon = 'rocket'; + tooltipIconColor = 'var(--color-green)'; + } + + const paceLine = projectedDate + ? `At this pace, you'll level up on ${projectedDate}.` + : `Keep going—every day moves you closer.`; + + const distanceLine = + pointsRemaining > 0 + ? `You're only ${pointsRemaining.toLocaleString()} points away!` + : `You've already reached this tier—enjoy the win!`; + + projectionText = + `Current: ${totalPoints.toLocaleString()} pts\n` + + `Next: ${nextLevel.title} (${nextLevel.min.toLocaleString()} pts)\n\n` + + `${headline}\n` + + `${paceLine}\n` + + `${distanceLine}`; + } else { + projectionText = + `Current: ${totalPoints.toLocaleString()} pts\n` + + `Next: ${nextLevel.title}\n\n` + + `Complete some tasks to see your estimated level-up date!`; + tooltipIcon = 'info'; + tooltipIconColor = 'var(--text-muted)'; + } + } + + // 7. Render Layout + const splitContainer = contentWrapper.createDiv({ + cls: 'cpwn-goal-split-container' + }); + + // Left: list of goals + const listEl = splitContainer.createDiv({ cls: 'cpwn-goal-list-left' }); + if (this.plugin.settings.vacationModeGlobal) { + // Vacation mode ON: show override message + // Show Vacation Icon + const iconContainer = listEl.createDiv({ + cls: 'cpwn-summary-icon-rest' // Reuse the same large icon class + }); + setIcon(iconContainer, 'plane'); // Palm tree icon + + + listEl.createDiv({ + text: 'All goals paused, enjoy your vacation!', + cls: 'cpwn-muted-text' + }); + } else if (goals.length === 0) { + listEl.createDiv({ + text: 'No goals set.', + cls: 'cpwn-muted-text' + }); + } else { + goals.forEach(g => { + + + const row = listEl.createDiv({ cls: 'cpwn-goal-row' }); + const left = row.createDiv({ cls: 'cpwn-goal-left' }); + const iconSpan = left.createSpan({ cls: 'cpwn-goal-icon' }); + + // CHECK IF ACTIVE TODAY + if (!g.isActiveToday) { + row.addClass('cpwn-goal-inactive'); // Add class for styling + + // Render "Rest Day" Icon instead of Checkbox + setIcon(iconSpan, 'coffee'); // 'coffee' or 'moon' or 'pause-circle' + iconSpan.setAttribute('aria-label', 'Rest day'); + + // Render Title (Dimmed) + const titleEl = left.createDiv({ cls: 'cpwn-goal-title', text: g.name }); + + // Render "Off" Badge instead of progress + const badge = row.createDiv({ cls: 'cpwn-goal-rest-badge', text: 'Off' }); + + return; // Skip the rest of the rendering for this goal + } + + + + setIcon(iconSpan, g.isMet ? 'square-check' : 'square'); + iconSpan.addClass(g.isMet ? 'is-completed' : 'is-incomplete'); + + + + + const textContainer = left.createDiv({ + cls: 'cpwn-goal-text' + }); + const titleRow = textContainer.createDiv({ + cls: 'cpwn-goal-title-row' + }); + titleRow.createSpan({ + text: g.name, + cls: 'cpwn-goal-title' + }); + if (g.type !== 'note-created') { + titleRow.createSpan({ + text: ` (${g.current || 0}/${g.target})`, + cls: 'cpwn-goal-count-inline' + }); + } + const right = row.createDiv({ cls: 'cpwn-goal-right' }); + const ptsClass = + g.points < 0 ? 'cpwn-points-neg' : 'cpwn-points-pos'; + right.createSpan({ + text: g.points > 0 ? `+${g.points}` : `${g.points}`, + cls: `cpwn-goal-points ${ptsClass}` + }); + }); + } + + // Right: Level Circle with Tooltip + const levelCol = splitContainer.createDiv({ + cls: 'cpwn-goal-level-right' + }); + + const size = 60; + const stroke = 4; + const r = (size - stroke) / 2; + const circ = 2 * Math.PI * r; + const offset = circ - (percentage / 100) * circ; + + const svgWrapper = levelCol.createDiv({ cls: 'cpwn-level-circle-wrapper' }); + // Basic aria label for accessibility; real content comes from custom tooltip + svgWrapper.setAttribute('aria-label', 'Goal progress summary'); + + // Safe SVG Creation + const svgNs = 'http://www.w3.org/2000/svg'; + const svgEl = document.createElementNS(svgNs, 'svg'); + svgEl.setAttribute('width', String(size)); + svgEl.setAttribute('height', String(size)); + svgEl.style.transform = 'rotate(-90deg)'; + + const bgCircle = document.createElementNS(svgNs, 'circle'); + bgCircle.setAttribute('cx', String(size / 2)); + bgCircle.setAttribute('cy', String(size / 2)); + bgCircle.setAttribute('r', String(r)); + bgCircle.setAttribute('stroke', 'var(--background-modifier-border)'); + bgCircle.setAttribute('stroke-width', String(stroke)); + bgCircle.setAttribute('fill', 'none'); + svgEl.appendChild(bgCircle); + + const progCircle = document.createElementNS(svgNs, 'circle'); + progCircle.setAttribute('cx', String(size / 2)); + progCircle.setAttribute('cy', String(size / 2)); + progCircle.setAttribute('r', String(r)); + progCircle.setAttribute('stroke', 'var(--text-accent)'); + progCircle.setAttribute('stroke-width', String(stroke)); + progCircle.setAttribute('fill', 'none'); + progCircle.style.strokeDasharray = `${circ}`; + progCircle.style.strokeDashoffset = `${offset}`; + progCircle.style.transition = 'stroke-dashoffset 0.5s ease'; + svgEl.appendChild(progCircle); + + svgWrapper.appendChild(svgEl); + + // Icon Inner (center of ring) + const iconInner = svgWrapper.createDiv({ cls: 'cpwn-level-icon-inner' }); + setIcon(iconInner, currentLevel.icon || 'sprout'); + + // Level Title under ring + levelCol.createDiv({ + text: currentLevel.title, + cls: 'cpwn-level-title-text' + }); + + // ----------------------------------------- + // Custom tooltip styled like popup + // ----------------------------------------- + let goalTooltipEl = null; + + const buildTooltipContent = () => { + if (!goalTooltipEl) { + goalTooltipEl = document.body.createDiv({ + cls: 'cpwn-goal-tooltip' + }); + } + goalTooltipEl.empty(); + + const header = goalTooltipEl.createDiv({ + cls: 'cpwn-goal-tooltip-header' + }); + + // Mini progress ring that mirrors the widget icon + const ringWrapper = header.createDiv({ + cls: 'cpwn-goal-tooltip-ring-wrapper' + }); + + const miniSize = 28; + const miniStroke = 3; + const miniR = (miniSize - miniStroke) / 2; + const miniCirc = 2 * Math.PI * miniR; + const miniOffset = miniCirc - (percentage / 100) * miniCirc; + + const miniSvg = document.createElementNS(svgNs, 'svg'); + miniSvg.setAttribute('width', String(miniSize)); + miniSvg.setAttribute('height', String(miniSize)); + miniSvg.style.transform = 'rotate(-90deg)'; + + // Background ring + const miniBg = document.createElementNS(svgNs, 'circle'); + miniBg.setAttribute('cx', String(miniSize / 2)); + miniBg.setAttribute('cy', String(miniSize / 2)); + miniBg.setAttribute('r', String(miniR)); + miniBg.setAttribute('stroke', 'var(--background-modifier-border)'); + miniBg.setAttribute('stroke-width', String(miniStroke)); + miniBg.setAttribute('fill', 'var(--background-primary)'); + miniSvg.appendChild(miniBg); + + // Progress arc + const miniProg = document.createElementNS(svgNs, 'circle'); + miniProg.setAttribute('cx', String(miniSize / 2)); + miniProg.setAttribute('cy', String(miniSize / 2)); + miniProg.setAttribute('r', String(miniR)); + miniProg.setAttribute('stroke', 'var(--text-accent)'); + miniProg.setAttribute('stroke-width', String(miniStroke)); + miniProg.setAttribute('fill', 'none'); + miniProg.style.strokeDasharray = `${miniCirc}`; + miniProg.style.strokeDashoffset = `${miniOffset}`; + miniSvg.appendChild(miniProg); + + ringWrapper.appendChild(miniSvg); + + // Level icon centered over the ring + const ringIcon = ringWrapper.createDiv({ + cls: 'cpwn-goal-tooltip-ring-icon' + }); + setIcon(ringIcon, currentLevel.icon || 'sprout'); + + // Level title + header.createDiv({ + cls: 'cpwn-goal-tooltip-title', + text: currentLevel.title + }); + + const body = goalTooltipEl.createDiv({ + cls: 'cpwn-goal-tooltip-body' + }); + + const lines = projectionText.split('\n'); + + lines.forEach((line) => { + const trimmed = line.trim(); + if (!trimmed) { + body.createDiv({ cls: 'cpwn-goal-tooltip-spacer' }); + return; + } + + // Extra gap before "At this pace..." + if (trimmed.startsWith('At this pace')) { + body.createDiv({ cls: 'cpwn-goal-tooltip-spacer-large' }); + } + + const isEmphasis = + trimmed.startsWith('Current:') || + trimmed.startsWith('Next:') || + trimmed.startsWith('Nice and steady') || + trimmed.startsWith('Good momentum') || + trimmed.startsWith("You're on fire") || + trimmed.startsWith('Absolutely crushing'); + + body.createDiv({ + cls: isEmphasis + ? 'cpwn-goal-tooltip-line-strong' + : 'cpwn-goal-tooltip-line', + text: trimmed + }); + }); + }; + + const positionTooltip = (evt) => { + if (!goalTooltipEl) return; + + const padding = 12; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Start by placing it to the bottom-right of the cursor + let x = evt.clientX + padding; + let y = evt.clientY + padding; + + // Measure tooltip + const rect = goalTooltipEl.getBoundingClientRect(); + + // If it would overflow to the right, flip to left side + if (x + rect.width + padding > viewportWidth) { + x = evt.clientX - rect.width - padding; + } + + // If it would overflow the bottom, move up + if (y + rect.height + padding > viewportHeight) { + y = evt.clientY - rect.height - padding; + } + + // Final clamp to viewport + x = Math.max(padding, Math.min(x, viewportWidth - rect.width - padding)); + y = Math.max(padding, Math.min(y, viewportHeight - rect.height - padding)); + + goalTooltipEl.style.left = `${x}px`; + goalTooltipEl.style.top = `${y}px`; + }; + + svgWrapper.addEventListener('mouseenter', (evt) => { + buildTooltipContent(); + // Ensure layout is up to date before measuring + requestAnimationFrame(() => { + if (!goalTooltipEl) return; + goalTooltipEl.style.opacity = '1'; + goalTooltipEl.style.pointerEvents = 'none'; + positionTooltip(evt); + }); + }); + + svgWrapper.addEventListener('mousemove', (evt) => { + positionTooltip(evt); + }); + + svgWrapper.addEventListener('mouseleave', () => { + if (goalTooltipEl) { + goalTooltipEl.style.opacity = '0'; + } + }); + } + break; + } + case 'taskstatusoverview': { const metrics = await this.getTaskMetrics(); // This passes the entire metrics object as the second argument diff --git a/src/widgets/CssChartRenderer.js b/src/widgets/CssChartRenderer.js index 444d841..c9fba6b 100644 --- a/src/widgets/CssChartRenderer.js +++ b/src/widgets/CssChartRenderer.js @@ -219,7 +219,7 @@ export class CssChartRenderer { // Add the correct class to the main container. This is what the CSS will use. if (showLegend) { this.container.addClass(`cpwn-legend-${legendPosition}`); - console.log(legendPosition); + console.log(legendPosition); } else { // This class is for when the legend is off (e.g., mini/small widgets) this.container.addClass('is-pie-only'); @@ -900,4 +900,212 @@ export class CssChartRenderer { this.renderLegend(chartContainer, datasets); } } + + /** + * Renders a bespoke cumulative area chart for Goal Momentum. + * Strictly follows Obsidian UI guidelines (CSS classes, textContent). + */ + renderWeeklyGoalMomentum(chartData, options = {}) { + this.cleanup(); + const chartContainer = this.container.createDiv({ cls: 'cpwn-pm-css-chart-container' }); + + const svgNs = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(svgNs, "svg"); + + svg.setAttribute('viewBox', '0 0 300 135'); + svg.setAttribute('preserveAspectRatio', 'none'); + + svg.setAttribute('preserveAspectRatio', 'none'); + svg.setAttribute('width', '100%'); + svg.setAttribute('height', '100%'); + + + chartContainer.appendChild(svg); + + const dataset = chartData.datasets[0]; + if (!dataset || dataset.data.length < 2) { + chartContainer.createDiv({ text: "Not enough data", cls: "cpwn-muted-text" }); + return; + } + + const rawData = dataset.data; + const minVal = Math.min(...rawData); + const maxVal = Math.max(...rawData); + const range = (maxVal - minVal) === 0 ? 10 : (maxVal - minVal); + + const padding = { top: 15, right: 10, bottom: 10, left: 10 }; + const width = 300 - padding.left - padding.right; + const height = 120 - padding.top - padding.bottom; + + // Map values to coordinates + const points = rawData.map((val, i) => { + const x = padding.left + (i / (rawData.length - 1)) * width; + // Normalize Y: Lowest value sits at bottom (y = height + top) + const normalizedVal = val - minVal; + const y = padding.top + height - (normalizedVal / range) * height; + return { x, y, val }; + }); + + // 1. Grid Lines (Stop at the dot) + points.forEach((p) => { + const gridLine = document.createElementNS(svgNs, 'line'); + gridLine.classList.add('cpwn-chart-grid-line'); + gridLine.setAttribute('x1', p.x); + gridLine.setAttribute('y1', p.y); // Start EXACTLY at the dot + gridLine.setAttribute('x2', p.x); + gridLine.setAttribute('y2', padding.top + height); // Go down to bottom axis + gridLine.setAttribute('stroke-opacity', '0.3'); // Subtle opacity via attribute + svg.appendChild(gridLine); + }); + + // 2. Gradient Definition + const defs = document.createElementNS(svgNs, 'defs'); + const gradientId = 'goalMomentumGradient-' + Math.random().toString(36).substr(2, 9); + const gradient = document.createElementNS(svgNs, 'linearGradient'); + gradient.setAttribute('id', gradientId); + gradient.setAttribute('x1', '0%'); + gradient.setAttribute('y1', '0%'); + gradient.setAttribute('x2', '0%'); + gradient.setAttribute('y2', '100%'); + + const stop1 = document.createElementNS(svgNs, 'stop'); + stop1.setAttribute('offset', '0%'); + stop1.setAttribute('stop-color', 'var(--interactive-accent)'); + stop1.setAttribute('stop-opacity', '0.5'); + + const stop2 = document.createElementNS(svgNs, 'stop'); + stop2.setAttribute('offset', '100%'); + stop2.setAttribute('stop-color', 'var(--interactive-accent)'); + stop2.setAttribute('stop-opacity', '0.05'); + + gradient.appendChild(stop1); + gradient.appendChild(stop2); + defs.appendChild(gradient); + svg.appendChild(defs); + + // 3. Draw Paths + const lineD = points.map((p, i) => (i === 0 ? `M ${p.x},${p.y}` : `L ${p.x},${p.y}`)).join(' '); + const areaD = `${lineD} L ${points[points.length - 1].x},${padding.top + height} L ${points[0].x},${padding.top + height} Z`; + + const areaPath = document.createElementNS(svgNs, 'path'); + areaPath.classList.add('cpwn-chart-area-fill'); + areaPath.setAttribute('d', areaD); + areaPath.setAttribute('fill', `url(#${gradientId})`); + svg.appendChild(areaPath); + + const linePath = document.createElementNS(svgNs, 'path'); + linePath.classList.add('cpwn-chart-line-stroke'); + linePath.setAttribute('d', lineD); + svg.appendChild(linePath); + + // 4. Dots & Hit Targets + points.forEach((p, i) => { + const circle = document.createElementNS(svgNs, 'circle'); + circle.classList.add('cpwn-chart-dot'); + circle.setAttribute('cx', p.x); + circle.setAttribute('cy', p.y); + circle.setAttribute('r', '3.5'); + svg.appendChild(circle); + + // Invisible Hit Target + const hitTarget = document.createElementNS(svgNs, 'rect'); + hitTarget.classList.add('cpwn-chart-hit-target'); + hitTarget.setAttribute('x', p.x - 10); + hitTarget.setAttribute('y', padding.top); + hitTarget.setAttribute('width', 20); + hitTarget.setAttribute('height', height); + + // Tooltip Events (Using Arrow Functions to fix 'this' context) + hitTarget.addEventListener('mouseenter', (e) => { + const label = chartData.labels[i]; + this.showTooltip(e, `${label}: ${p.val} pts`); + circle.setAttribute('r', '5.5'); // Highlight effect + }); + + hitTarget.addEventListener('mousemove', (e) => { + this.moveTooltip(e); + }); + + hitTarget.addEventListener('mouseleave', () => { + this.hideTooltip(); + circle.setAttribute('r', '3.5'); // Reset + }); + + svg.appendChild(hitTarget); + }); + + const labelClass = options.labelClass || 'cpwn-axis-label'; + + // 5. X-Axis Labels + chartData.labels.forEach((label, i) => { + const text = document.createElementNS(svgNs, 'text'); + text.classList.add(labelClass); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('x', points[i].x); + text.setAttribute('y', 130); + text.textContent = label; // Use textContent + svg.appendChild(text); + }); + + } + + showTooltip(e, text) { + // 1. Create tooltip element if it doesn't exist yet + if (!this.tooltipEl) { + this.tooltipEl = document.body.createDiv({ cls: 'cpwn-chart-tooltip' }); + // Safety: Ensure it doesn't block mouse events for the chart + this.tooltipEl.style.pointerEvents = 'none'; + this.tooltipEl.style.position = 'absolute'; + this.tooltipEl.style.zIndex = '9999'; // Ensure it sits on top + } + + // 2. Set the text content + this.tooltipEl.setText(text); + + // 3. Position and show it + this.moveTooltip(e); + this.tooltipEl.style.display = 'block'; + } + + /** + * Updates the tooltip position to follow the mouse. + * Adds smart positioning to prevent overflow on the right edge. + * @param {MouseEvent} e - The mouse movement event. + */ + moveTooltip(e) { + if (this.tooltipEl) { + const tooltipRect = this.tooltipEl.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const offset = 15; // Base offset from cursor + + let left = e.pageX + offset; + let top = e.pageY + offset; + + // Check for right overflow + if (left + tooltipRect.width > viewportWidth - 20) { // 20px buffer + // Flip to left side of cursor + left = e.pageX - tooltipRect.width - offset; + } + + // Optional: Check for bottom overflow (flip up) + const viewportHeight = window.innerHeight; + if (top + tooltipRect.height > viewportHeight - 20) { + top = e.pageY - tooltipRect.height - offset; + } + + this.tooltipEl.style.left = `${left}px`; + this.tooltipEl.style.top = `${top}px`; + } + } + + + /** + * Hides the tooltip from view. + */ + hideTooltip() { + if (this.tooltipEl) { + this.tooltipEl.style.display = 'none'; + } + } + } diff --git a/src/widgets/GoalDataAggregator.js b/src/widgets/GoalDataAggregator.js new file mode 100644 index 0000000..fffdadc --- /dev/null +++ b/src/widgets/GoalDataAggregator.js @@ -0,0 +1,48 @@ +import { GoalTracker } from '../logic/GoalTracker'; +import { moment } from 'obsidian'; + +export class GoalDataAggregator { + + static async getTodaysGoals(app, settings, allTasks) { + const tracker = new GoalTracker(app, settings); + const score = await tracker.calculateDailyScore(moment(), allTasks); + return score.results; // [{ name: 'Journal', isMet: true, ... }] + } + + static async getPointsHistory(app, settings, allTasks) { + const tracker = new GoalTracker(app, settings); + const labels = []; + const data = []; // This will hold the ACTUAL cumulative totals + + // 1. Calculate "Baseline" Total (Score from deep past up to 7 days ago) + let runningTotal = 0; + const startOfHistory = moment().subtract(30, 'days'); + const startOfChart = moment().subtract(6, 'days'); + + // Calculate baseline (simplified loop for performance) + for (let d = startOfHistory.clone(); d.isBefore(startOfChart, 'day'); d.add(1, 'day')) { + const score = await tracker.calculateDailyScore(d, allTasks); + runningTotal += score.totalPoints; + } + + // 2. Calculate Visible 7 Days + for (let i = 6; i >= 0; i--) { + const date = moment().subtract(i, 'days'); + labels.push(date.format('ddd')); + + const score = await tracker.calculateDailyScore(date, allTasks); + runningTotal += score.totalPoints; + data.push(runningTotal); + } + + return { + labels, + datasets: [{ + label: 'Total Points', + data: data // [150, 160, 165...] + }] + }; + } + + +} diff --git a/src/widgets/TaskDataAggregator.js b/src/widgets/TaskDataAggregator.js index 8819e0f..3d57773 100644 --- a/src/widgets/TaskDataAggregator.js +++ b/src/widgets/TaskDataAggregator.js @@ -1,4 +1,6 @@ // src/widgets/TaskDataAggregator.js +import { moment } from 'obsidian'; + export class TaskDataAggregator { /** * Filters and groups tasks based on a widget configuration. diff --git a/styles.css b/styles.css index 4bdb956..284630f 100644 --- a/styles.css +++ b/styles.css @@ -3109,6 +3109,11 @@ body.is-mobile .cpwn-tabs-content-wrapper { fill: var(--text-muted); } +.cpwn-pm-line-chart .cpwn-small-axis-label { + font-size: 14px; + fill: var(--text-muted); +} + .cpwn-pm-line-chart .line-path { fill: none; stroke-width: 3.5; /* Slightly thicker line */ @@ -3485,21 +3490,21 @@ body.is-mobile .cpwn-tabs-content-wrapper { /* Pie Chart Size Differentiation */ .cpwn-pm-widget-medium .cpwn-pm-widget-pie { - min-height: 175px; /* Default for medium */ + max-height: 150px; /* Default for medium */ } .cpwn-pm-widget-medium .cpwn-pm-widget-pie .cpwn-pm-pie-chart { - min-height: 150px; + /*min-height: 150px;*/ max-height: 175px; } .cpwn-pm-widget-large .cpwn-pm-widget-pie { - min-height: 260px; /* Taller for large */ + max-height: 260px; /* Taller for large */ } /* Make sure the pie itself scales appropriately */ .cpwn-pm-widget-large .cpwn-pm-widget-pie .cpwn-pm-pie-chart { - min-height: 220px; - max-height: 220px; + /* min-height: 220px;*/ + max-height: 260px; } /* -- LAYOUTS -- */ @@ -4028,4 +4033,510 @@ body.is-mobile .cpwn-tabs-content-wrapper { .cpwn-fade-visible { opacity: 1; -} \ No newline at end of file +} + +/* Chart Container */ +.cpwn-pm-css-chart-container { + position: relative; + width: 100%; + height: 100%; +} + +.cpwn-pm-css-chart-container svg { + overflow: visible; + width: 100%; + font-family: var(--font-ui); +} + +/* Grid Lines */ +.cpwn-chart-grid-line { + stroke: var(--background-modifier-border); + stroke-width: 1px; +} + + +.cpwn-chart-line-stroke { + fill: none; + stroke: var(--interactive-accent); + stroke-width: 2px; +} + +/* Data Points */ +.cpwn-chart-dot { + fill: var(--interactive-accent); + stroke: var(--background-primary); + stroke-width: 1px; + transition: r 0.2s ease; +} + +/* Hit Targets (Invisible) */ +.cpwn-chart-hit-target { + fill: transparent; + cursor: pointer; +} + +/* Axis Labels */ +.cpwn-axis-label { + font-size: 10px; + fill: var(--text-muted); + text-anchor: middle; +} + + +/* Tooltip */ +.cpwn-chart-tooltip { + position: absolute; + z-index: var(--layer-tooltip); + pointer-events: none; + padding: 4px 8px; + background-color: var(--background-modifier-message); + color: var(--text-normal); + border-radius: var(--radius-s); + font-size: var(--font-ui-smaller); + box-shadow: var(--shadow-s); + display: none; +} + +/* Goal Row Layout */ +.cpwn-goal-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + padding: 2px 0; +} + +/* Left Section (Icon + Text) */ +.cpwn-goal-left { + display: flex; + align-items: center; + flex-grow: 1; + min-width: 0; /* Allows text truncation if needed */ +} + +/* Icon Styling */ +.cpwn-goal-icon { + margin-right: 8px; + display: flex; + align-items: center; +} + +.cpwn-goal-icon.is-completed { + color: var(--text-success); +} + +.cpwn-goal-icon.is-incomplete { + color: var(--text-muted); +} + +/* Text Container */ +.cpwn-goal-text { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.cpwn-goal-title { + font-weight: var(--font-ui-small); + color: var(--text-normal); +} + +.cpwn-goal-progress { + font-size: var(--font-ui-smaller); + /* cpwn-muted-text handles color, but we ensure size here */ +} + +/* Right Section (Points) */ +.cpwn-goal-right { + white-space: nowrap; + margin-left: 8px; +} + +.cpwn-goal-points { + font-weight: bold; + font-size: var(--font-ui-small); + color: var(--text-accent); +} + +.cpwn-goal-list-left .cpwn-goal-row:first-child { + margin-top: 10px; +} + +/* --- Daily Goals Widget Split Layout --- */ +.cpwn-goal-split-container { + display: grid; + grid-template-columns: 1fr 80px; /* List takes available space, Level takes fixed 80px */ + gap: 12px; + align-items: center; +} + +/* Left List Styling */ +.cpwn-goal-list-left { + display: flex; + flex-direction: column; + /*gap: 6px;*/ +} + +.cpwn-goal-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2px 0; +} + +.cpwn-goal-left { + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; /* Prevent text overflow */ +} + +.cpwn-goal-title-row { + display: flex; + align-items: baseline; + gap: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cpwn-goal-title { + font-size: 0.95em; + color: var(--text-normal); +} + +.cpwn-goal-count-inline { + font-size: 0.85em; + color: var(--text-muted); +} + +.cpwn-goal-right { + margin-left: 8px; + white-space: nowrap; +} + +.cpwn-goal-points { + font-weight: bold; + font-size: 0.9em; +} +.cpwn-points-pos { color: var(--text-accent); } +.cpwn-points-neg { color: var(--text-error); } + +/* Right Level Styling */ +.cpwn-goal-level-right { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +.cpwn-level-circle-wrapper { + position: relative; + width: 60px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; +} + +.cpwn-level-icon-inner { + position: absolute; + color: var(--text-normal); + display: flex; + align-items: center; + justify-content: center; +} +.cpwn-level-icon-inner svg { + width: 34px; + height: 34px; +} + +.cpwn-level-title-text { + font-size: 0.75em; + color: var(--text-muted); + margin-top: 6px; + line-height: 1.1; + max-width: 80px; +} + +.cpwn-level-circle-wrapper svg circle:nth-child(2) { + transition: stroke-dashoffset 0.5s ease; + /* Ensure the stroke color is visible */ + stroke: var(--text-accent); + /* Ensure it's on top */ + z-index: 2; +} + +/* Goal progress tooltip, styled like a mini popup window */ +.cpwn-goal-tooltip { + position: fixed; + z-index: 9999; + max-width: 360px; + padding: 10px 12px; + border-radius: 8px; + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + color: var(--text-normal); + font-size: 12px; + line-height: 1.4; + opacity: 0; + transition: opacity 0.15s ease-out; + pointer-events: none; +} + +/* Optional: reuse popup font/spacing feel */ +.cpwn-goal-tooltip-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +/* Wrapper for the mini ring */ +.cpwn-goal-tooltip-ring-wrapper { + position: relative; + width: 28px; + height: 28px; + flex-shrink: 0; +} +.cpwn-goal-tooltip-icon { + display: flex; + align-items: center; + justify-content: center; +} + +/* Performance state icon (color set inline from JS) */ +.cpwn-goal-tooltip-icon-perf { + opacity: 0.9; +} + +/* Level icon uses the progress accent colour */ +.cpwn-goal-tooltip-icon-level { + /* Bigger icon in the popup */ + width: 28px; + height: 28px; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + + /* Progress value (0–100) comes from JS; default to 0 */ + --cpwn-goal-progress: 0; + + /* Outer ring: accent for [0, progress], dull border after that */ + background: + conic-gradient( + var(--text-accent) + 0deg + calc(var(--cpwn-goal-progress) * 3.6deg), + var(--background-modifier-border) + calc(var(--cpwn-goal-progress) * 3.6deg) + 360deg + ), + radial-gradient( + farthest-side, + var(--background-primary) 70%, + transparent 71% + ); + + /* Make sure the inner circle stays the background color, ring outside */ + background-origin: border-box; + background-clip: border-box, padding-box; +} + + +/* Center the Lucide icon over the SVG ring */ +.cpwn-goal-tooltip-ring-icon { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +/* Make the Lucide glyph itself clearly visible inside the ring */ +.cpwn-goal-tooltip-ring-icon svg { + width: 14px; + height: 14px; + stroke: var(--text-normal); + color: var(--text-normal); +} + +.cpwn-goal-tooltip-title { + font-weight: 600; + font-size: 12px; +} + +.cpwn-goal-tooltip-body { + margin-top: 2px; +} + +.cpwn-goal-tooltip-line-strong { + font-weight: 500; + margin-bottom: 2px; +} + +.cpwn-goal-tooltip-line { + color: var(--text-muted); + margin-bottom: 2px; +} + +.cpwn-goal-tooltip-spacer { + height: 4px; +} + +.cpwn-goal-tooltip-spacer-large { + height: 8px; /* gap between “Nice and steady…” and “At this pace…” */ +} + +/* Weekday pill selector in GoalEditModal */ +.cpwn-goal-days-picker { + display: inline-flex; + border-radius: 999px; + overflow: hidden; /* clip inner corners */ + background-color: var(--background-modifier-border); +} + +/* Each day cell: larger tap target */ +.cpwn-goal-day { + padding: 6px 16px; /* bigger horizontally and vertically */ + min-width: 28px; /* wider cells */ + text-align: center; + font-size: 12px; /* larger letter */ + letter-spacing: 0.06em; + cursor: pointer; + color: var(--text-muted); + background-color: transparent; + border-right: 1px solid rgba(0, 0, 0, 0.25); +} + +/* Make the very first cell visually round on the left */ +.cpwn-goal-day:first-child { + border-top-left-radius: 999px; + border-bottom-left-radius: 999px; +} + +/* Make the very last cell visually round on the right */ +.cpwn-goal-day:last-child { + border-top-right-radius: 999px; + border-bottom-right-radius: 999px; + border-right: none; +} + +/* Active days: Obsidian accent, but keep corner rounding from above */ +.cpwn-goal-day.is-active { + background-color: var(--interactive-accent); + color: var(--text-on-accent, #fff); +} + +.cpwn-level-table { + width: 100%; + border-collapse: collapse; + margin-top: 6px; + font-size: 0.9em; +} + +.cpwn-level-table th, +.cpwn-level-table td { + padding: 4px 6px; +} + +.cpwn-level-table tr:nth-child(even) { + background-color: var(--background-secondary-alt); +} + +.cpwn-level-icon svg { + width: 16px; + height: 16px; +} + +.cpwn-goal-item .setting-item { + border-top: none; + border-bottom: none; + padding: 0; /* Remove default padding if it's too large */ +} + +/* Dim the entire row */ +.cpwn-goal-item.cpwn-goal-inactive { + opacity: 0.5; + filter: grayscale(100%); +} + +/* Style the "Rest" icon */ +.cpwn-goal-item.cpwn-goal-inactive .cpwn-goal-icon { + color: var(--text-muted); + cursor: default; +} + +/* Style the "OFF" badge */ +.cpwn-goal-rest-badge { + font-size: 0.7em; + font-weight: bold; + color: var(--text-muted); + border: 1px solid var(--text-muted); + border-radius: 4px; + padding: 2px 6px; + margin-left: auto; /* Push to the right */ +} + +/* Condensed Daily Goal Widget Layout */ +.cpwn-goal-split-container.condensed { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px; +} + +.cpwn-goal-summary-large { + font-size: 2em; + font-weight: bold; + line-height: 1; +} + +.cpwn-goal-summary-sub { + font-size: 0.8em; + color: var(--text-muted); +} + +/* The larger font size for the condensed chart */ +.cpwn-axis-label-large { + font-size: 16px; /* Larger font */ + fill: var(--text-muted); +} + +/* Vacation Text */ +.cpwn-summary-vacation { + font-size: 0.9em; + color: var(--text-muted); +} + +/* Large Numbers (e.g. "1/3") */ +.cpwn-summary-large { + font-size: 2.2em; + font-weight: bold; + line-height: 1; + color: var(--text-normal); +} + +.cpwn-summary-icon-rest { + display: flex; + justify-content: center; /* Center horizontally */ + margin-top: 10px; + margin-bottom: 2px; + color: var(--text-muted); +} + +.cpwn-summary-icon-rest svg { + width: 44px; + height: 44px; +} + +/* Subtext (e.g. "goals met" or "2 off today") */ +.cpwn-summary-sub { + padding-top: 10px; + font-size: 0.6em; + color: var(--text-muted); +} +