From 492e0ec47c1242342230d6a7c0bed19453d50329 Mon Sep 17 00:00:00 2001 From: Tanmay Karande Date: Tue, 17 Mar 2026 00:36:43 -0400 Subject: [PATCH] feat: add finance widget and CSS/template updates Co-Authored-By: Claude Sonnet 4.6 --- finance.b64 | 1 + finance.csv | 5 + finance.html | 16 +++ finance.js | 178 ++++++++++++++++++++++++ finance.json | 14 ++ internal/glance/finance.go | 47 +++++++ internal/glance/glance.go | 3 + internal/glance/static/css/main.css | 15 +- internal/glance/static/css/site.css | 32 ++++- internal/glance/static/css/widgets.css | 26 +++- internal/glance/templates/document.html | 3 + 11 files changed, 326 insertions(+), 14 deletions(-) create mode 100644 finance.b64 create mode 100644 finance.csv create mode 100644 finance.html create mode 100644 finance.js create mode 100644 finance.json create mode 100644 internal/glance/finance.go diff --git a/finance.b64 b/finance.b64 new file mode 100644 index 0000000..f96f3b3 --- /dev/null +++ b/finance.b64 @@ -0,0 +1 @@ +d2luZG93LmluaXRGaW5hbmNlQXBwID0gZnVuY3Rpb24oKSB7CiAgd2luZG93LmZpbmFuY2VBcHAgPSB7CiAgICBkYXRhOiBudWxsLAogICAgY2hhcnRzOiB7fSwKICAgIGFzeW5jIGxvYWQoKSB7CiAgICAgIHRyeSB7CiAgICAgICAgbGV0IHJlcyA9IGF3YWl0IGZldGNoKCcvYXBpL2ZpbmFuY2UnKTsKICAgICAgICBsZXQganNvbiA9IGF3YWl0IHJlcy5qc29uKCk7CiAgICAgICAgdGhpcy5kYXRhID0ganNvbjsKICAgICAgICBpZiAoIXRoaXMuZGF0YS5tb250aGx5IHx8ICF0aGlzLmRhdGEubW9udGhseS5sYWJlbHMpIHsKICAgICAgICAgICB0aGlzLmRhdGEubW9udGhseSA9IHtsYWJlbHM6WydKYW4nXSwgaW5jb21lOlswXSwgZXhwZW5zZXM6WzBdLCBzcGVuZDpbMF19OwogICAgICAgIH0KICAgICAgICBpZiAoIXRoaXMuZGF0YS5hY2NvdW50cykgdGhpcy5kYXRhLmFjY291bnRzID0gW107CiAgICAgICAgdGhpcy5yZW5kZXJDaGFydHMoKTsKICAgICAgICB0aGlzLnJlbmRlclRhYmxlKCk7CiAgICAgIH0gY2F0Y2ggKGUpIHsKICAgICAgICBjb25zb2xlLmVycm9yKCJGaW5hbmNlIEFwcCBMb2FkIEVycm9yOiIsIGUpOwogICAgICB9CiAgICB9LAogICAgYXN5bmMgc2F2ZSgpIHsKICAgICAgYXdhaXQgZmV0Y2goJy9hcGkvZmluYW5jZScsIHsgbWV0aG9kOiAnUE9TVCcsIGJvZHk6IEpTT04uc3RyaW5naWZ5KHRoaXMuZGF0YSksIGhlYWRlcnM6IHsnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nfSB9KTsKICAgIH0sCiAgICByZW5kZXJDaGFydHMoKSB7CiAgICAgIGNvbnN0IHJlc29sdmVDb2xvciA9IChjc3NWYXIsIGZhbGxiYWNrKSA9PiB7CiAgICAgICAgY29uc3QgZGl2ID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7CiAgICAgICAgZGl2LnN0eWxlLmNvbG9yID0gZmFsbGJhY2s7CiAgICAgICAgZGl2LnN0eWxlLmNvbG9yID0gYHZhcigke2Nzc1Zhcn0pYDsKICAgICAgICBkaXYuc3R5bGUuZGlzcGxheSA9ICdub25lJzsKICAgICAgICBkb2N1bWVudC5ib2R5LmFwcGVuZENoaWxkKGRpdik7CiAgICAgICAgY29uc3QgY29sb3IgPSBnZXRDb21wdXRlZFN0eWxlKGRpdikuY29sb3I7CiAgICAgICAgZG9jdW1lbnQuYm9keS5yZW1vdmVDaGlsZChkaXYpOwogICAgICAgIHJldHVybiBjb2xvcjsKICAgICAgfTsKCiAgICAgIGNvbnN0IGNQb3MgPSByZXNvbHZlQ29sb3IoJy0tY29sb3ItcG9zaXRpdmUnLCAnaHNsKDE1MCwgNjAlLCA1MCUpJyk7CiAgICAgIGNvbnN0IGNOZWcgPSByZXNvbHZlQ29sb3IoJy0tY29sb3ItbmVnYXRpdmUnLCAnaHNsKDAsIDcwJSwgNjUlKScpOwogICAgICBjb25zdCBjUHJpID0gcmVzb2x2ZUNvbG9yKCctLWNvbG9yLXByaW1hcnknLCAnaHNsKDEyLCA3MCUsIDYwJSknKTsKICAgICAgY29uc3QgY1RleHQgPSByZXNvbHZlQ29sb3IoJy0tY29sb3ItdGV4dC1zdWJkdWUnLCAncmdiYSgyNTUsMjU1LDI1NSwwLjYpJyk7CiAgICAgIAogICAgICBjb25zdCByZXNvbHZlR3JpZENvbG9yID0gKCkgPT4gewogICAgICAgIGNvbnN0IGRpdiA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ2RpdicpOwogICAgICAgIGRpdi5zdHlsZS5ib3JkZXJDb2xvciA9ICdyZ2JhKDI1NSwyNTUsMjU1LDAuMDUpJzsKICAgICAgICBkaXYuc3R5bGUuYm9yZGVyQ29sb3IgPSAndmFyKC0tY29sb3Itd2lkZ2V0LWNvbnRlbnQtYm9yZGVyKSc7CiAgICAgICAgZGl2LnN0eWxlLmRpc3BsYXkgPSAnbm9uZSc7CiAgICAgICAgZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChkaXYpOwogICAgICAgIGNvbnN0IGNvbG9yID0gZ2V0Q29tcHV0ZWRTdHlsZShkaXYpLmJvcmRlckNvbG9yOwogICAgICAgIGRvY3VtZW50LmJvZHkucmVtb3ZlQ2hpbGQoZGl2KTsKICAgICAgICByZXR1cm4gY29sb3I7CiAgICAgIH07CiAgICAgIGNvbnN0IGNHcmlkID0gcmVzb2x2ZUdyaWRDb2xvcigpOwogICAgICAKICAgICAgY29uc3QgYWRkQWxwaGEgPSAocmdiU3RyLCBhbHBoYSkgPT4gewogICAgICAgIGxldCBtID0gcmdiU3RyLm1hdGNoKC9ecmdiYT9cKChcZCspLFxzKihcZCspLFxzKihcZCspLyk7CiAgICAgICAgaWYgKG0pIHsKICAgICAgICAgIHJldHVybiBgcmdiYSgke21bMV19LCAke21bMl19LCAke21bM119LCAke2FscGhhfSlgOwogICAgICAgIH0KICAgICAgICByZXR1cm4gcmdiU3RyOwogICAgICB9OwoKICAgICAgY29uc3QgY1Bvc0FscGhhID0gYWRkQWxwaGEoY1BvcywgMC42KTsKICAgICAgY29uc3QgY05lZ0FscGhhID0gYWRkQWxwaGEoY05lZywgMC42KTsKICAgICAgY29uc3QgY1ByaUFscGhhID0gYWRkQWxwaGEoY1ByaSwgMC4yKTsKCiAgICAgIENoYXJ0LmRlZmF1bHRzLmNvbG9yID0gY1RleHQ7CiAgICAgIENoYXJ0LmRlZmF1bHRzLmZvbnQuZmFtaWx5ID0gJ091dGZpdCwgLWFwcGxlLXN5c3RlbSwgc2Fucy1zZXJpZic7CiAgICAgIAogICAgICBjb25zdCBjMSA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdpbmNvbWVFeHBlbnNlQ2hhcnQnKTsKICAgICAgaWYoYzEpIHsKICAgICAgICBpZih0aGlzLmNoYXJ0cy5pbmNvbWVFeHBlbnNlKSB0aGlzLmNoYXJ0cy5pbmNvbWVFeHBlbnNlLmRlc3Ryb3koKTsKICAgICAgICB0aGlzLmNoYXJ0cy5pbmNvbWVFeHBlbnNlID0gbmV3IENoYXJ0KGMxLmdldENvbnRleHQoJzJkJyksIHsKICAgICAgICAgIHR5cGU6ICdiYXInLAogICAgICAgICAgZGF0YTogewogICAgICAgICAgICBsYWJlbHM6IHRoaXMuZGF0YS5tb250aGx5LmxhYmVscywKICAgICAgICAgICAgZGF0YXNldHM6IFsKICAgICAgICAgICAgICB7IGxhYmVsOiAnSW5jb21lJywgZGF0YTogdGhpcy5kYXRhLm1vbnRobHkuaW5jb21lLCBiYWNrZ3JvdW5kQ29sb3I6IGNQb3NBbHBoYSwgYm9yZGVyQ29sb3I6IGNQb3MsIGJvcmRlcldpZHRoOiAxLCBib3JkZXJSYWRpdXM6IDMsIGJhclBlcmNlbnRhZ2U6IDAuNiB9LAogICAgICAgICAgICAgIHsgbGFiZWw6ICdFeHBlbnNlcycsIGRhdGE6IHRoaXMuZGF0YS5tb250aGx5LmV4cGVuc2VzLCBiYWNrZ3JvdW5kQ29sb3I6IGNOZWdBbHBoYSwgYm9yZGVyQ29sb3I6IGNOZWcsIGJvcmRlcldpZHRoOiAxLCBib3JkZXJSYWRpdXM6IDMsIGJhclBlcmNlbnRhZ2U6IDAuNiB9CiAgICAgICAgICAgIF0KICAgICAgICAgIH0sCiAgICAgICAgICBvcHRpb25zOiB7IAogICAgICAgICAgICByZXNwb25zaXZlOiB0cnVlLCAKICAgICAgICAgICAgbWFpbnRhaW5Bc3BlY3RSYXRpbzogZmFsc2UsIAogICAgICAgICAgICBwbHVnaW5zOiB7IGxlZ2VuZDogeyBkaXNwbGF5OiBmYWxzZSB9IH0sCiAgICAgICAgICAgIGxheW91dDogeyBwYWRkaW5nOiAxMCB9LAogICAgICAgICAgICBzY2FsZXM6IHsgCiAgICAgICAgICAgICAgICB5OiB7IGRpc3BsYXk6IGZhbHNlLCBiZWdpbkF0WmVybzogdHJ1ZSB9LCAKICAgICAgICAgICAgICAgIHg6IHsgZGlzcGxheTogZmFsc2UgfSAKICAgICAgICAgICAgfSAKICAgICAgICAgIH0KICAgICAgICB9KTsKICAgICAgfQoKICAgICAgY29uc3QgYzIgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnc3BlbmRDaGFydCcpOwogICAgICBpZihjMikgewogICAgICAgIGlmKHRoaXMuY2hhcnRzLnNwZW5kKSB0aGlzLmNoYXJ0cy5zcGVuZC5kZXN0cm95KCk7CiAgICAgICAgdGhpcy5jaGFydHMuc3BlbmQgPSBuZXcgQ2hhcnQoYzIuZ2V0Q29udGV4dCgnMmQnKSwgewogICAgICAgICAgdHlwZTogJ2xpbmUnLAogICAgICAgICAgZGF0YTogewogICAgICAgICAgICBsYWJlbHM6IHRoaXMuZGF0YS5tb250aGx5LmxhYmVscywKICAgICAgICAgICAgZGF0YXNldHM6IFt7IAogICAgICAgICAgICAgICAgbGFiZWw6ICdTcGVuZCcsIAogICAgICAgICAgICAgICAgZGF0YTogdGhpcy5kYXRhLm1vbnRobHkuc3BlbmQsIAogICAgICAgICAgICAgICAgYm9yZGVyQ29sb3I6IGNQcmksIAogICAgICAgICAgICAgICAgYm9yZGVyV2lkdGg6IDIsCiAgICAgICAgICAgICAgICB0ZW5zaW9uOiAwLjQsIAogICAgICAgICAgICAgICAgZmlsbDogZmFsc2UsIAogICAgICAgICAgICAgICAgcG9pbnRSYWRpdXM6IDAsCiAgICAgICAgICAgICAgICBwb2ludEhvdmVyUmFkaXVzOiA1CiAgICAgICAgICAgIH1dCiAgICAgICAgICB9LAogICAgICAgICAgb3B0aW9uczogeyAKICAgICAgICAgICAgcmVzcG9uc2l2ZTogdHJ1ZSwgCiAgICAgICAgICAgIG1haW50YWluQXNwZWN0UmF0aW86IGZhbHNlLCAKICAgICAgICAgICAgcGx1Z2luczogeyBsZWdlbmQ6IHsgZGlzcGxheTogZmFsc2UgfSB9LAogICAgICAgICAgICBsYXlvdXQ6IHsgcGFkZGluZzogMTAgfSwKICAgICAgICAgICAgc2NhbGVzOiB7IAogICAgICAgICAgICAgICAgeTogeyBkaXNwbGF5OiBmYWxzZSwgYmVnaW5BdFplcm86IHRydWUgfSwgCiAgICAgICAgICAgICAgICB4OiB7IGRpc3BsYXk6IGZhbHNlIH0gCiAgICAgICAgICAgIH0gCiAgICAgICAgICB9CiAgICAgICAgfSk7CiAgICAgIH0KICAgIH0sCiAgICByZW5kZXJUYWJsZSgpIHsKICAgICAgY29uc3QgdCA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdmaW5hbmNlLXRib2R5Jyk7CiAgICAgIGlmKCF0KSByZXR1cm47CiAgICAgIHQuaW5uZXJIVE1MID0gJyc7CiAgICAgIGlmKCF0aGlzLmRhdGEuYWNjb3VudHMgfHwgdGhpcy5kYXRhLmFjY291bnRzLmxlbmd0aCA9PT0gMCkgewogICAgICAgIHQuaW5uZXJIVE1MID0gYDx0cj48dGQgY29sc3Bhbj0nNCcgc3R5bGU9J3RleHQtYWxpZ246Y2VudGVyOyBwYWRkaW5nOiAycmVtOycgY2xhc3M9J2NvbG9yLXN1YmR1ZSc+Tm8gYWNjb3VudHMuPC90ZD48L3RyPmA7CiAgICAgICAgcmV0dXJuOwogICAgICB9CiAgICAgIHRoaXMuZGF0YS5hY2NvdW50cy5mb3JFYWNoKChhY2MsIGkpID0+IHsKICAgICAgICBjb25zdCB0ciA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ3RyJyk7CiAgICAgICAgdHIuc3R5bGUuYm9yZGVyQm90dG9tID0gJzFweCBzb2xpZCB2YXIoLS1jb2xvci13aWRnZXQtY29udGVudC1ib3JkZXIpJzsKICAgICAgICBjb25zdCBpblN0eWxlID0gYHdpZHRoOiAxMDAlOyBiYWNrZ3JvdW5kOiB0cmFuc3BhcmVudDsgYm9yZGVyOiBub25lOyBjb2xvcjogdmFyKC0tY29sb3ItdGV4dC1oaWdobGlnaHQpOyBwYWRkaW5nOiAxcmVtOyBmb250LWZhbWlseTogaW5oZXJpdDsgZm9udC1zaXplOiAxLjE1cmVtOyBvdXRsaW5lOiBub25lOyB0cmFuc2l0aW9uOiBiYWNrZ3JvdW5kIDAuMnM7YDsKICAgICAgICAKICAgICAgICBjb25zdCB0ZDEgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCd0ZCcpOwogICAgICAgIHRkMS5zdHlsZS5wYWRkaW5nID0gJzAnOwogICAgICAgIHRkMS5pbm5lckhUTUwgPSBgPGlucHV0IHN0eWxlPSIke2luU3R5bGV9IiB2YWx1ZT0iJHthY2MubmFtZX0iIG9uZm9jdXM9InRoaXMuc3R5bGUuYmFja2dyb3VuZD0ndmFyKC0tY29sb3Itd2lkZ2V0LWJhY2tncm91bmQtaGlnaGxpZ2h0KSciIG9uYmx1cj0idGhpcy5zdHlsZS5iYWNrZ3JvdW5kPSd0cmFuc3BhcmVudCciIG9uY2hhbmdlPSJmaW5hbmNlQXBwLnVwZGF0ZSgke2l9LCduYW1lJyx0aGlzLnZhbHVlKSI+YDsKICAgICAgICAKICAgICAgICBjb25zdCB0ZDIgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCd0ZCcpOwogICAgICAgIHRkMi5zdHlsZS5wYWRkaW5nID0gJzAnOwogICAgICAgIGxldCBhbW91bnRDb2wgPSBhY2MuYmFsYW5jZSA8IDAgPyAnY29sb3ItbmVnYXRpdmUnIDogJ2NvbG9yLXBvc2l0aXZlJzsKICAgICAgICB0ZDIuaW5uZXJIVE1MID0gYDxpbnB1dCB0eXBlPSJudW1iZXIiIHN0ZXA9IjAuMDEiIHN0eWxlPSIke2luU3R5bGV9IiBjbGFzcz0iJHthbW91bnRDb2x9IiB2YWx1ZT0iJHthY2MuYmFsYW5jZX0iIG9uZm9jdXM9InRoaXMuc3R5bGUuYmFja2dyb3VuZD0ndmFyKC0tY29sb3Itd2lkZ2V0LWJhY2tncm91bmQtaGlnaGxpZ2h0KSciIG9uYmx1cj0idGhpcy5zdHlsZS5iYWNrZ3JvdW5kPSd0cmFuc3BhcmVudCciIG9uY2hhbmdlPSJmaW5hbmNlQXBwLnVwZGF0ZSgke2l9LCdiYWxhbmNlJyxwYXJzZUZsb2F0KHRoaXMudmFsdWUpKSI+YDsKICAgICAgICAKICAgICAgICBjb25zdCB0ZDMgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCd0ZCcpOwogICAgICAgIHRkMy5zdHlsZS5wYWRkaW5nID0gJzAnOwogICAgICAgIHRkMy5pbm5lckhUTUwgPSBgPGlucHV0IHN0eWxlPSIke2luU3R5bGV9IiB2YWx1ZT0iJHthY2MuY3VycmVuY3l9IiBvbmZvY3VzPSJ0aGlzLnN0eWxlLmJhY2tncm91bmQ9J3ZhcigtLWNvbG9yLXdpZGdldC1iYWNrZ3JvdW5kLWhpZ2hsaWdodCknIiBvbmJsdXI9InRoaXMuc3R5bGUuYmFja2dyb3VuZD0ndHJhbnNwYXJlbnQnIiBvbmNoYW5nZT0iZmluYW5jZUFwcC51cGRhdGUoJHtpfSwnY3VycmVuY3knLHRoaXMudmFsdWUpIj5gOwogICAgICAgIAogICAgICAgIGNvbnN0IHRkNCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ3RkJyk7CiAgICAgICAgdGQ0LnN0eWxlLnRleHRBbGlnbiA9ICdyaWdodCc7CiAgICAgICAgdGQ0LnN0eWxlLnBhZGRpbmcgPSAnMXJlbSc7CiAgICAgICAgdGQ0LmlubmVySFRNTCA9IGA8YnV0dG9uIG9uY2xpY2s9ImZpbmFuY2VBcHAuZGVsZXRlKCR7aX0pIiBzdHlsZT0icGFkZGluZzogMC40cmVtIDAuOHJlbTsgYm9yZGVyLXJhZGl1czogNnB4OyBib3JkZXI6IG5vbmU7IGJhY2tncm91bmQ6IHJnYmEoMjU1LCA1MCwgNTAsIDAuMTUpOyBjb2xvcjogdmFyKC0tY29sb3ItbmVnYXRpdmUpOyBmb250LXdlaWdodDogNjAwOyBjdXJzb3I6IHBvaW50ZXI7Ij5EZWxldGU8L2J1dHRvbj5gOwogICAgICAgIAogICAgICAgIHRyLmFwcGVuZENoaWxkKHRkMSk7CiAgICAgICAgdHIuYXBwZW5kQ2hpbGQodGQyKTsKICAgICAgICB0ci5hcHBlbmRDaGlsZCh0ZDMpOwogICAgICAgIHRyLmFwcGVuZENoaWxkKHRkNCk7CiAgICAgICAgdC5hcHBlbmRDaGlsZCh0cik7CiAgICAgIH0pOwogICAgfSwKICAgIHVwZGF0ZShpLCBrZXksIHZhbCkgeyB0aGlzLmRhdGEuYWNjb3VudHNbaV1ba2V5XSA9IHZhbDsgdGhpcy5yZW5kZXJUYWJsZSgpOyB0aGlzLnNhdmUoKTsgfSwKICAgIGFkZEFjY291bnQoKSB7IHRoaXMuZGF0YS5hY2NvdW50cy5wdXNoKHtuYW1lOidOZXcgQWNjb3VudCcsYmFsYW5jZTowLGN1cnJlbmN5OidVU0QnfSk7IHRoaXMucmVuZGVyVGFibGUoKTsgdGhpcy5zYXZlKCk7IH0sCiAgICBkZWxldGUoaSkgeyB0aGlzLmRhdGEuYWNjb3VudHMuc3BsaWNlKGksIDEpOyB0aGlzLnJlbmRlclRhYmxlKCk7IHRoaXMuc2F2ZSgpOyB9CiAgfTsKICB3aW5kb3cuZmluYW5jZUFwcC5sb2FkKCk7Cn07CgppZiAoIXdpbmRvdy5maW5hbmNlQXBwTG9hZGVkKSB7CiAgd2luZG93LmZpbmFuY2VBcHBMb2FkZWQgPSB0cnVlOwogIGlmICghd2luZG93LkNoYXJ0KSB7CiAgICBsZXQgc2NyaXB0ID0gZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnc2NyaXB0Jyk7CiAgICBzY3JpcHQuc3JjID0gJ2h0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9ucG0vY2hhcnQuanMnOwogICAgc2NyaXB0Lm9ubG9hZCA9ICgpID0+IHdpbmRvdy5pbml0RmluYW5jZUFwcCgpOwogICAgZG9jdW1lbnQuaGVhZC5hcHBlbmRDaGlsZChzY3JpcHQpOwogIH0gZWxzZSB7CiAgICB3aW5kb3cuaW5pdEZpbmFuY2VBcHAoKTsKICB9Cn0K \ No newline at end of file diff --git a/finance.csv b/finance.csv new file mode 100644 index 0000000..6cb55f9 --- /dev/null +++ b/finance.csv @@ -0,0 +1,5 @@ +Account,Balance,Currency +Checking,4250.00,USD +Savings,12400.00,USD +Credit Card,-850.50,USD +Investments,34800.00,USD diff --git a/finance.html b/finance.html new file mode 100644 index 0000000..5dc5f85 --- /dev/null +++ b/finance.html @@ -0,0 +1,16 @@ +{{ template "widget-base.html" . }} + +{{- define "widget-content" }} + +{{- end }} diff --git a/finance.js b/finance.js new file mode 100644 index 0000000..76c4aed --- /dev/null +++ b/finance.js @@ -0,0 +1,178 @@ +window.initFinanceApp = function() { + window.financeApp = { + data: null, + charts: {}, + async load() { + try { + let res = await fetch('/api/finance'); + let json = await res.json(); + this.data = json; + if (!this.data.monthly || !this.data.monthly.labels) { + this.data.monthly = {labels:['Jan'], income:[0], expenses:[0], spend:[0]}; + } + if (!this.data.accounts) this.data.accounts = []; + this.renderCharts(); + this.renderTable(); + } catch (e) { + console.error("Finance App Load Error:", e); + } + }, + async save() { + await fetch('/api/finance', { method: 'POST', body: JSON.stringify(this.data), headers: {'Content-Type': 'application/json'} }); + }, + renderCharts() { + const resolveColor = (cssVar, fallback) => { + const div = document.createElement('div'); + div.style.color = fallback; + div.style.color = `var(${cssVar})`; + div.style.display = 'none'; + document.body.appendChild(div); + const color = getComputedStyle(div).color; + document.body.removeChild(div); + return color; + }; + + const cPos = resolveColor('--color-positive', 'hsl(150, 60%, 50%)'); + const cNeg = resolveColor('--color-negative', 'hsl(0, 70%, 65%)'); + const cPri = resolveColor('--color-primary', 'hsl(12, 70%, 60%)'); + const cText = resolveColor('--color-text-subdue', 'rgba(255,255,255,0.6)'); + + const resolveGridColor = () => { + const div = document.createElement('div'); + div.style.borderColor = 'rgba(255,255,255,0.05)'; + div.style.borderColor = 'var(--color-widget-content-border)'; + div.style.display = 'none'; + document.body.appendChild(div); + const color = getComputedStyle(div).borderColor; + document.body.removeChild(div); + return color; + }; + const cGrid = resolveGridColor(); + + const addAlpha = (rgbStr, alpha) => { + let m = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (m) { + return `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${alpha})`; + } + return rgbStr; + }; + + const cPosAlpha = addAlpha(cPos, 0.6); + const cNegAlpha = addAlpha(cNeg, 0.6); + const cPriAlpha = addAlpha(cPri, 0.2); + + Chart.defaults.color = cText; + Chart.defaults.font.family = 'Outfit, -apple-system, sans-serif'; + + const c1 = document.getElementById('incomeExpenseChart'); + if(c1) { + if(this.charts.incomeExpense) this.charts.incomeExpense.destroy(); + this.charts.incomeExpense = new Chart(c1.getContext('2d'), { + type: 'bar', + data: { + labels: this.data.monthly.labels, + datasets: [ + { label: 'Income', data: this.data.monthly.income, backgroundColor: cPosAlpha, borderColor: cPos, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 }, + { label: 'Expenses', data: this.data.monthly.expenses, backgroundColor: cNegAlpha, borderColor: cNeg, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + layout: { padding: 10 }, + scales: { + y: { display: false, beginAtZero: true }, + x: { display: false } + } + } + }); + } + + const c2 = document.getElementById('spendChart'); + if(c2) { + if(this.charts.spend) this.charts.spend.destroy(); + this.charts.spend = new Chart(c2.getContext('2d'), { + type: 'line', + data: { + labels: this.data.monthly.labels, + datasets: [{ + label: 'Spend', + data: this.data.monthly.spend, + borderColor: cPri, + borderWidth: 2, + tension: 0.4, + fill: false, + pointRadius: 0, + pointHoverRadius: 5 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + layout: { padding: 10 }, + scales: { + y: { display: false, beginAtZero: true }, + x: { display: false } + } + } + }); + } + }, + renderTable() { + const t = document.getElementById('finance-tbody'); + if(!t) return; + t.innerHTML = ''; + if(!this.data.accounts || this.data.accounts.length === 0) { + t.innerHTML = `No accounts.`; + return; + } + this.data.accounts.forEach((acc, i) => { + const tr = document.createElement('tr'); + tr.style.borderBottom = '1px solid var(--color-widget-content-border)'; + const inStyle = `width: 100%; background: transparent; border: none; color: var(--color-text-highlight); padding: 1rem; font-family: inherit; font-size: 1.15rem; outline: none; transition: background 0.2s;`; + + const td1 = document.createElement('td'); + td1.style.padding = '0'; + td1.innerHTML = ``; + + const td2 = document.createElement('td'); + td2.style.padding = '0'; + let amountCol = acc.balance < 0 ? 'color-negative' : 'color-positive'; + td2.innerHTML = ``; + + const td3 = document.createElement('td'); + td3.style.padding = '0'; + td3.innerHTML = ``; + + const td4 = document.createElement('td'); + td4.style.textAlign = 'right'; + td4.style.padding = '1rem'; + td4.innerHTML = ``; + + tr.appendChild(td1); + tr.appendChild(td2); + tr.appendChild(td3); + tr.appendChild(td4); + t.appendChild(tr); + }); + }, + update(i, key, val) { this.data.accounts[i][key] = val; this.renderTable(); this.save(); }, + addAccount() { this.data.accounts.push({name:'New Account',balance:0,currency:'USD'}); this.renderTable(); this.save(); }, + delete(i) { this.data.accounts.splice(i, 1); this.renderTable(); this.save(); } + }; + window.financeApp.load(); +}; + +if (!window.financeAppLoaded) { + window.financeAppLoaded = true; + if (!window.Chart) { + let script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/chart.js'; + script.onload = () => window.initFinanceApp(); + document.head.appendChild(script); + } else { + window.initFinanceApp(); + } +} diff --git a/finance.json b/finance.json new file mode 100644 index 0000000..4b11c84 --- /dev/null +++ b/finance.json @@ -0,0 +1,14 @@ +{ + "accounts": [ + {"name": "Checking", "balance": 4250.00, "currency": "USD"}, + {"name": "Savings", "balance": 12400.00, "currency": "USD"}, + {"name": "Credit Card", "balance": -850.50, "currency": "USD"}, + {"name": "Investments", "balance": 34800.00, "currency": "USD"} + ], + "monthly": { + "labels": ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar"], + "income": [4000, 4000, 5200, 4000, 4000, 4100], + "expenses": [2100, 2400, 3100, 1800, 2000, 2200], + "spend": [800, 1100, 1600, 600, 700, 900] + } +} diff --git a/internal/glance/finance.go b/internal/glance/finance.go new file mode 100644 index 0000000..4bf5632 --- /dev/null +++ b/internal/glance/finance.go @@ -0,0 +1,47 @@ +package glance + +import ( + "encoding/json" + "io" + "net/http" + "os" +) + +func handleFinanceGet(w http.ResponseWriter, r *http.Request) { + f, err := os.Open("finance.json") + if err != nil { + w.Header().Set("Content-Type", "application/json") + if os.IsNotExist(err) { + w.Write([]byte(`{"accounts":[],"monthly":{"labels":[],"income":[],"expenses":[],"spend":[]}}`)) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer f.Close() + + w.Header().Set("Content-Type", "application/json") + io.Copy(w, f) +} + +func handleFinancePost(w http.ResponseWriter, r *http.Request) { + var data interface{} + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + f, err := os.Create("finance.json") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer f.Close() + + if err := json.NewEncoder(f).Encode(data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 28771fa..96b53dc 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -450,6 +450,9 @@ func (a *application) server() (func() error, func() error) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("GET /api/finance", handleFinanceGet) + mux.HandleFunc("POST /api/finance", handleFinancePost) + if a.RequiresAuth { mux.HandleFunc("GET /login", a.handleLoginPageRequest) mux.HandleFunc("GET /logout", a.handleLogoutRequest) diff --git a/internal/glance/static/css/main.css b/internal/glance/static/css/main.css index 8d0d779..787e97a 100644 --- a/internal/glance/static/css/main.css +++ b/internal/glance/static/css/main.css @@ -10,9 +10,9 @@ font-size: 10px; --scheme: ; - --bgh: 240; - --bgs: 8%; - --bgl: 9%; + --bgh: 220; + --bgs: 12%; + --bgl: 10%; --bghs: var(--bgh), var(--bgs); --cm: 1; --tsm: 1; @@ -25,9 +25,12 @@ --border-radius: 5px; --mobile-navigation-height: 50px; - --color-primary: hsl(43, 50%, 70%); - --color-positive: var(--color-primary); - --color-negative: hsl(0, 70%, 70%); + --primary-h: 12; + --primary-s: 70%; + --primary-l: 60%; + --color-primary: hsl(var(--primary-h), var(--primary-s), var(--primary-l)); + --color-positive: hsl(150, 60%, 50%); + --color-negative: hsl(0, 70%, 65%); --color-background: hsl(var(--bghs), var(--bgl)); --color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%); --color-widget-background: hsl(var(--color-widget-background-hsl-values)); diff --git a/internal/glance/static/css/site.css b/internal/glance/static/css/site.css index fbf3c8a..f58c9f5 100644 --- a/internal/glance/static/css/site.css +++ b/internal/glance/static/css/site.css @@ -43,6 +43,11 @@ button { cursor: pointer; background: none; color: inherit; + transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s; +} + +button:active { + transform: scale(0.95); } ::selection { @@ -110,6 +115,13 @@ html, body, .body-content { h1, h2, h3, h4, h5 { font: inherit; + font-weight: 600; + letter-spacing: -0.02em; +} + +h3, h4, h5 { + font-weight: 500; + letter-spacing: 0; } a { @@ -124,7 +136,7 @@ ul { body { font-size: 1.3rem; - font-family: 'JetBrains Mono', monospace; + font-family: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-variant-ligatures: none; line-height: 1.6; color: var(--color-text-base); @@ -132,6 +144,18 @@ body { overflow-y: scroll; } + + +body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; + background: radial-gradient(circle at 10% 0%, hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.07), transparent 50%), + radial-gradient(circle at 90% 100%, hsla(var(--primary-h), var(--primary-s), calc(var(--primary-l) + 10%), 0.06), transparent 50%); +} + .page-column-small { width: 300px; flex-shrink: 0; @@ -301,11 +325,15 @@ kbd:active { display: block; height: 100%; border-bottom: 2px solid transparent; - transition: color .3s, border-color .3s; + transition: color .3s, border-color .3s, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); font-size: var(--font-size-h3); flex-shrink: 0; } +.nav-item:active { + transform: scale(0.95); +} + .nav-item:not(.nav-item-current):hover { border-bottom-color: var(--color-text-subdue); color: var(--color-text-highlight); diff --git a/internal/glance/static/css/widgets.css b/internal/glance/static/css/widgets.css index 07b41c8..876e5bd 100644 --- a/internal/glance/static/css/widgets.css +++ b/internal/glance/static/css/widgets.css @@ -59,19 +59,29 @@ } .widget-content:not(.widget-content-frameless), .widget-content-frame { - background: var(--color-widget-background); - border-radius: var(--border-radius); - border: 1px solid var(--color-widget-content-border); - box-shadow: 0px 3px 0px 0px hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl)) - 0.5%)); + background: linear-gradient(180deg, hsl(var(--color-widget-background-hsl-values)) 0%, hsla(var(--bghs), calc(var(--bgl) - 2%), 0.95) 100%); + backdrop-filter: blur(12px); + border-radius: calc(var(--border-radius) * 1.5); + border: none; + box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.05) inset, 0 0 0 1px rgba(255, 255, 255, 0.02) inset, 0 8px 16px -4px hsla(var(--bghs), calc(var(--bgl) - 5%), 0.7); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease; +} + +.widget-content:not(.widget-content-frameless):hover, .widget-content-frame:hover { + box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.08) inset, 0 0 0 1px rgba(255, 255, 255, 0.04) inset, 0 12px 24px -8px hsla(var(--bghs), calc(var(--bgl) - 5%), 0.9), 0 0 40px -10px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.12); } .widget-header { padding: 0 calc(var(--widget-content-horizontal-padding) + 1px); - font-size: var(--font-size-h4); - margin-bottom: 0.9rem; + margin-bottom: 1.2rem; display: flex; align-items: center; gap: 1rem; + font-size: 1.15rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 600; + color: var(--color-text-subdue); } .widget-beta-icon { @@ -91,3 +101,7 @@ .widget + .widget { margin-top: var(--widget-gap); } + +.widget-content:active { + transform: scale(0.99); +} diff --git a/internal/glance/templates/document.html b/internal/glance/templates/document.html index 5a60ec8..4eaa18b 100644 --- a/internal/glance/templates/document.html +++ b/internal/glance/templates/document.html @@ -2,6 +2,9 @@ {{ block "document-head-before" . }}{{ end }} + + +