20-calendar-system #37

Merged
Ajdj100 merged 25 commits from 20-calendar-system into main 2025-11-28 00:06:10 -06:00
23 changed files with 1529 additions and 397 deletions

513
api/package-lock.json generated
View File

@@ -24,6 +24,7 @@
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/morgan": "^1.9.10", "@types/morgan": "^1.9.10",
"@types/node": "^24.8.1", "@types/node": "^24.8.1",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
@@ -34,6 +35,44 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@npmcli/fs": { "node_modules/@npmcli/fs": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
@@ -272,6 +311,20 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/aproba": { "node_modules/aproba": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
@@ -294,6 +347,16 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0" "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
} }
}, },
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/aws-ssl-profiles": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -348,6 +411,19 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bindings": { "node_modules/bindings": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -399,6 +475,19 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/buffer": { "node_modules/buffer": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -504,6 +593,31 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
@@ -533,6 +647,16 @@
"color-support": "bin.js" "color-support": "bin.js"
} }
}, },
"node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -685,6 +809,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.2.1", "version": "17.2.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
@@ -905,12 +1042,52 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/file-uri-to-path": { "node_modules/file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
@@ -971,6 +1148,21 @@
"license": "ISC", "license": "ISC",
"optional": true "optional": true
}, },
"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": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1047,6 +1239,19 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": { "node_modules/github-from-package": {
"version": "0.0.0", "version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
@@ -1075,6 +1280,40 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.9",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1228,6 +1467,16 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/imurmurhash": { "node_modules/imurmurhash": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -1298,6 +1547,29 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": { "node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -1308,6 +1580,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-lambda": { "node_modules/is-lambda": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz",
@@ -1315,6 +1600,16 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-promise": { "node_modules/is-promise": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -1458,6 +1753,30 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -1675,6 +1994,20 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mylas": {
"version": "2.1.14",
"resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz",
"integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/raouldeheer"
}
},
"node_modules/mysql2": { "node_modules/mysql2": {
"version": "3.14.3", "version": "3.14.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.3.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.3.tgz",
@@ -1790,6 +2123,16 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/npmlog": { "node_modules/npmlog": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
@@ -1951,11 +2294,47 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pause": { "node_modules/pause": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/plimit-lit": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz",
"integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"queue-lit": "^1.5.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -2041,6 +2420,37 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/queue-lit": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz",
"integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/random-bytes": { "node_modules/random-bytes": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
@@ -2103,6 +2513,29 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/retry": { "node_modules/retry": {
"version": "0.12.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
@@ -2113,6 +2546,17 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -2146,6 +2590,30 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2363,6 +2831,16 @@
"simple-concat": "^1.0.0" "simple-concat": "^1.0.0"
} }
}, },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/smart-buffer": { "node_modules/smart-buffer": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
@@ -2565,6 +3043,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2574,6 +3065,28 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tsc-alias": {
"version": "1.8.16",
"resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz",
"integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.3",
"commander": "^9.0.0",
"get-tsconfig": "^4.10.0",
"globby": "^11.0.4",
"mylas": "^2.1.9",
"normalize-path": "^3.0.0",
"plimit-lit": "^1.2.6"
},
"bin": {
"tsc-alias": "dist/bin/index.js"
},
"engines": {
"node": ">=16.20.2"
}
},
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@@ -8,8 +8,8 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsc && node ./built/api/src/index.js", "dev": "tsc && tsc-alias && node ./built/api/src/index.js",
"build" : "tsc" "build" : "tsc && tsc-alias"
}, },
"dependencies": { "dependencies": {
"connect-sqlite3": "^0.9.16", "connect-sqlite3": "^0.9.16",
@@ -27,6 +27,7 @@
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/morgan": "^1.9.10", "@types/morgan": "^1.9.10",
"@types/node": "^24.8.1", "@types/node": "^24.8.1",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -46,6 +46,7 @@ const { status, memberStatus } = require('./routes/statuses')
const authRouter = require('./routes/auth') const authRouter = require('./routes/auth')
const { roles, memberRoles } = require('./routes/roles'); const { roles, memberRoles } = require('./routes/roles');
const { courseRouter, eventRouter } = require('./routes/course'); const { courseRouter, eventRouter } = require('./routes/course');
const { calendarRouter } = require('./routes/calendar')
const morgan = require('morgan'); const morgan = require('morgan');
app.use('/application', applicationsRouter); app.use('/application', applicationsRouter);
@@ -59,6 +60,7 @@ app.use('/roles', roles)
app.use('/memberRoles', memberRoles) app.use('/memberRoles', memberRoles)
app.use('/course', courseRouter) app.use('/course', courseRouter)
app.use('/courseEvent', eventRouter) app.use('/courseEvent', eventRouter)
app.use('/calendar', calendarRouter)
app.use('/', authRouter) app.use('/', authRouter)
app.get('/ping', (req, res) => { app.get('/ping', (req, res) => {

View File

@@ -1,4 +1,6 @@
import { getEventAttendance, getEventDetails, getShortEventsInRange } from "../services/calendarService"; import { Request, Response } from "express";
import { createEvent, getEventAttendance, getEventDetails, getShortEventsInRange, setAttendanceStatus, setEventCancelled, updateEvent } from "../services/calendarService";
import { CalendarAttendance, CalendarEvent } from "@app/shared/types/calendar";
const express = require('express'); const express = require('express');
const r = express.Router(); const r = express.Router();
@@ -9,42 +11,108 @@ function addMonths(date: Date, months: number): Date {
return d return d
} }
//get calendar events paged //get calendar events paged, requires a query string with from= and to= as mariadb ISO strings
r.get('/', async (req, res) => { r.get('/', async (req, res) => {
const viewDate: Date = req.body.date; try {
//generate date range const fromDate: string = req.query.from;
const backDate: Date = addMonths(viewDate, -1); const toDate: string = req.query.to;
const frontDate: Date = addMonths(viewDate, 2);
const events = getShortEventsInRange(backDate, frontDate); if (fromDate === undefined || toDate === undefined) {
res.status(400).send("Missing required query parameters 'from' and 'to'");
return;
}
const events = await getShortEventsInRange(fromDate, toDate);
res.status(200).json(events); res.status(200).json(events);
} catch (error) {
console.error('Error fetching calendar events:', error);
res.status(500).send('Error fetching calendar events');
}
}); });
r.get('/upcoming', async (req, res) => { r.get('/upcoming', async (req, res) => {
res.sendStatus(501); res.sendStatus(501);
}) })
//get event details r.post('/:id/cancel', async (req: Request, res: Response) => {
r.get('/:id', async (req, res) => {
try { try {
const eventID: number = req.params.id; const eventID = Number(req.params.id);
setEventCancelled(eventID, true);
res.sendStatus(200);
} catch (error) {
console.error('Error setting cancel status:', error);
res.status(500).send('Error setting cancel status');
}
})
r.post('/:id/uncancel', async (req: Request, res: Response) => {
try {
const eventID = Number(req.params.id);
setEventCancelled(eventID, false);
res.sendStatus(200);
} catch (error) {
console.error('Error setting cancel status:', error);
res.status(500).send('Error setting cancel status');
}
})
let details = getEventDetails(eventID);
let attendance = await getEventAttendance(eventID);
let out = { ...details, attendance } r.post('/:id/attendance', async (req: Request, res: Response) => {
console.log(out); try {
res.status(200).json(out); let member = req.user.id;
let event = Number(req.params.id);
let state = req.query.state as CalendarAttendance;
setAttendanceStatus(member, event, state);
res.sendStatus(200);
} catch (error) {
console.error('Failed to set attendance:', error);
res.status(500).json(error);
}
})
//get event details
r.get('/:id', async (req: Request, res: Response) => {
try {
const eventID: number = Number(req.params.id);
let details: CalendarEvent = await getEventDetails(eventID);
details.eventSignups = await getEventAttendance(eventID);
res.status(200).json(details);
} catch (err) { } catch (err) {
console.error('Insert failed:', err); console.error('Insert failed:', err);
res.status(500).json(err); res.status(500).json(err);
} }
}) })
//post a new calendar event
r.post('/', async (req, res) => {
//post a new calendar event
r.post('/', async (req: Request, res: Response) => {
try {
const member = req.user.id;
let event: CalendarEvent = req.body;
event.creator_id = member;
event.start = new Date(event.start);
event.end = new Date(event.end);
createEvent(event);
res.sendStatus(200);
} catch (error) {
console.error('Failed to create event:', error);
res.status(500).json(error);
}
}) })
module.exports.calendar = r; r.put('/', async (req: Request, res: Response) => {
try {
let event: CalendarEvent = req.body;
event.start = new Date(event.start);
event.end = new Date(event.end);
console.log(event);
updateEvent(event);
res.sendStatus(200);
} catch (error) {
console.error('Failed to update event:', error);
res.status(500).json(error);
}
})
module.exports.calendarRouter = r;

View File

@@ -1,6 +0,0 @@
export declare function createEvent(eventObject: any): Promise<void>;
export declare function updateEvent(eventObject: any): Promise<void>;
export declare function cancelEvent(eventID: any): Promise<void>;
export declare function getShortEventsInRange(startDate: any, endDate: any): Promise<void>;
export declare function getEventDetailed(eventID: any): Promise<void>;
//# sourceMappingURL=calendarService.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"calendarService.d.ts","sourceRoot":"","sources":["calendarService.ts"],"names":[],"mappings":"AAEA,wBAAsB,WAAW,CAAC,WAAW,KAAA,iBAE5C;AAED,wBAAsB,WAAW,CAAC,WAAW,KAAA,iBAE5C;AAED,wBAAsB,WAAW,CAAC,OAAO,KAAA,iBAExC;AAED,wBAAsB,qBAAqB,CAAC,SAAS,KAAA,EAAE,OAAO,KAAA,iBAE7D;AAED,wBAAsB,gBAAgB,CAAC,OAAO,KAAA,iBAE7C"}

View File

@@ -1,19 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createEvent = createEvent;
exports.updateEvent = updateEvent;
exports.cancelEvent = cancelEvent;
exports.getShortEventsInRange = getShortEventsInRange;
exports.getEventDetailed = getEventDetailed;
const pool = require('../db');
async function createEvent(eventObject) {
}
async function updateEvent(eventObject) {
}
async function cancelEvent(eventID) {
}
async function getShortEventsInRange(startDate, endDate) {
}
async function getEventDetailed(eventID) {
}
//# sourceMappingURL=calendarService.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"calendarService.js","sourceRoot":"","sources":["calendarService.ts"],"names":[],"mappings":";;AAEA,kCAEC;AAED,kCAEC;AAED,kCAEC;AAED,sDAEC;AAED,4CAEC;AApBD,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;AAEtB,KAAK,UAAU,WAAW,CAAC,WAAW;AAE7C,CAAC;AAEM,KAAK,UAAU,WAAW,CAAC,WAAW;AAE7C,CAAC;AAEM,KAAK,UAAU,WAAW,CAAC,OAAO;AAEzC,CAAC;AAEM,KAAK,UAAU,qBAAqB,CAAC,SAAS,EAAE,OAAO;AAE9D,CAAC;AAEM,KAAK,UAAU,gBAAgB,CAAC,OAAO;AAE9C,CAAC"}

View File

@@ -1,26 +1,12 @@
import pool from '../db'; import pool from '../db';
import { CalendarEventShort, CalendarSignup, CalendarEvent, CalendarAttendance } from "@app/shared/types/calendar"
export interface CalendarEvent { import { toDateTime } from "@app/shared/utils/time"
id: number;
name: string;
start: Date; // DATETIME -> Date
end: Date; // DATETIME -> Date
location: string;
color: string; // 7 character hex string
description?: string | null;
creator?: number | null; // foreign key to members.id, nullable
cancelled: boolean; // TINYINT(1) -> boolean
created_at: Date; // TIMESTAMP -> Date
updated_at: Date; // TIMESTAMP -> Date
}
export type Attendance = 'attending' | 'maybe' | 'not_attending';
export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'created_at' | 'updated_at' | 'cancelled'>) { export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'created_at' | 'updated_at' | 'cancelled'>) {
const sql = ` const sql = `
INSERT INTO calendar_events INSERT INTO calendar_events
(name, start, end, location, color, description, creator) (name, start, end, location, color, description, creator)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`; `;
const params = [ const params = [
eventObject.name, eventObject.name,
@@ -29,7 +15,7 @@ export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'creat
eventObject.location, eventObject.location,
eventObject.color, eventObject.color,
eventObject.description ?? null, eventObject.description ?? null,
eventObject.creator, eventObject.creator_id,
]; ];
const result = await pool.query(sql, params); const result = await pool.query(sql, params);
@@ -40,7 +26,6 @@ export async function updateEvent(eventObject: CalendarEvent) {
if (!eventObject.id) { if (!eventObject.id) {
throw new Error("updateEvent: Missing event ID."); throw new Error("updateEvent: Missing event ID.");
} }
const sql = ` const sql = `
UPDATE calendar_events UPDATE calendar_events
SET SET
@@ -49,14 +34,14 @@ export async function updateEvent(eventObject: CalendarEvent) {
end = ?, end = ?,
location = ?, location = ?,
color = ?, color = ?,
description = ?, description = ?
WHERE id = ? WHERE id = ?
`; `;
const params = [ const params = [
eventObject.name, eventObject.name,
eventObject.start, toDateTime(eventObject.start),
eventObject.end, toDateTime(eventObject.end),
eventObject.location, eventObject.location,
eventObject.color, eventObject.color,
eventObject.description ?? null, eventObject.description ?? null,
@@ -67,28 +52,30 @@ export async function updateEvent(eventObject: CalendarEvent) {
return { success: true }; return { success: true };
} }
export async function cancelEvent(eventID: number) { export async function setEventCancelled(eventID: number, cancelled: boolean) {
const input = cancelled ? 1 : 0;
const sql = ` const sql = `
UPDATE calendar_events UPDATE calendar_events
SET cancelled = 1 SET cancelled = ?
WHERE id = ? WHERE id = ?
`; `;
await pool.query(sql, [eventID]); await pool.query(sql, [input, eventID]);
return { success: true }; return { success: true };
} }
export async function getShortEventsInRange(startDate: Date, endDate: Date) { export async function getShortEventsInRange(startDate: string, endDate: string): Promise<CalendarEventShort[]> {
const sql = ` const sql = `
SELECT id, name, start, end, color SELECT id, name, start, end, color, cancelled, full_day
FROM calendar_events FROM calendar_events
WHERE start BETWEEN ? AND ? WHERE start BETWEEN ? AND ?
ORDER BY start ASC ORDER BY start ASC
`; `;
return await pool.query(sql, [startDate, endDate]); const res: CalendarEventShort[] = await pool.query(sql, [startDate, endDate]);
return res;
} }
export async function getEventDetails(eventID: number) { export async function getEventDetails(eventID: number): Promise<CalendarEvent> {
const sql = ` const sql = `
SELECT SELECT
e.id, e.id,
@@ -101,14 +88,14 @@ export async function getEventDetails(eventID: number) {
e.cancelled, e.cancelled,
e.created_at, e.created_at,
e.updated_at, e.updated_at,
m.id AS creator_id, e.creator AS creator_id,
m.name AS creator_name m.name AS creator_name
FROM calendar_events e FROM calendar_events e
LEFT JOIN members m ON e.creator = m.id LEFT JOIN members m ON e.creator = m.id
WHERE e.id = ? WHERE e.id = ?
`; `;
let vals: CalendarEvent[] = await pool.query(sql, [eventID]);
return await pool.query(sql, [eventID]) return vals[0];
} }
export async function getUpcomingEvents(date: Date, limit: number) { export async function getUpcomingEvents(date: Date, limit: number) {
@@ -124,7 +111,7 @@ export async function getUpcomingEvents(date: Date, limit: number) {
} }
export async function setAttendanceStatus(memberID: number, eventID: number, status: Attendance) { export async function setAttendanceStatus(memberID: number, eventID: number, status: CalendarAttendance) {
const sql = ` const sql = `
INSERT INTO calendar_events_signups (member_id, event_id, status) INSERT INTO calendar_events_signups (member_id, event_id, status)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -135,7 +122,7 @@ export async function setAttendanceStatus(memberID: number, eventID: number, sta
return { success: true } return { success: true }
} }
export async function getEventAttendance(eventID: number) { export async function getEventAttendance(eventID: number): Promise<CalendarSignup[]> {
const sql = ` const sql = `
SELECT SELECT
s.member_id, s.member_id,

View File

@@ -0,0 +1,33 @@
import z from "zod";
const dateRe = /^\d{4}-\d{2}-\d{2}$/ // YYYY-MM-DD
const timeRe = /^(?:[01]\d|2[0-3]):[0-5]\d$/ // HH:mm (24h)\
export function parseLocalDateTime(dateStr: string, timeStr: string) {
// Construct a Date in the user's local timezone
const [y, m, d] = dateStr.split("-").map(Number)
const [hh, mm] = timeStr.split(":").map(Number)
return new Date(y, m - 1, d, hh, mm, 0, 0)
}
export const calendarEventSchema = z.object({
name: z.string().min(2, "Please enter at least 2 characters").max(100),
startDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
startTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
endDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
endTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
location: z.string().max(200).default(""),
color: z.string().regex(/^#([0-9A-Fa-f]{6})$/, "Use a hex color like #AABBCC"),
description: z.string().max(2000).default(""),
id: z.number().int().nonnegative().nullable().default(null),
}).superRefine((vals, ctx) => {
const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime)
if (!(end > start)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End must be after start",
path: ["endTime"], // attach to a visible field
})
}
})

39
shared/types/calendar.ts Normal file
View File

@@ -0,0 +1,39 @@
export interface CalendarEvent {
id?: number;
name: string;
start: Date;
end: Date;
location: string;
color: string;
description: string;
creator_id?: number;
cancelled?: boolean;
created_at?: Date;
updated_at?: Date;
creator_name?: string | null;
eventSignups?: CalendarSignup[] | null;
}
export enum CalendarAttendance {
Attending = "attending",
NotAttending = "not_attending",
Maybe = "maybe"
}
export interface CalendarSignup {
member_id: number;
eventID: number;
status: CalendarAttendance;
member_name?: string;
}
export interface CalendarEventShort {
id: number;
name: string;
start: Date;
end: Date;
color: string;
cancelled: boolean;
full_day: boolean;
}

11
shared/utils/time.ts Normal file
View File

@@ -0,0 +1,11 @@
export function toDateTime(date: Date): string {
// This produces a CST-local time because server runs in CST
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
const hour = date.getHours().toString().padStart(2, "0");
const minute = date.getMinutes().toString().padStart(2, "0");
const second = date.getSeconds().toString().padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}

View File

@@ -1,13 +1,13 @@
export interface CalendarEvent { // export interface CalendarEvent {
name: string, // name: string,
start: Date, // start: Date,
end: Date, // end: Date,
location: string, // location: string,
color: string, // color: string,
description: string, // description: string,
creator: any | null, // user object // creator: any | null, // user object
id: number | null // id: number | null
} // }
export enum CalendarAttendance { export enum CalendarAttendance {
Attending = "attending", Attending = "attending",
@@ -21,22 +21,107 @@ export interface CalendarSignup {
state: CalendarAttendance state: CalendarAttendance
} }
export async function createCalendarEvent(eventData: CalendarEvent) { import { CalendarEventShort, CalendarEvent } from "@shared/types/calendar";
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getMonthCalendarEvents(viewedMonth: Date): Promise<CalendarEventShort[]> {
const year = viewedMonth.getFullYear();
const month = viewedMonth.getMonth();
// Base range: first and last day of the month
const firstOfMonth = new Date(year, month, 1);
const lastOfMonth = new Date(year, month + 1, 0);
// --- Apply 10 day padding ---
const start = new Date(firstOfMonth);
start.setDate(start.getDate() - 10);
const end = new Date(lastOfMonth);
end.setDate(end.getDate() + 10);
end.setHours(23, 59, 59, 999);
const from = start.toISOString();
const to = end.toISOString();
const url = `${addr}/calendar?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch events: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function getCalendarEvent(id: number): Promise<CalendarEvent> {
let res = await fetch(`${addr}/calendar/${id}`);
if (res.ok) {
return await res.json();
} else {
throw new Error(`Failed to fetch event: ${res.status} ${res.statusText}`);
}
}
export async function createCalendarEvent(eventData: CalendarEvent) {
let res = await fetch(`${addr}/calendar`, {
method: "POST",
credentials: "include",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventData)
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function editCalendarEvent(eventData: CalendarEvent) { export async function editCalendarEvent(eventData: CalendarEvent) {
let res = await fetch(`${addr}/calendar`, {
method: "PUT",
credentials: "include",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventData)
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }
export async function cancelCalendarEvent(eventID: number) { export async function setCancelCalendarEvent(eventID: number, cancel: boolean) {
let route = cancel ? "cancel" : "uncancel";
console.log(route);
let res = await fetch(`${addr}/calendar/${eventID}/${route}`, {
method: "POST",
credentials: "include"
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
} }
export async function adminCancelCalendarEvent(eventID: number) {
} }
export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) { export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) {
let res = await fetch(`${addr}/calendar/${eventID}/attendance?state=${state}`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
return;
} else {
throw new Error(`Failed to set attendance: ${res.status} ${res.statusText}`);
}
} }

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { toTypedSchema } from "@vee-validate/zod" import { toTypedSchema } from "@vee-validate/zod"
import { ref, defineExpose, watch } from "vue" import { ref, defineExpose, watch, nextTick } from "vue"
import * as z from "zod" import * as z from "zod"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -21,11 +21,18 @@ import {
} from "@/components/ui/form" } from "@/components/ui/form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import Textarea from "../ui/textarea/Textarea.vue" import Textarea from "../ui/textarea/Textarea.vue"
import { CalendarEvent } from "@/api/calendar" import { CalendarEvent } from "@shared/types/calendar"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
// ---------- helpers ----------
const dateRe = /^\d{4}-\d{2}-\d{2}$/ // YYYY-MM-DD
const timeRe = /^(?:[01]\d|2[0-3]):[0-5]\d$/ // HH:mm (24h)
function toLocalDateString(d: Date) { function toLocalDateString(d: Date) {
// yyyy-MM-dd with local time zone // yyyy-MM-dd with local time zone
@@ -45,45 +52,50 @@ function roundToNextHour(d = new Date()) {
t.setHours(t.getHours() + 1) t.setHours(t.getHours() + 1)
return t return t
} }
function parseLocalDateTime(dateStr: string, timeStr: string) {
// Construct a Date in the user's local timezone
const [y, m, d] = dateStr.split("-").map(Number)
const [hh, mm] = timeStr.split(":").map(Number)
return new Date(y, m - 1, d, hh, mm, 0, 0)
}
// ---------- schema ---------- import { calendarEventSchema, parseLocalDateTime } from '@shared/schemas/calendarEventSchema'
const zEvent = z.object({ import { createCalendarEvent, editCalendarEvent } from "@/api/calendar"
name: z.string().min(2, "Please enter at least 2 characters").max(100), import DialogDescription from "../ui/dialog/DialogDescription.vue"
startDate: z.string().regex(dateRe, "Use YYYY-MM-DD"), const formSchema = toTypedSchema(calendarEventSchema)
startTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
endDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
endTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
location: z.string().max(200).default(""),
color: z.string().regex(/^#([0-9A-Fa-f]{6})$/, "Use a hex color like #AABBCC"),
description: z.string().max(2000).default(""),
id: z.number().int().nonnegative().nullable().default(null),
}).superRefine((vals, ctx) => {
const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime)
if (!(end > start)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End must be after start",
path: ["endTime"], // attach to a visible field
})
}
})
const formSchema = toTypedSchema(zEvent)
// ---------- dialog state & defaults ---------- // ---------- dialog state & defaults ----------
const clickedDate = ref<string | null>(null);
const dialogOpen = ref(false) const dialogOpen = ref(false)
function openDialog() { dialogOpen.value = true } const dialogMode = ref<'create' | 'edit'>('create');
const editEvent = ref<CalendarEvent | null>();
function openDialog(dateStr?: string, mode?: 'create' | 'edit', event?: CalendarEvent) {
dialogMode.value = mode ?? 'create';
editEvent.value = event ?? null;
clickedDate.value = dateStr ?? null;
dialogOpen.value = true
initialValues.value = makeInitialValues()
}
defineExpose({ openDialog }) defineExpose({ openDialog })
function makeInitialValues() { function makeInitialValues() {
const start = roundToNextHour()
if (dialogMode.value === 'edit' && editEvent.value) {
const e = editEvent.value;
return {
name: e.name,
startDate: toLocalDateString(new Date(e.start)),
startTime: toLocalTimeString(new Date(e.start)),
endDate: toLocalDateString(new Date(e.end)),
endTime: toLocalTimeString(new Date(e.end)),
location: e.location,
color: e.color,
description: e.description,
id: e.id,
}
}
let start: Date;
if (clickedDate.value) {
const local = new Date(clickedDate.value + "T20:00:00");
start = local;
} else {
start = roundToNextHour();
}
const end = new Date(start.getTime() + 60 * 60 * 1000) const end = new Date(start.getTime() + 60 * 60 * 1000)
return { return {
name: "", name: "",
@@ -92,50 +104,77 @@ function makeInitialValues() {
endDate: toLocalDateString(end), endDate: toLocalDateString(end),
endTime: toLocalTimeString(end), endTime: toLocalTimeString(end),
location: "", location: "",
color: "#3b82f6", color: "#6890ee",
description: "", description: "",
id: null as number | null, id: null as number | null,
} }
} }
const initialValues = ref(makeInitialValues()) const initialValues = ref(null)
const formKey = ref(0) const formKey = ref(0)
watch(dialogOpen, (isOpen) => { watch(dialogOpen, async (isOpen) => {
if (!isOpen) { if (isOpen) {
formKey.value++ // remounts the form -> picks up fresh initialValues await nextTick();
formRef.value?.resetForm({ values: makeInitialValues() })
} }
}) })
// ---------- submit ---------- // ---------- submit ----------
function onSubmit(vals: z.infer<typeof zEvent>) { async function onSubmit(vals: z.infer<typeof calendarEventSchema>) {
const start = parseLocalDateTime(vals.startDate, vals.startTime) const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime) const end = parseLocalDateTime(vals.endDate, vals.endTime)
const event: CalendarEvent = { const event: CalendarEvent = {
id: vals.id ?? null,
name: vals.name, name: vals.name,
start, start,
end, end,
location: vals.location, location: vals.location,
color: vals.color, color: vals.color,
description: vals.description, description: vals.description,
id: null,
creator: null
} }
console.log("Submitting CalendarEvent:", event) try {
if (dialogMode.value === "edit") {
await editCalendarEvent(event);
} else {
await createCalendarEvent(event);
}
emit('reload');
} catch (error) {
console.error(error);
}
// close after success // close after success
dialogOpen.value = false dialogOpen.value = false
} }
const emit = defineEmits<{
(e: 'reload'): void
}>()
const formRef = ref(null);
const colorOptions = [
{ name: "Blue", hex: "#6890ee" },
{ name: "Purple", hex: "#a25fce" },
{ name: "Orange", hex: "#fba037" },
{ name: "Green", hex: "#6cd265" },
{ name: "Red", hex: "#ff5959" },
];
function getColorName(hex: string) {
return colorOptions.find(c => c.hex === hex)?.name ?? hex
}
</script> </script>
<template> <template>
<Form :key="formKey" v-slot="{ handleSubmit, resetForm }" :validation-schema="formSchema" <Form ref="formRef" :key="formKey" v-slot="{ handleSubmit, resetForm }" :validation-schema="formSchema"
:initial-values="initialValues" keep-values as=""> :initial-values="initialValues" keep-values as="">
<Dialog v-model:open="dialogOpen"> <Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[520px]"> <DialogContent class="sm:max-w-[520px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Create Event</DialogTitle> <DialogTitle>{{ dialogMode == "edit" ? 'Edit Event' : 'Create Event' }}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader> </DialogHeader>
<form id="dialogForm" class="grid grid-cols-1 gap-4" <form id="dialogForm" class="grid grid-cols-1 gap-4"
@@ -150,21 +189,48 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
<!-- Color --> <!-- Color -->
<div class="w-[60px]"> <div class="w-[120px]">
<FormField v-slot="{ componentField }" name="color"> <FormField v-slot="{ componentField }" name="color">
<FormItem> <FormItem>
<FormLabel>Color</FormLabel> <FormLabel>Color</FormLabel>
<FormControl> <FormControl>
<Input type="color" class="h-[38px] p-1 cursor-pointer" <Select :modelValue="componentField.modelValue"
v-bind="componentField" /> @update:modelValue="componentField.onChange">
<SelectTrigger>
<SelectValue asChild>
<template #default="{ selected }">
<div class="flex items-center gap-2 w-[70px]">
<span class="inline-block size-4 rounded"
:style="{ background: componentField.modelValue }">
</span>
<span>{{ getColorName(componentField.modelValue) }}</span>
</div>
</template>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in colorOptions" :key="opt.hex" :value="opt.hex">
<div class="flex items-center gap-2">
<span class="inline-block size-4 rounded"
:style="{ background: opt.hex }"></span>
<span>{{ opt.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -180,7 +246,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormControl> <FormControl>
<Input type="date" v-bind="componentField" /> <Input type="date" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -191,7 +259,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
<!-- If you ever want native picker: type="time" --> <!-- If you ever want native picker: type="time" -->
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -204,7 +274,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormControl> <FormControl>
<Input type="date" v-bind="componentField" /> <Input type="date" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -214,7 +286,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
@@ -226,7 +300,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormControl> <FormControl>
<Input type="text" v-bind="componentField" /> <Input type="text" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage /> <FormMessage />
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -236,9 +312,11 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea class="resize-none h-32" v-bind="componentField" /> <Textarea class="resize-none h-32 scrollbar-themed" v-bind="componentField" />
</FormControl> </FormControl>
<div class="h-3">
<FormMessage/> <FormMessage/>
</div>
</FormItem> </FormItem>
</FormField> </FormField>
@@ -249,9 +327,39 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
</form> </form>
<DialogFooter> <DialogFooter>
<Button type="submit" form="dialogForm">Create</Button> <Button type="submit" form="dialogForm">{{ dialogMode == "edit" ? 'Update' : 'Create' }}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Form> </Form>
</template> </template>
<style scoped>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import type { CalendarEvent, CalendarSignup } from '@shared/types/calendar'
import { CircleAlert, Clock, EllipsisVertical, MapPin, User, X } from 'lucide-vue-next';
import { computed, onMounted, ref, watch } from 'vue';
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
import Button from '../ui/button/Button.vue';
import { CalendarAttendance, getCalendarEvent, setCalendarEventAttendance, setCancelCalendarEvent } from '@/api/calendar';
import { useUserStore } from '@/stores/user';
import { useRoute } from 'vue-router';
import DropdownMenu from '../ui/dropdown-menu/DropdownMenu.vue';
import DropdownMenuTrigger from '../ui/dropdown-menu/DropdownMenuTrigger.vue';
import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue';
import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue';
const route = useRoute();
// const eventID = computed(() => {
// const id = route.params.id;
// if (typeof id === 'string') return id;
// return undefined;
// });
const loaded = ref<boolean>(false);
const activeEvent = ref<CalendarEvent | null>(null);
// onMounted(async () => {
// let eventID = route.params.id;
// console.log(eventID);
// activeEvent.value = await getCalendarEvent(Number(eventID));
// loaded.value = true;
// });
watch(
() => route.params.id,
async (id) => {
if (!id) return;
activeEvent.value = await getCalendarEvent(Number(id));
loaded.value = true;
},
{ immediate: true }
);
const emit = defineEmits<{
(e: 'close'): void
(e: 'reload'): void
(e: 'edit', event: CalendarEvent): void
}>()
// const activeEvent = computed(() => props.event)
const startFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit'
})
const endFmt = new Intl.DateTimeFormat(undefined, {
hour: 'numeric', minute: '2-digit'
})
const whenText = computed(() => {
if (!activeEvent.value?.start) return ''
const s = new Date(activeEvent.value.start)
const e = activeEvent.value?.end ? new Date(activeEvent.value.end) : null
return e
? `${startFmt.format(s)} ${endFmt.format(e)}`
: `${startFmt.format(s)}`
})
const attending = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Attending) })
const maybe = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Maybe) })
const declined = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.NotAttending) })
const viewedState = ref<CalendarAttendance>(CalendarAttendance.Attending);
let user = useUserStore();
const myAttendance = computed<CalendarSignup | null>(() => {
return activeEvent.value.eventSignups.find(
(s) => s.member_id === user.user.id
) || null;
});
async function setAttendance(state: CalendarAttendance) {
await setCalendarEventAttendance(activeEvent.value.id, state);
//refresh event data
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
const canEditEvent = computed(() => {
if (user.user.id == activeEvent.value.creator_id)
return true;
});
async function setCancel(isCancelled: boolean) {
await setCancelCalendarEvent(activeEvent.value.id, isCancelled);
emit("reload");
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
async function forceReload() {
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
}
defineExpose({forceReload})
</script>
<template>
<div v-if="loaded">
<!-- Header -->
<div class="flex items-center justify-between gap-3 border-b px-4 py-3">
<h2 class="text-lg font-semibold break-all">
{{ activeEvent?.name || 'Event' }}
</h2>
<div class="flex gap-4">
<DropdownMenu v-if="canEditEvent">
<DropdownMenuTrigger>
<button
class="inline-flex flex-none size-8 items-center justify-center cursor-pointer rounded-md hover:bg-muted/40 transition">
<EllipsisVertical class="size-6" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="emit('edit', activeEvent)">
Edit
</DropdownMenuItem>
<DropdownMenuItem v-if="activeEvent.cancelled"
@click="setCancel(false)">
Un-Cancel
</DropdownMenuItem>
<DropdownMenuItem v-else @click="setCancel(true)">
Cancel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
class="inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition cursor-pointer"
aria-label="Close" @click="emit('close')">
<X class="size-4" />
</button>
</div>
</div>
<!-- Body -->
<div class="flex-1 flex flex-col items-center min-h-0 overflow-y-auto px-4 py-4 space-y-6 w-full">
<section v-if="activeEvent.cancelled == true" class="w-full">
<div class="flex p-2 rounded-md w-full bg-destructive gap-3">
<CircleAlert></CircleAlert> This event has been cancelled
</div>
</section>
<section class="w-full">
<ButtonGroup class="flex w-full">
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.Attending)">Going</Button>
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.Maybe ? 'border-2 !border-l-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.Maybe)">Maybe</Button>
<Button variant="outline"
:class="myAttendance?.status === CalendarAttendance.NotAttending ? 'border-2 !border-l-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.NotAttending)">Declined</Button>
</ButtonGroup>
</section>
<!-- When -->
<section v-if="whenText" class="space-y-2 w-full">
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<Clock class="size-4 opacity-80" />
<span class="font-medium">{{ whenText }}</span>
</div>
</section>
<!-- Quick meta chips -->
<section class="flex flex-wrap gap-2 w-full">
<span class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<MapPin class="size-3.5 opacity-80" />
<span class="font-medium">{{ activeEvent.location || "Unknown" }}</span>
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<User class="size-3.5 opacity-80" />
<span class="font-medium">Created by: {{ activeEvent.creator_name || "Unknown User"
}}</span>
</span>
</section>
<!-- Description -->
<section class="space-y-2 w-full">
<p class="text-lg font-semibold">Description</p>
<p class="border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2 whitespace-pre-line">
{{ activeEvent.description }}
</p>
</section>
<!-- Attendance -->
<section class="space-y-2 w-full">
<p class="text-lg font-semibold">Attendance</p>
<div class="flex flex-col border bg-muted/50 rounded-lg min-h-24 my-2">
<div class="flex w-full pt-2 border-b *:w-full *:text-center *:pb-1 *:cursor-pointer">
<label
:class="viewedState === CalendarAttendance.Attending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.Attending">Going {{ attending.length }}</label>
<label
:class="viewedState === CalendarAttendance.Maybe ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.Maybe">Maybe {{ maybe.length }}</label>
<label
:class="viewedState === CalendarAttendance.NotAttending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="viewedState = CalendarAttendance.NotAttending">Declined {{ declined.length
}}</label>
</div>
<div class="px-5 py-4 min-h-28">
<div v-if="viewedState === CalendarAttendance.Attending" v-for="person in attending">
<p>{{ person.member_name }}</p>
</div>
<div v-if="viewedState === CalendarAttendance.Maybe" v-for="person in maybe">
<p>{{ person.member_name }}</p>
</div>
<div v-if="viewedState === CalendarAttendance.NotAttending" v-for="person in declined">
<p>{{ person.member_name }}</p>
</div>
</div>
</div>
</section>
</div>
<!-- Footer (optional actions) -->
<!-- <div class="border-t px-4 py-3 flex items-center justify-end gap-2">
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
Edit
</button>
<button
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
Open details
</button>
</div> -->
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import { cn } from "@/lib/utils";
import { buttonGroupVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
});
</script>
<template>
<div
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:class="
cn(buttonGroupVariants({ orientation: props.orientation }), props.class)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { cn } from "@/lib/utils";
import { Separator } from '@/components/ui/separator';
const props = defineProps({
orientation: { type: String, required: false, default: "vertical" },
decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Separator
data-slot="button-group-separator"
v-bind="delegatedProps"
:orientation="props.orientation"
:class="
cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: "div" },
});
</script>
<template>
<Primitive
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:as="as"
:as-child="asChild"
:class="
cn(
'bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,22 @@
import { cva } from "class-variance-authority";
export { default as ButtonGroup } from "./ButtonGroup.vue";
export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue";
export { default as ButtonGroupText } from "./ButtonGroupText.vue";
export const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);

View File

@@ -0,0 +1,28 @@
import { ref, watch, onMounted } from "vue";
import { getMonthCalendarEvents } from "@/api/calendar";
import type { CalendarEventShort } from "@shared/types/calendar";
export function useCalendarEvents(selectedMonth, selectedYear) {
const events = ref([]);
function toCalEvent(e: CalendarEventShort) {
return {
id: e.id.toString(),
title: e.name,
start: new Date(e.start),
end: e.end ? new Date(e.end) : undefined,
extendedProps: { color: e.color, cancelled: !!e.cancelled },
};
}
async function loadEvents() {
const date = new Date(selectedYear.value, selectedMonth.value, 1);
const monthEvents = await getMonthCalendarEvents(date);
events.value = monthEvents.map(toCalEvent);
}
watch([selectedMonth, selectedYear], loadEvents);
onMounted(loadEvents);
return { events, loadEvents };
}

View File

@@ -0,0 +1,33 @@
import { ref } from "vue";
export function useCalendarNavigation(calendarApiGetter: () => any) {
const selectedMonth = ref(new Date().getMonth());
const selectedYear = ref(new Date().getFullYear());
const years = Array.from({ length: 41 }, (_, i) => selectedYear.value - 20 + i);
function goPrev() { calendarApiGetter()?.prev(); }
function goNext() { calendarApiGetter()?.next(); }
function goToday() { calendarApiGetter()?.today(); }
function onDatesSet() {
const d = calendarApiGetter()?.getDate() ?? new Date();
selectedMonth.value = d.getMonth();
selectedYear.value = d.getFullYear();
}
function goToSelectedDate() {
calendarApiGetter()?.gotoDate(new Date(selectedYear.value, selectedMonth.value, 1));
}
return {
selectedMonth,
selectedYear,
years,
goPrev,
goNext,
goToday,
onDatesSet,
goToSelectedDate,
};
}

View File

@@ -5,85 +5,50 @@ import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next' import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue' import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue'
import { getCalendarEvent, getMonthCalendarEvents } from '@/api/calendar'
import { CalendarEvent, CalendarEventShort } from '@shared/types/calendar'
import { Calendar } from '@fullcalendar/core'
import ViewCalendarEvent from '@/components/calendar/ViewCalendarEvent.vue'
import { useRouter, useRoute } from 'vue-router'
import { useCalendarEvents } from '@/composables/useCalendarEvents'
import { useCalendarNavigation } from '@/composables/useCalendarNavigation'
const monthLabels = [ const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December' 'July', 'August', 'September', 'October', 'November', 'December'
] ]
const selectedMonth = ref<number>(new Date().getMonth())
const selectedYear = ref<number>(new Date().getFullYear())
const years = Array.from({ length: 41 }, (_, i) => selectedYear.value - 20 + i) // +/- 20 yrs
function api() { function api() {
return calendarRef.value?.getApi() return calendarRef.value?.getApi()
} }
// keep dropdowns in sync whenever the calendar navigates const router = useRouter();
function onDatesSet() { const route = useRoute();
const d = api()?.getDate() ?? new Date()
selectedMonth.value = d.getMonth()
selectedYear.value = d.getFullYear()
}
function buildFullDate(month: number, year: number): Date { function buildFullDate(month: number, year: number): Date {
return new Date(year, month, 1); //default to first of month return new Date(year, month, 1); //default to first of month
} }
const { selectedMonth, selectedYear, years, goPrev, goNext, goToday, onDatesSet, goToSelectedDate } = useCalendarNavigation(api)
watch([selectedMonth, selectedYear], () => { const { events, loadEvents} = useCalendarEvents(selectedMonth, selectedYear);
console.log('Selected date changed:', selectedMonth.value, selectedYear.value)
})
onMounted(() => {
// fetchEventsFor(selectedMonth.value, selectedYear.value)
})
function goPrev() { api()?.prev() }
function goNext() { api()?.next() }
function goToday() { api()?.today() }
// jump to the selected month/year
function goToSelectedDate() {
api()?.gotoDate(new Date(selectedYear.value, selectedMonth.value, 1))
}
type CalEvent = {
id: string
title: string
start: string
end?: string
extendedProps?: Record<string, any>
}
const events = ref<CalEvent[]>([
{ id: '1', title: 'Squad Training', start: '2025-10-08T19:00:00', extendedProps: { trainer: 'Alex', location: 'Range A', color: '#88C4FF' } },
{ id: '2', title: 'Ops Briefing', start: '2025-10-09T20:30:00', extendedProps: { owner: 'CO', agenda: ['Weather', 'Route', 'Risks'], color: '#dba42c' } },
])
const panelOpen = ref(false) const panelOpen = ref(false)
const activeEvent = ref<CalEvent | null>(null) const activeEvent = ref<CalendarEvent | null>(null)
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null) const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
function onEventClick(arg: any) { async function onEventClick(arg: any) {
activeEvent.value = { const targetEvent = arg.event.id;
id: arg.event.id, router.push(`/calendar/event/${targetEvent}`)
title: arg.event.title,
start: arg.event.startStr,
end: arg.event.endStr,
extendedProps: arg.event.extendedProps
}
panelOpen.value = true panelOpen.value = true
} }
const currentEventID = ref<number | null>(null);
const dialogRef = ref<any>(null) const dialogRef = ref<any>(null)
// NEW: handle day/time slot clicks to start creating an event // NEW: handle day/time slot clicks to start creating an event
function onDateClick(arg: { dateStr: string }) { function onDateClick(arg: { dateStr: string }) {
dialogRef.value?.openDialog(); dialogRef.value?.openDialog(arg.dateStr);
// For now, just open the panel with a draft payload. // For now, just open the panel with a draft payload.
// activeEvent.value = { // activeEvent.value = {
// id: '__draft__', // id: '__draft__',
@@ -109,7 +74,7 @@ const calendarOptions = ref({
navLinks: false, navLinks: false,
dateClick: onDateClick, dateClick: onDateClick,
eventClick: onEventClick, eventClick: onEventClick,
editable: true, editable: false,
// force block-mode in dayGrid so we can lay it out on one line // force block-mode in dayGrid so we can lay it out on one line
eventDisplay: 'block', eventDisplay: 'block',
@@ -123,12 +88,25 @@ const calendarOptions = ref({
// custom renderer -> one-line pill // custom renderer -> one-line pill
eventContent(arg) { eventContent(arg) {
//debug
// console.log("Rendering event:", {
// id: arg.event.id,
// title: arg.event.title,
// extendedProps: arg.event.extendedProps,
// fullEvent: arg.event
// })
const ext = arg.event.extendedProps || {} const ext = arg.event.extendedProps || {}
const c = ext.color || arg.backgroundColor || arg.borderColor || '' const color = ext.color || arg.backgroundColor || arg.borderColor || ''
const isCancelled = !!ext.cancelled;
const wrap = document.createElement('div') const wrap = document.createElement('div')
wrap.className = 'ev-pill' wrap.className = 'ev-pill'
if (c) wrap.style.setProperty('--ev-color', String(c)) // dot color if (color) wrap.style.setProperty('--ev-color', String(color)) // dot color
if (isCancelled) {
wrap.classList.add('is-cancelled')
}
const dot = document.createElement('span') const dot = document.createElement('span')
dot.className = 'ev-dot' dot.className = 'ev-dot'
@@ -149,6 +127,16 @@ const calendarOptions = ref({
//@ts-ignore (shhh) //@ts-ignore (shhh)
calendarOptions.value.datesSet = onDatesSet calendarOptions.value.datesSet = onDatesSet
watch(() => route.params.id, async (newID) => {
if (newID === undefined) {
panelOpen.value = false;
currentEventID.value = null;
} else {
panelOpen.value = true;
currentEventID.value = Number(newID);
}
}, { immediate: true })
watch(panelOpen, async () => { watch(panelOpen, async () => {
await nextTick() await nextTick()
@@ -156,39 +144,22 @@ watch(panelOpen, async () => {
}) })
const startFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit'
})
const endFmt = new Intl.DateTimeFormat(undefined, {
hour: 'numeric', minute: '2-digit'
})
const whenText = computed(() => {
if (!activeEvent.value?.start) return ''
const s = new Date(activeEvent.value.start)
const e = activeEvent.value?.end ? new Date(activeEvent.value.end) : null
return e
? `${startFmt.format(s)} ${endFmt.format(e)}`
: `${startFmt.format(s)}`
})
function onCreateEvent() { function onCreateEvent() {
const iso = new Date().toISOString().slice(0, 10) // YYYY-MM-DD const iso = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
onDateClick({ dateStr: iso }) onDateClick({ dateStr: iso })
} }
const eventViewRef = ref(null);
onMounted(() => { onMounted(() => {
onDatesSet() onDatesSet()
}) })
const ext = computed(() => activeEvent.value?.extendedProps ?? {})
</script> </script>
<template> <template>
<CreateCalendarEvent ref="dialogRef"></CreateCalendarEvent> <div>
<CreateCalendarEvent ref="dialogRef" @reload="loadEvents(); eventViewRef.forceReload();"></CreateCalendarEvent>
<div class="flex"> <div class="flex">
<div class="flex-1 min-h-0 mt-5"> <div class="flex-1 min-h-0 mt-5">
<div class="h-[80vh] min-h-0"> <div class="h-[80vh] min-h-0">
@@ -199,15 +170,14 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
<!-- <h2 class="text-xl font-semibold tracking-tight"> <!-- <h2 class="text-xl font-semibold tracking-tight">
{{ monthLabels[selectedMonth] }} {{ selectedYear }} {{ monthLabels[selectedMonth] }} {{ selectedYear }}
</h2> --> </h2> -->
<!-- Month dropdown --> <!-- Month dropdown -->
<select v-model.number="selectedMonth" @change="goToSelectedDate" <select v-model.number="selectedMonth" @change="goToSelectedDate"
class="ml-2 rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select month"> class="ml-2 rounded-md border bg-transparent px-2 py-1 text-sm"
aria-label="Select month">
<option v-for="(m, i) in monthLabels" :key="m" :value="i" class="bg-card"> <option v-for="(m, i) in monthLabels" :key="m" :value="i" class="bg-card">
{{ m }} {{ m }}
</option> </option>
</select> </select>
<!-- Year dropdown --> <!-- Year dropdown -->
<select v-model.number="selectedYear" @change="goToSelectedDate" <select v-model.number="selectedYear" @change="goToSelectedDate"
class="rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select year"> class="rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select year">
@@ -216,7 +186,6 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
</option> </option>
</select> </select>
</div> </div>
<!-- Right: nav + today + create --> <!-- Right: nav + today + create -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
@@ -240,109 +209,63 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
Create Create
</button> </button>
</div> </div>
</div> </div>
<FullCalendar ref="calendarRef" :options="calendarOptions" /> <FullCalendar ref="calendarRef" :options="calendarOptions" />
</div> </div>
</div> </div>
<aside v-if="panelOpen"
<aside v-if="panelOpen" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }"> :style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<!-- Header --> <ViewCalendarEvent ref="eventViewRef" @close="() => { router.push('/calendar'); }" @reload="loadEvents()" @edit="(val) => {dialogRef.openDialog(null, 'edit', val)}">
<div class="flex items-center justify-between gap-3 border-b px-4 py-3"> </ViewCalendarEvent>
<h2 class="text-lg font-semibold line-clamp-2">
{{ activeEvent?.title || 'Event' }}
</h2>
<button
class="inline-flex size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition"
aria-label="Close" @click="panelOpen = false">
<X class="size-4" />
</button>
</div>
<!-- Body -->
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-6">
<!-- When -->
<section v-if="whenText" class="space-y-2">
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<Clock class="size-4 opacity-80" />
<span class="font-medium">{{ whenText }}</span>
</div>
</section>
<!-- Quick meta chips -->
<section class="flex flex-wrap gap-2">
<span v-if="ext.location"
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<MapPin class="size-3.5 opacity-80" />
<span class="font-medium">{{ ext.location }}</span>
</span>
<span v-if="ext.owner" class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<User class="size-3.5 opacity-80" />
<span class="font-medium">Owner: {{ ext.owner }}</span>
</span>
<span v-if="ext.trainer"
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<User class="size-3.5 opacity-80" />
<span class="font-medium">Trainer: {{ ext.trainer }}</span>
</span>
</section>
<!-- Agenda (special-cased array) -->
<section v-if="Array.isArray(ext.agenda) && ext.agenda.length" class="space-y-2">
<div class="flex items-center gap-2 text-sm font-medium">
<ListTodo class="size-4 opacity-80" />
Agenda
</div>
<ul class="space-y-1.5">
<li v-for="(item, i) in ext.agenda" :key="i" class="flex items-start gap-2 text-sm">
<span class="mt-1.5 size-1.5 rounded-full bg-foreground/50"></span>
<span>{{ item }}</span>
</li>
</ul>
</section>
<!-- Generic details (extendedProps minus the ones above) -->
<section v-if="ext && Object.keys(ext).length" class="space-y-3">
<div class="text-sm font-medium opacity-80">Details</div>
<dl class="grid grid-cols-1 gap-y-3">
<template v-for="(val, key) in ext" :key="key">
<template v-if="!['agenda', 'owner', 'trainer', 'location'].includes(String(key))">
<div class="grid grid-cols-[120px_1fr] items-start gap-3">
<dt class="text-xs uppercase tracking-wide opacity-60">{{ key }}</dt>
<dd class="text-sm">
<span v-if="Array.isArray(val)">{{ val.join(', ') }}</span>
<span v-else>{{ String(val) }}</span>
</dd>
</div>
</template>
</template>
</dl>
</section>
</div>
<!-- Footer (optional actions) -->
<div class="border-t px-4 py-3 flex items-center justify-end gap-2">
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
Edit
</button>
<button
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
Open details
</button>
</div>
</aside> </aside>
</div>
</div> </div>
</template> </template>
<style scoped>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>
<style scoped> <style scoped>
/* ---------- Optional container "card" around the calendar ---------- */ /* ---------- Optional container "card" around the calendar ---------- */
:global(.fc) { :global(.fc) {
height: 100% !important; height: 100% !important;
} }
.calendar-card { :global(.ev-pill.is-cancelled) {
opacity: 0.45;
text-decoration: line-through;
filter: grayscale(100%);
}
:global(.calendar-card) {
background: var(--color-card); background: var(--color-card);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
@@ -370,6 +293,10 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
/* no internal scroll for month grid */ /* no internal scroll for month grid */
} }
:global(.fc-daygrid:hover) {
cursor: pointer;
}
/* Subtle borders everywhere */ /* Subtle borders everywhere */
:global(.fc .fc-scrollgrid), :global(.fc .fc-scrollgrid),
:global(.fc .fc-scrollgrid td), :global(.fc .fc-scrollgrid td),
@@ -431,6 +358,7 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
text-decoration: none; text-decoration: none;
height: 26px;
} }
/* Today: soft background + stronger number */ /* Today: soft background + stronger number */
@@ -454,7 +382,6 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
background: transparent; background: transparent;
color: var(--color-foreground); color: var(--color-foreground);
border-radius: 4px; border-radius: 4px;
padding: 4px 8px;
margin: 2px 6px; margin: 2px 6px;
text-decoration: none; text-decoration: none;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
@@ -465,14 +392,7 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
border-color: color-mix(in oklab, var(--color-foreground) 12%, var(--color-border)); border-color: color-mix(in oklab, var(--color-foreground) 12%, var(--color-border));
} }
/* One-line custom pill content (our renderer) */
:global(.ev-pill) {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: inherit;
}
:global(.ev-dot) { :global(.ev-dot) {
width: 8px; width: 8px;
@@ -499,23 +419,22 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
} }
/* One-line custom pill */ /* One-line custom pill */
.ev-pill { :global(.ev-pill) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
padding: 4px 8px; padding: 4px 8px;
border-radius: 14px; border-radius: 6px;
border: 1px solid var(--color-border); background: color-mix(in srgb, var(--ev-color) 15%, transparent);
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
color: var(--color-foreground);
text-decoration: none; text-decoration: none;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
} }
.ev-pill:hover { :global(.ev-pill:hover) {
background: color-mix(in oklab, var(--color-primary) 20%, transparent); /* background: color-mix(in oklab, var(--color-primary) 20%, transparent);
border-color: color-mix(in oklab, var(--color-primary) 45%, var(--color-border)); border-color: color-mix(in oklab, var(--color-primary) 45%, var(--color-border)); */
cursor: pointer;
} }
.ev-dot { .ev-dot {
@@ -544,15 +463,17 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
color: var(--color-primary-foreground); color: var(--color-primary-foreground);
} }
:global(.fc-daygrid-top) {
margin-bottom: 2px;
}
/* --- Replace the default today highlight with a round badge --- */ /* --- Replace the default today highlight with a round badge --- */
:global(.fc .fc-daygrid-day.fc-day-today) { :global(.fc .fc-daygrid-day.fc-day-today) {
background: transparent; background: transparent;
} }
:global(.fc .fc-daygrid-day.fc-day-today .fc-daygrid-day-number) { :global(.fc .fc-daygrid-day.fc-day-today .fc-daygrid-day-number) {
display: inline-block; border-radius: 6px;
padding: 2px 8px;
border-radius: 9999px;
background: color-mix(in oklab, var(--color-primary) 100%, transparent); background: color-mix(in oklab, var(--color-primary) 100%, transparent);
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;

View File

@@ -15,7 +15,10 @@ const router = createRouter({
{ path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/members', component: () => import('@/pages/memberList.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/loa', component: () => import('@/pages/SubmitLOA.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/transfer', component: () => import('@/pages/Transfer.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/calendar', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true }, },
{ path: '/calendar/event/:id', component: () => import('@/pages/Calendar.vue'), meta: { requiresAuth: true, memberOnly: true }, },
{ path: '/trainingReport', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport/new', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },
{ path: '/trainingReport/:id', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } }, { path: '/trainingReport/:id', component: () => import('@/pages/TrainingReport.vue'), meta: { requiresAuth: true, memberOnly: true } },