177 Commits

Author SHA1 Message Date
94d3a2716e ci: semantic-release update
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m19s
2026-03-19 16:30:48 -04:00
18d9f998f5 ci: Add pre-commit hook
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m28s
2026-03-19 15:50:36 -04:00
bb3042014e refactor: everything to nuxt.js 2026-03-19 14:30:36 -04:00
6e1f58cd8e fix: yarn install
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m39s
2026-03-18 23:17:18 -04:00
cc6903a799 fix: Update for new yarn
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 3m8s
2026-03-18 23:11:38 -04:00
6c4d047bf0 fix: lock yarn version
Some checks failed
Build BAB Application Deployment Artifact / build (push) Has been cancelled
2026-03-18 23:09:03 -04:00
2874ea3be1 fix(ui): hidden components on hard reload
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m16s
2026-03-15 22:44:07 -04:00
26bc33a095 fix(ui): layout fixes 2026-03-15 22:12:35 -04:00
67c7a3c050 chore: Update dependencies to latest
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m17s
fix: claude fixes to various errors
2026-03-15 10:41:12 -04:00
5d08b1c927 fix: Update AppwriteIDs for new dev
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m33s
2026-03-15 07:53:55 -04:00
148b8ff49d fix: add devel branch to builds
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m24s
2026-03-14 23:28:36 -04:00
c4113f63a4 Merge branch 'alpha' into devel 2026-03-14 23:25:03 -04:00
6274e4936d fix: Broken tag
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m48s
2026-03-14 23:07:10 -04:00
e1259688a4 chore: Add Claude Fix some bugs.
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m40s
2026-03-14 22:50:00 -04:00
e2a4dd851d Test
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m2s
2025-11-17 22:47:05 -05:00
2a61cc105f Test 2024-10-03 11:50:15 -04:00
d6f58ddabd chore: add version files.
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 4m41s
fix(ui): small tweaks to layout.
2024-06-25 08:33:05 -04:00
a1d9e147f9 fix(ci): New release
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m26s
2024-06-24 11:17:24 -04:00
92bfc7bafa chore: force new release
Some checks failed
Build BAB Application Deployment Artifact / build (push) Has been cancelled
2024-06-24 11:16:06 -04:00
6f61edd659 chore: change tar directory
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m43s
2024-06-24 10:25:51 -04:00
ea4e848e57 fix: make build process work
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m4s
2024-06-24 09:48:24 -04:00
c08fa6c2d8 fix: correct semantic-version step
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m56s
2024-06-23 23:54:43 -04:00
01aae9e8ff fix: correct use of secret
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m0s
2024-06-23 23:50:52 -04:00
70c6837858 refactor: update build pipeline
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m0s
2024-06-23 23:46:22 -04:00
6167a713dd refactor: Try new github actions script
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m48s
2024-06-23 00:56:47 -04:00
ab6b909fba fix: semantic-release now working correctly in development 2024-06-22 14:39:50 -04:00
9fdab2acc9 fix: correct paths to version 2024-06-22 12:11:45 -04:00
68c242ae81 feat: Add automatic version.js generation 2024-06-22 12:01:59 -04:00
cb3c1ab05f mend 2024-06-22 11:20:32 -04:00
02dae967a2 mend 2024-06-22 11:14:02 -04:00
77ae081031 mend 2024-06-22 11:13:58 -04:00
aed60cc0d5 mend 2024-06-22 11:09:29 -04:00
278c7309b7 feat: add semantic-release 2024-06-22 11:07:08 -04:00
a11b2a0568 fix: reactivity bug with ListReservationsPage
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m32s
2024-06-21 23:44:34 -04:00
ff8e54449a feat: add realtime updates of interval and reservation 2024-06-21 23:13:30 -04:00
64a59e856f feat: rudimentary realtime update of intervals 2024-06-20 23:36:05 -04:00
5e8c5a1631 feat: enable websocket proxy for dev 2024-06-20 23:14:20 -04:00
e97949cab3 fix: Improve reactivity in intervals 2024-06-20 21:52:00 -04:00
b7a3608e67 fix: dev targets 2024-06-19 23:02:01 -04:00
bbb544c029 chore: bump version
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m26s
2024-06-19 19:13:33 -04:00
da42f6ed22 chore: Update gitignore
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m8s
2024-06-17 16:31:29 -04:00
8016e20451 fix: remove dotenv files from repo 2024-06-17 16:30:59 -04:00
64ee8f4fea chore: Change actions to only run on devel branch
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m13s
2024-06-17 16:25:44 -04:00
17e8d7dc37 chore: manually bump version 2024-06-17 16:20:20 -04:00
a409b0a5c7 refactor: Configuration improvement
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m13s
2024-06-17 15:37:45 -04:00
6ec4a1e025 feat: Re-enable profile page and allow editing name
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 4m48s
2024-06-15 10:28:38 -04:00
d063b0cf0d fix: (auth) token login fix 2024-06-15 00:05:41 -04:00
643d74e29d feat: (auth) switch to OTP code via e-mail
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m24s
2024-06-14 16:23:48 -04:00
1526a10630 feat: (auth) Add ability to signup with e-mail 2024-06-14 15:19:29 -04:00
fc035106d0 fix: trying to load resources before auth
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m29s
2024-06-13 23:46:43 -04:00
8ae855838b feat: (auth) Add Google and DIscord Auth
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m0s
2024-06-13 23:38:03 -04:00
9bd10b56d9 Update TOS and Privacy Policy 2024-06-13 22:53:03 -04:00
1a78f82c5e chore: update version strings 2024-06-13 20:35:13 -04:00
475ba45248 Fix imports
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m58s
2024-06-10 11:44:28 -04:00
2a949d771a refactor: Redo env var names to work with vite. Add version
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m1s
2024-06-09 08:53:34 -04:00
7fc640d679 v0.6.1 2024-06-05 20:36:45 -04:00
91b54cf791 Bigger buttons 2024-06-04 16:41:43 -04:00
27b15a37f7 Bump version
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m18s
2024-06-04 16:35:42 -04:00
947b463fe2 Minor UI tweaks 2024-06-03 23:04:32 -04:00
c3098b073f UI Enhancements 2024-06-03 12:01:38 -04:00
b2420b270c Fix booking update and reactivity
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m28s
2024-06-02 10:08:57 -04:00
9104ccab0f Many improvements. Still no reactivity on List 2024-06-02 08:48:14 -04:00
387af2e6ce Sorted out a bunch of reactivity issues 2024-05-29 10:00:48 -04:00
6654132120 Add Delete Reservation function 2024-05-26 07:13:20 -04:00
59d2729719 Fix bug
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m6s
2024-05-25 08:34:25 -04:00
9f398e5509 Add List View 2024-05-24 20:45:04 -04:00
2fb236cf97 Style edits 2024-05-24 08:36:28 -04:00
7bc0573455 Add minutes to booking duration 2024-05-24 08:24:46 -04:00
68a2b8ffff Visual improvements 2024-05-24 08:11:47 -04:00
ce696a5a04 Small tweak to boat cards 2024-05-23 10:02:37 -04:00
b0d6ec877b More auth / role checks for navlinks 2024-05-23 09:55:02 -04:00
c03ad48615 Team based role auth for routes 2024-05-23 09:32:22 -04:00
55bc1acbb3 Many esthetic changes 2024-05-22 17:18:02 -04:00
cd692a6f3b Fix login bug. Improve reservations 2024-05-21 16:32:31 -04:00
737de91bbc Update naming 2024-05-20 21:53:09 -04:00
a6e357f973 Disable click on disabled slots 2024-05-19 07:18:13 -04:00
76b0498a18 Booking
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m57s
2024-05-18 10:07:09 -04:00
d6339815aa Navigation Tweaks 2024-05-18 08:49:56 -04:00
97481a5d2e Disable not ready stuff
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m58s
2024-05-17 21:50:38 -04:00
369bbc4960 Remove conflicting blocks 2024-05-17 21:41:11 -04:00
c3ee739366 Small tweak to handle swapped dates 2024-05-17 20:56:18 -04:00
adc34a116b Tracked down a date bug. Also tried to optimize, but not sure it's necessary.
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m2s
2024-05-17 20:41:26 -04:00
b506ab7ca9 Many changes to try to improve reliability 2024-05-17 18:17:25 -04:00
dd631b71bb Refactor Reservations into new store
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m2s
2024-05-13 12:42:10 -04:00
b0921ccf32 refactor utils 2024-05-13 12:31:27 -04:00
78211a33ae Added reservation and username lookup
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m13s
2024-05-13 10:49:03 -04:00
4a273ccb2f Temporary fix for https://github.com/quasarframework/quasar-ui-qcalendar/issues/439
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m57s
2024-05-11 09:11:17 -04:00
3a67f2fbb1 Rename TimeBlock to Interva. More Interval functionality.
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m40s
2024-05-10 09:50:04 -04:00
77619b0741 Edits to usability 2024-05-09 12:57:21 -04:00
ea785887a1 Sorted out reactivity with storeToRefs
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m1s
2024-05-08 23:43:18 -04:00
b860e1d977 Add some checks 2024-05-08 17:23:23 -04:00
274d0193f7 Some timeblock stuff working 2024-05-08 13:32:10 -04:00
033993b1b8 Upgrade Quasar 2024-05-06 19:22:28 -04:00
2872fb867e Started work on Schedule Management 2024-05-06 17:22:11 -04:00
8e73650462 Clean up all kinds of typescript issues 2024-05-05 15:58:58 -04:00
634cff507c Converted some schedule to use backend 2024-05-04 23:17:05 -04:00
fa4d83e42d Cleanup linting messages. Also, break some things 2024-05-04 12:08:16 -04:00
c92f737612 New image. Update some vars
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m4s
2024-05-02 20:09:41 -04:00
5792e80112 Filtering booked blocks
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m53s
2024-05-01 11:02:33 -04:00
db0755a368 Cleanup warnings 2024-05-01 09:56:08 -04:00
2b61d57a8a Stub out passenger info
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m2s
2024-04-30 23:08:17 -04:00
29f9aeaba4 Minor cosmetic cleanup
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m9s
2024-04-30 17:11:11 -04:00
28600578f1 Fix update of timblock 2024-04-30 17:04:55 -04:00
b66afb5692 Change colour of date header to white 2024-04-30 16:04:30 -04:00
2f68877ce6 Updates to booking
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m58s
2024-04-30 13:56:42 -04:00
0de9991a49 Fix generated data 2024-04-29 21:46:08 -04:00
4faff7cc8c Selection working
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m0s
2024-04-29 21:21:57 -04:00
c297f1f287 More work on timeblocks
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m0s
2024-04-29 21:14:02 -04:00
43e68c8ae7 Refactor Schedules 2024-04-29 12:54:54 -04:00
e1a784ef45 Add swipe
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m5s
2024-04-29 12:22:57 -04:00
d9cfa4ab56 Convert type to interface
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m1s
2024-04-29 08:37:15 -04:00
cb2131ae7e Fix Build issues
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m59s
2024-04-28 20:36:48 -04:00
de04b53914 Time Select
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 38s
2024-04-28 19:07:00 -04:00
1a18881980 Update QCalendar 2024-04-13 21:01:42 -04:00
84867875c5 Updates to booking
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m30s
2024-04-13 12:11:14 -04:00
ea0bc82c49 Task list improvements
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m47s
2024-04-08 18:39:00 -04:00
15ef8435f6 Basic filtering and buttong 2024-04-08 13:37:45 -04:00
4c2cae7149 Rudimentary searching 2024-04-08 13:03:33 -04:00
ffaf31bbeb Add searching 2024-04-08 11:28:45 -04:00
6ab1aa26b1 Updates to Tasks 2024-04-08 10:18:55 -04:00
5d9dbb0653 Densify table
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m9s
2024-04-07 10:43:38 -04:00
299ede4aa9 Task List Enhancements 2024-04-06 15:02:48 -04:00
b91ba39d06 Basic Task deletion 2024-04-05 22:01:51 -04:00
8464701082 Added task functionality 2024-04-05 20:50:56 -04:00
b3ce8e59cb Change the colour to Red.
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m6s
2024-04-04 16:17:56 -04:00
55071318ca Test Yellow
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m13s
2024-04-04 11:19:10 -04:00
b66b63101f Break out component for refactoring
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m53s
2024-04-04 10:04:36 -04:00
9db1b4d97c Change to green
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m13s
2024-04-03 13:49:04 -04:00
71a8c2e8d2 Fix typo in Route
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m12s
2024-04-03 13:39:30 -04:00
88738715b6 Change build
Some checks failed
Build BAB Application Deployment Artifact / build (push) Has been cancelled
2024-04-03 13:37:37 -04:00
53c650d4b0 Remove Broken component 2024-04-03 13:36:50 -04:00
deb6a0b8ed Basic New Task
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m7s
2024-04-03 13:28:35 -04:00
923d09d713 A number of task improvements. Not optimal tag selection
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m20s
2024-03-31 14:43:45 -04:00
d752898865 Basic Task Display 2024-03-30 11:45:59 -04:00
435438aaa8 More task changes
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m3s
2024-03-18 10:51:33 -04:00
084aadccef Update tasklist
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m4s
2024-03-12 23:37:25 -04:00
468569fa27 Add some subtasks
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m11s
2024-03-12 23:04:47 -04:00
0986d04ea6 Attempt to add basic tasklist
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m8s
2024-03-12 22:44:24 -04:00
6ff1a69e2b Update dependencies 2024-03-10 17:22:04 -04:00
052cae2c2e Update project
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m27s
2024-03-10 12:54:49 -04:00
29170f9e13 Add personas to docs 2024-03-10 11:45:00 -04:00
25ed6df62a Update quasar
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m11s
2024-03-07 21:49:38 -05:00
2f86700fb7 Change Number
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m33s
2024-02-13 14:56:41 -05:00
e7a79736b7 Change icons to blue
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m3s
2024-01-16 14:19:38 -05:00
2d585d499e Final build working
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m50s
2024-01-01 18:56:12 -05:00
284d5ffcb4 Move env file creation to the right place
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m2s
2024-01-01 18:48:58 -05:00
27a476ae00 Test
Some checks failed
Build BAB Application Deployment Artifact / build (push) Has been cancelled
2024-01-01 18:47:11 -05:00
ee7f79550c Fix name of .env file
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m1s
2024-01-01 18:42:57 -05:00
2ef801905b Update envfile
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m3s
2024-01-01 18:30:54 -05:00
752421c9fc Add the env file, so app builds with correct API info
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m3s
2024-01-01 17:39:11 -05:00
ce169f6a61 reorder install
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m56s
2024-01-01 13:42:03 -05:00
622b9fc82d Install dependencies with yarn
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m58s
2024-01-01 13:19:10 -05:00
275f23c421 Remove npm package-lock.json
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 59s
2024-01-01 13:12:48 -05:00
88ed4caf5b Update all yarn packages
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m2s
2024-01-01 13:10:30 -05:00
346e395e15 Build tar, as all the dates are messed up with zip
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m23s
2023-12-31 15:32:09 -05:00
f30848803b Update Boat selection component
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m33s
2023-12-31 15:04:53 -05:00
96dab93483 Fix URL Path
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m29s
2023-12-29 23:58:04 -05:00
a6abee1ddf Enable verbosity for debugging
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m29s
2023-12-29 12:45:19 -05:00
b20f2bffd6 Remove Secret
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m29s
2023-12-29 12:33:36 -05:00
f6689cbc5c Change url from secret to var
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m37s
2023-12-29 12:19:39 -05:00
8383605115 Disable SSL verification
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m31s
2023-12-29 12:14:20 -05:00
f69614d5c7 Fix URL again
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m40s
2023-12-29 12:06:24 -05:00
f7902011cc Fix action url
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 5s
2023-12-29 12:04:21 -05:00
e86876ba69 Update Actions
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 5s
2023-12-29 11:29:31 -05:00
cd6f2e3ba2 Updates to selection component
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m27s
2023-12-29 09:22:28 -05:00
66e2169f45 Adapting to time blocks for bookings
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m11s
2023-12-23 11:39:54 -05:00
489cc2646b Try v4 of the upload-artifact action
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m15s
2023-12-20 14:23:42 -05:00
295f1f7449 Don't bother tar/gz, as it's adding an extra, unnecessary step
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m20s
2023-12-20 13:59:50 -05:00
33a1bc24f6 Test run number addition to build
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m21s
2023-12-20 12:27:27 -05:00
d18780bb21 Begin implementation of timeblocks. Update workflow to build on devel branch
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m21s
2023-12-20 10:48:51 -05:00
ef569ac3b1 Merge minor edits from development.
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m30s
2023-12-18 20:50:51 -05:00
9390b7035c Change interval to 1h. Create StatusTypes 2023-12-18 20:44:01 -05:00
ac1730401a Add a shortened displayName for boats for a better mobile experience 2023-12-18 20:23:17 -05:00
bc41b1a7a1 Add bordered logo
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m42s
2023-12-18 18:10:05 -05:00
ea566d4a42 Add docs folder and design of users
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m31s
2023-12-18 14:26:03 -05:00
573e327a0f Move hard-coded API settings to .env file 2023-12-03 08:51:01 -05:00
831e81e892 Update package.json 2023-12-03 08:19:57 -05:00
181 changed files with 21377 additions and 8428 deletions

View File

@@ -0,0 +1,8 @@
Write a session handoff file for the current session.
Steps:
1. Read `templates/claude-templates.md` and find the Session Handoff template (Template 4). Use the Light Handoff if this is a small project (under 5 sessions), Full Handoff otherwise.
2. Fill in every field based on what was accomplished in this session. Be specific — include exact file paths for every output, exact numbers discovered, and conditional logic established.
3. Write the handoff to `./docs/summaries/handoff-[today's date]-[topic].md`.
4. If a previous handoff file exists in `./docs/summaries/`, move it to `./docs/archive/handoffs/`.
5. Tell me the file path of the new handoff and summarize what it contains.

View File

@@ -0,0 +1,13 @@
Process an input document into a structured source summary.
Steps:
1. Read `templates/claude-templates.md` and find the Source Document Summary template (Template 1). Use the Light Source Summary if this is a small project (under 5 sessions), Full Source Summary otherwise.
2. Read the document at: $ARGUMENTS
3. Extract all information into the template format. Pay special attention to:
- EXACT numbers — do not round or paraphrase
- Requirements in IF/THEN/BUT/EXCEPT format
- Decisions with rationale and rejected alternatives
- Open questions marked as OPEN, ASSUMED, or MISSING
4. Write the summary to `./docs/summaries/source-[filename].md`.
5. Move the original document to `./docs/archive/`.
6. Tell me: what was extracted, what's unclear, and what needs follow-up.

View File

@@ -0,0 +1,13 @@
Report on the current project state.
Steps:
1. Read `./docs/summaries/00-project-brief.md` for project context.
2. Find and read the latest `handoff-*.md` file in `./docs/summaries/` for current state.
3. List all files in `./docs/summaries/` to understand what's been processed.
4. Report:
- **Project:** name and type from the project brief
- **Current phase:** based on the project phase tracker
- **Last session:** what was accomplished (from the latest handoff)
- **Next steps:** what the next session should do (from the latest handoff)
- **Open questions:** anything unresolved
- **Summary file count:** how many files in docs/summaries/ (warn if approaching 15)

View File

@@ -1,9 +0,0 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

View File

@@ -1,8 +0,0 @@
/dist
/src-capacitor
/src-cordova
/.quasar
/node_modules
.eslintrc.js
/src-ssr
/quasar.config.*.temporary.compiled*

View File

@@ -1,90 +0,0 @@
module.exports = {
// https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy
// This option interrupts the configuration hierarchy at this file
// Remove this if you have an higher level ESLint config file (it usually happens into a monorepos)
root: true,
// https://eslint.vuejs.org/user-guide/#how-to-use-a-custom-parser
// Must use parserOptions instead of "parser" to allow vue-eslint-parser to keep working
// `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted
parserOptions: {
parser: require.resolve('@typescript-eslint/parser'),
extraFileExtensions: [ '.vue' ]
},
env: {
browser: true,
es2021: true,
node: true,
'vue/setup-compiler-macros': true
},
// Rules order is important, please avoid shuffling them
extends: [
// Base ESLint recommended rules
// 'eslint:recommended',
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#usage
// ESLint typescript rules
'plugin:@typescript-eslint/recommended',
// Uncomment any of the lines below to choose desired strictness,
// but leave only one uncommented!
// See https://eslint.vuejs.org/rules/#available-rules
'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention)
// 'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
// 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
// https://github.com/prettier/eslint-config-prettier#installation
// usage with Prettier, provided by 'eslint-config-prettier'.
'prettier'
],
plugins: [
// required to apply rules which need type information
'@typescript-eslint',
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files
// required to lint *.vue files
'vue'
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
// Prettier has not been included as plugin to avoid performance impact
// add it as an extension for your IDE
],
globals: {
ga: 'readonly', // Google Analytics
cordova: 'readonly',
__statics: 'readonly',
__QUASAR_SSR__: 'readonly',
__QUASAR_SSR_SERVER__: 'readonly',
__QUASAR_SSR_CLIENT__: 'readonly',
__QUASAR_SSR_PWA__: 'readonly',
process: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly'
},
// add your custom rules here
rules: {
'prefer-promise-reject-errors': 'off',
quotes: ['warn', 'single', { avoidEscape: true }],
// this rule, if on, would require explicit return type on the `render` function
'@typescript-eslint/explicit-function-return-type': 'off',
// in plain CommonJS modules, you can't use `import foo = require('foo')` to pass this rule, so it has to be disabled
'@typescript-eslint/no-var-requires': 'off',
// The core 'no-unused-vars' rules (in the eslint:recommended ruleset)
// does not work with type definitions
'no-unused-vars': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
}
}

View File

@@ -1,35 +1,56 @@
name: Build BAB Application Deployment Artifact name: Build BAB Application Deployment Artifact
run-name: ${{ gitea.actor }} is building an artifact 🚀 run-name: ${{ gitea.actor }} is building a BAB App artifact 🚀
on: on:
push: push:
branches: branches:
- main - main
- alpha
- devel
jobs: jobs:
build: build:
runs-on: ubuntu-18.04 runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v2 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: "20"
- name: Enable Corepack and Yarn
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Install dependencies - name: Install dependencies
run: npm install run: yarn install --immutable
- name: Install yarn
run: npm install --global yarn - name: Create env file
- name: Install Quasar CLI run: echo "${{ vars.ENV_FILE }}" > .env
run: npm install -g @quasar/cli
- name: Build Project - name: Show env file
run: quasar build -m pwa run: cat .env
- name: Get Version Number
id: get_version - name: Build and Release
run: echo "::set-output name=VERSION::$(node -p "require('./package.json').version")" id: build
- name: Create Tarball of Build run: yarn semantic-release
run: tar -czvf build-${{ steps.get_version.outputs.VERSION }}.tar.gz dist/pwa env:
- name: Upload Tarball GITEA_TOKEN: ${{ secrets.GT_TOKEN }}
uses: actions/upload-artifact@v3 GITEA_URL: ${{ vars.GT_URL }}
- name: Trigger Ansible Deploy Playbook
uses: https://github.com/distributhor/workflow-webhook@v3
with: with:
name: build-artifact-${{ steps.get_version.outputs.VERSION }} webhook_url: ${{ vars.WEBHOOK_URL }}
path: build-${{ steps.get_version.outputs.VERSION }}.tar.gz webhook_auth_type: bearer
webhook_auth: Token:${{ secrets.WEBHOOK_SECRET }}
verbose: true
data: >
{
"artifact_url":
"${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/v${{ steps.build.outputs.VERSION }}/release-${{ steps.build.outputs.VERSION }}.tar.gz"
}

59
.gitignore vendored
View File

@@ -1,37 +1,40 @@
.DS_Store # Nuxt dev/build outputs
.thumbs.db .output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules node_modules
# Quasar core related directories # Logs
.quasar logs
/dist *.log
/quasar.config.*.temporary.compiled*
# Cordova related directories and files # Misc
/src-cordova/node_modules .DS_Store
/src-cordova/platforms .thumbs.db
/src-cordova/plugins .fleet
/src-cordova/www
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/node_modules
# BEX related directories and files
/src-bex/www
/src-bex/js/core
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea .idea
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
# local .env files # Local env files
.env.local* .env
.env.*
!.env.example
# Yarn 4
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
# Release artifacts
release-*.gz
CHANGELOG.md
VERSION

1
.husky/pre-commit Executable file
View File

@@ -0,0 +1 @@
yarn typecheck

3
.npmrc
View File

@@ -1,3 +0,0 @@
# pnpm-related options
shamefully-hoist=true
strict-peer-dependencies=false

View File

@@ -1,4 +0,0 @@
{
"singleQuote": true,
"semi": true
}

27
.releaserc.json Normal file
View File

@@ -0,0 +1,27 @@
{
"branches": [
"main",
"next",
{ "name": "beta", "prerelease": true },
{ "name": "devel", "prerelease": true },
{ "name": "alpha", "prerelease": true }
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
[
"@semantic-release/exec",
{
"prepareCmd": "node generate-version.cjs '${nextRelease.version}' && yarn install --immutable && yarn generate",
"publishCmd": "tar -czvf release-${nextRelease.version}.tar.gz -C .output/public . && echo '::set-output name=VERSION::${nextRelease.version}'"
}
],
[
"@saithodev/semantic-release-gitea",
{
"assets": ["release-${nextRelease.version}.tar.gz"]
}
]
]
}

940
.yarn/releases/yarn-4.13.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

3
.yarnrc.yml Normal file
View File

@@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs

51
CLAUDE.md Normal file
View File

@@ -0,0 +1,51 @@
# CLAUDE.md
## Session Start
Read the latest handoff in docs/summaries/ if one exists. Load only the files that handoff references — not all summaries. If no handoff exists, ask: what is the project, what type of work, what is the target deliverable.
Before starting work, state: what you understand the project state to be, what you plan to do this session, and any open questions.
## Identity
You work with Patrick, a Solutions Architect, on the OYS Borrow a Boat (bab-app) project — a Quasar/Vue 3 app for managing a Borrow a Boat program for a Yacht Club. Backend is Appwrite.
## Project Overview
- **App**: OYS Borrow a Boat (oys_bab)
- **Stack**: Quasar (Vue 3), TypeScript, Appwrite (BaaS)
- **Purpose**: Manage a Borrow a Boat program for a Yacht Club
- **Docs**: docs/planning/ contains personas, user/role/permission model, and time-based logic
## Rules
1. Do not mix unrelated project contexts in one session.
2. Write state to disk, not conversation. After completing meaningful work, write a summary to docs/summaries/ using templates from templates/claude-templates.md. Include: decisions with rationale, exact numbers, file paths, open items.
3. Before compaction or session end, write to disk: every number, every decision with rationale, every open question, every file path, exact next action.
4. When switching work types (research → writing → review), write a handoff to docs/summaries/handoff-[date]-[topic].md and suggest a new session.
5. Do not silently resolve open questions. Mark them OPEN or ASSUMED.
6. Do not bulk-read documents. Process one at a time: read, summarize to disk, release from context before reading next. For the detailed protocol, read docs/context/processing-protocol.md.
7. Sub-agent returns must be structured, not free-form prose. Use output contracts from templates/claude-templates.md.
## Where Things Live
- templates/claude-templates.md — summary, handoff, decision, analysis, task, output contract templates (read on demand)
- docs/summaries/ — active session state (latest handoff + project brief + decision records + source summaries)
- docs/context/ — reusable domain knowledge, loaded only when relevant to the current task
- processing-protocol.md — full document processing steps
- archive-rules.md — summary lifecycle and file archival rules
- subagent-rules.md — rules for structured sub-agent outputs
- docs/planning/ — original planning documents (personas, roles/permissions, time logic)
- docs/archive/ — processed raw files. Do not read unless explicitly told.
- output/deliverables/ — final outputs
- src/ — Quasar/Vue app source
- src-pwa/ — PWA config
- appwrite.json — Appwrite project config
## Error Recovery
If context degrades or auto-compact fires unexpectedly: write current state to docs/summaries/recovery-[date].md, tell the user what may have been lost, suggest a fresh session.
## Before Delivering Output
Verify: exact numbers preserved, open questions marked OPEN, output matches what was requested (not assumed), claims backed by specific data, output consistent with stored decisions in docs/context/, summary written to disk for this session's work.

View File

@@ -41,3 +41,7 @@ quasar build
### Customize the configuration ### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js). See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
### TODO
https://github.com/semantic-release/semantic-release

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.0.0

5
app/app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useAuthStore } from '~/stores/auth';
import { useBoatStore } from '~/stores/boat';
import type { Boat } from '~/utils/boat.types';
import type { Interval, Reservation } from '~/utils/schedule.types';
import BoatScheduleTableComponent from '~/components/scheduling/boat/BoatScheduleTableComponent.vue';
import { formatDate } from '~/utils/schedule';
import { useReservationStore } from '~/stores/reservation';
import { useQuasar } from 'quasar';
interface BookingForm {
$id?: string;
user?: string;
interval?: Interval | null;
reason?: string;
members?: string[];
guests?: string[];
comment?: string;
}
const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other'];
const boatStore = useBoatStore();
const auth = useAuthStore();
const newForm = {
user: auth.currentUser?.$id,
interval: {} as Interval,
reason: 'Open Sail',
members: [],
guests: [],
comment: '',
};
const reservation = defineModel<Reservation>();
const reservationStore = useReservationStore();
const boatSelect = ref(false);
const bookingForm = ref<BookingForm>({ ...newForm });
const $q = useQuasar();
const $router = useRouter();
watch(reservation, (newReservation) => {
if (!newReservation) {
bookingForm.value = newForm;
} else {
bookingForm.value = {
...newReservation,
user: auth.currentUser?.$id,
interval: {
start: newReservation.start,
end: newReservation.end,
resource: newReservation.resource,
},
};
}
});
const updateInterval = (interval: Interval | null | undefined) => {
bookingForm.value.interval = interval;
boatSelect.value = false;
};
const bookingDuration = computed((): { hours: number; minutes: number } => {
if (bookingForm.value.interval?.start && bookingForm.value.interval?.end) {
const start = new Date(bookingForm.value.interval.start).getTime();
const end = new Date(bookingForm.value.interval.end).getTime();
const delta = Math.abs(end - start) / 1000;
const hours = Math.floor(delta / 3600) % 24;
const minutes = Math.floor(delta - hours * 3600) % 60;
return { hours, minutes };
}
return { hours: 0, minutes: 0 };
});
const bookingName = computed(() => auth.getUserNameById(bookingForm.value?.user));
const boat = computed((): Boat | null => {
const boatId = bookingForm.value.interval?.resource;
return boatStore.getBoatById(boatId);
});
const onDelete = () => {
reservationStore.deleteReservation(reservation.value?.$id);
$router.go(-1);
};
const onReset = () => {
bookingForm.value.interval = null;
bookingForm.value = reservation.value
? {
...reservation.value,
interval: {
start: reservation.value.start,
end: reservation.value.end,
resource: reservation.value.resource,
},
}
: { ...newForm };
};
const onSubmit = async () => {
const booking = bookingForm.value;
if (
!(
booking.interval &&
booking.interval.resource &&
booking.interval.start &&
booking.interval.end &&
auth.currentUser
)
) {
return false;
}
const newReservation = <Reservation>{
resource: booking.interval.resource,
start: booking.interval.start,
end: booking.interval.end,
user: auth.currentUser.$id,
status: 'confirmed',
reason: booking.reason,
comment: booking.comment,
$id: reservation.value?.$id,
};
const status = $q.notify({
color: 'secondary',
textColor: 'white',
message: 'Submitting Reservation',
spinner: true,
closeBtn: 'Dismiss',
position: 'top',
timeout: 0,
group: false,
});
try {
const r = await reservationStore.createOrUpdateReservation(newReservation);
status({
color: 'positive',
icon: 'cloud_done',
message: `Booking ${newReservation.$id ? 'updated' : 'created'}: ${
boatStore.getBoatById(r.resource)?.name
} at ${formatDate(r.start)}`,
spinner: false,
});
} catch (e) {
status({ color: 'negative', icon: 'error', spinner: false, message: 'Failed to book!' + e });
}
$router.go(-1);
};
</script>
<template>
<div class="q-pa-xs row q-gutter-xs">
<q-card flat class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
<q-card-section>
<div class="text-h5 q-mt-none q-mb-xs">
{{ reservation ? 'Modify Booking' : 'New Booking' }}
</div>
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
</q-card-section>
<q-list class="q-px-xs">
<q-item class="q-pa-none" clickable @click="boatSelect = true">
<q-card v-if="boat" class="col-12">
<q-card-section>
<q-img :src="boat.imgSrc" :fit="'scale-down'">
<div class="row absolute-top">
<div class="col text-h7 text-left">{{ boat.name }}</div>
<div class="col text-right text-caption">{{ boat.class }}</div>
</div>
</q-img>
</q-card-section>
<q-separator />
<q-card-section horizontal>
<q-card-section class="col-9">
<q-list dense class="row">
<q-item class="q-ma-none col-12">
<q-item-section avatar>
<q-badge color="primary" label="Start" />
</q-item-section>
<q-item-section class="text-body2">
{{ formatDate(bookingForm.interval?.start) }}
</q-item-section>
</q-item>
<q-item class="q-ma-none col-12">
<q-item-section avatar>
<q-badge color="primary" label="End" />
</q-item-section>
<q-item-section class="text-body2" style="min-width: 150px">
{{ formatDate(bookingForm.interval?.end) }}
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-separator vertical />
<q-card-section class="col-3 flex flex-center bg-grey-4">
{{ bookingDuration.hours }} hours
<div v-if="bookingDuration.minutes">
<q-separator />
{{ bookingDuration.minutes }} mins
</div>
</q-card-section>
</q-card-section>
</q-card>
<div v-else class="col-12">
<q-field filled>Tap to Select a Boat / Time</q-field>
</div>
</q-item>
<q-item class="q-px-none">
<q-item-section>
<q-select filled v-model="bookingForm.reason" :options="reason_options" label="Reason for sail" />
</q-item-section>
</q-item>
<q-item class="q-px-none">
<q-item-section>
<q-input v-model="bookingForm.comment" clearable autogrow filled label="Additional Comments (optional)" />
</q-item-section>
</q-item>
</q-list>
<q-card-actions align="right">
<q-btn label="Delete" color="negative" size="lg" v-if="reservation?.$id" @click="onDelete" />
<q-btn label="Reset" @click="onReset" size="lg" color="secondary" />
<q-btn label="Submit" @click="onSubmit" size="lg" color="primary" />
</q-card-actions>
</q-card>
<q-dialog v-model="boatSelect" full-width>
<BoatScheduleTableComponent :model-value="bookingForm.interval" @update:model-value="updateInterval" />
</q-dialog>
</div>
</template>

View File

@@ -0,0 +1,8 @@
<template>
<q-tabs class="mobile-only">
<q-route-tab name="Boats" icon="sailing" to="/boat" />
<q-route-tab name="Schedule" icon="calendar_month" to="/schedule" />
</q-tabs>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
const certifications = [
{ title: 'J/27 Skipper', badgeText: 'J/27', description: 'Certified to be a skipper on a J/27 class boat.' },
{ title: 'Capri 25 Skipper', badgeText: 'Capri25', description: 'Certified to be a skipper on a Capri 25 class boat.' },
{ title: 'Night', badgeText: 'Night', description: 'Certified to operate boats at night' },
{ title: 'Navigation', badgeText: 'Nav', description: 'Advanced Navigation' },
{ title: 'Crew', badgeText: 'crew', description: 'Crew certification.' },
];
</script>
<template>
<div>Certification</div>
<q-item
v-for="cert in certifications"
:key="cert.title"
clickable
v-ripple
class="rounded-borders"
:class="$q.dark.isActive ? 'bg-grey-9 text-white' : 'bg-grey-2'">
<q-item-section avatar>
<q-avatar rounded>
<q-icon :name="`check`" />
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ cert.title }}</q-item-label>
<q-item-label caption>
<q-badge color="green-4" text-color="black">{{ cert.badgeText }}</q-badge>
</q-item-label>
</q-item-section>
<q-item-section>
<span>{{ cert.description }}</span>
</q-item-section>
</q-item>
</template>

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import { Dialog } from 'quasar';
import { useNavLinks } from '~/utils/navlinks';
import { useAuthStore } from '~/stores/auth';
import { APP_VERSION } from '~/utils/version';
defineProps(['drawer']);
defineEmits(['drawer-toggle']);
const { enabledLinks } = useNavLinks();
const authStore = useAuthStore();
function showAbout() {
Dialog.create({
title: 'OYS Borrow a Boat',
message: `Version ${APP_VERSION}<br>Manage a Borrow a Boat program for a Yacht Club.<br><br>© Oakville Yacht Squadron`,
html: true,
});
}
async function logout() {
await authStore.logout();
await navigateTo('/login');
}
</script>
<template>
<q-drawer
:model-value="drawer"
show-if-above
:width="200"
:breakpoint="1024"
@update:model-value="$emit('drawer-toggle')">
<q-scroll-area class="fit">
<q-list padding class="menu-list">
<template v-for="link in enabledLinks" :key="link.name">
<q-item clickable v-ripple :to="link.to">
<q-item-section avatar>
<q-icon :name="link.icon" />
</q-item-section>
<q-item-section>
<span :class="link.color ? `text-${link.color}` : ''">
{{ link.name }}
</span>
</q-item-section>
</q-item>
<q-list v-if="link.sublinks">
<div v-for="sublink in link.sublinks" :key="sublink.name">
<q-item clickable v-ripple :to="sublink.to" class="q-ml-md">
<q-item-section avatar>
<q-icon :name="sublink.icon" />
</q-item-section>
<q-item-section>
<span :class="sublink.color ? `text-${sublink.color}` : ''">
{{ sublink.name }}
</span>
</q-item-section>
</q-item>
</div>
</q-list>
</template>
<q-item clickable v-ripple @click="showAbout()">
<q-item-section avatar><q-icon name="info" /></q-item-section>
<q-item-section>About</q-item-section>
</q-item>
<q-item clickable v-ripple @click="logout()">
<q-item-section avatar><q-icon name="logout" /></q-item-section>
<q-item-section>Logout</q-item-section>
</q-item>
</q-list>
</q-scroll-area>
</q-drawer>
</template>
<style lang="sass" scoped>
.menu-list .q-item
border-radius: 0 32px 32px 0
</style>

View File

@@ -1,50 +1,33 @@
<script setup lang="ts">
import type { ReferenceEntry } from '~/stores/reference';
defineProps({ entries: Array<ReferenceEntry> });
</script>
<template> <template>
<q-card <q-card flat bordered class="my-card" v-for="entry in entries" :key="entry.title">
flat
bordered
class="my-card"
v-for="entry in entries"
:key="entry.title"
>
<q-card-section> <q-card-section>
<div class="row items-center no-wrap"> <div class="row items-center no-wrap">
<div class="col"> <div class="col">
<div class="text-h6">{{ entry.title }}</div> <div class="text-h6">{{ entry.title }}</div>
<div class="text-subtitle2">{{ entry.subtitle }}</div> <div class="text-subtitle2">{{ entry.subtitle }}</div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<q-btn color="grey-7" round flat icon="more_vert"> <q-btn color="grey-7" round flat icon="more_vert">
<q-menu cover auto-close> <q-menu cover auto-close>
<q-list> <q-list>
<q-item clickable> <q-item clickable><q-item-section>Remove Card</q-item-section></q-item>
<q-item-section>Remove Card</q-item-section> <q-item clickable><q-item-section>Send Feedback</q-item-section></q-item>
</q-item> <q-item clickable><q-item-section>Share</q-item-section></q-item>
<q-item clickable>
<q-item-section>Send Feedback</q-item-section>
</q-item>
<q-item clickable>
<q-item-section>Share</q-item-section>
</q-item>
</q-list> </q-list>
</q-menu> </q-menu>
</q-btn> </q-btn>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
<q-separator /> <q-separator />
<q-card-actions> <q-card-actions>
<q-btn flat :to="'reference/' + entry.id + '/view'">Read</q-btn> <q-btn flat :to="'reference/' + entry.id + '/view'">Read</q-btn>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</template> </template>
<script setup lang="ts">
import { ReferenceEntry } from 'src/stores/reference';
defineProps({
entries: Array<ReferenceEntry>,
});
</script>

View File

@@ -0,0 +1,168 @@
<!-- Abandoned: superseded by block-based booking. Retained for future reference. -->
<script setup lang="ts">
import { ref } from 'vue';
import type { TimestampOrNull, Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
QCalendarResource,
QCalendarMonth,
today,
parseTimestamp,
addToDate,
parsed,
} from '@quasar/quasar-ui-qcalendar';
import { useBoatStore } from '~/stores/boat';
import type { Boat } from '~/utils/boat.types';
import { useReservationStore } from '~/stores/reservation';
import { date } from 'quasar';
import { computed } from 'vue';
import type { StatusTypes } from '~/utils/schedule.types';
import { useIntervalStore } from '~/stores/interval';
import { storeToRefs } from 'pinia';
interface EventData {
event: object;
scope: { timestamp: object; columnindex: number; activeDate: boolean; droppable: boolean };
}
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
interface ResourceIntervalScope {
resource: Boat;
intervals: [];
timeStartPosX(start: TimestampOrNull): number;
timeDurationWidth(duration: number): number;
}
const statusLookup = {
confirmed: ['#14539a', 'white'],
pending: ['#f2c037', 'white'],
tentative: ['white', 'grey'],
};
const calendar = ref();
const boatStore = useBoatStore();
const reservationStore = useReservationStore();
const { selectedDate } = storeToRefs(useIntervalStore());
const duration = ref(1);
const formattedMonth = computed(() => {
const d = new Date(selectedDate.value);
return monthFormatter()?.format(d);
});
const disabledBefore = computed(() => {
const todayTs = parseTimestamp(today()) as Timestamp;
return addToDate(todayTs, { day: -1 }).date;
});
function monthFormatter() {
try {
return new Intl.DateTimeFormat('en-CA', { month: 'long', timeZone: 'UTC' });
} catch { /* */ }
}
function getEvents(scope: ResourceIntervalScope) {
const resourceEvents = reservationStore.getReservationsByDate(selectedDate.value, scope.resource.$id);
return resourceEvents.value.map((event) => ({
left: scope.timeStartPosX(parsed(event.start)),
width: scope.timeDurationWidth(date.getDateDiff(event.end, event.start, 'minutes')),
title: event.user,
status: event.status,
}));
}
function getStyle(event: { left: number; width: number; title: string; status: StatusTypes }) {
return {
position: 'absolute',
background: event.status ? statusLookup[event.status][0] : 'white',
color: event.status ? statusLookup[event.status][1] : '#14539a',
left: `${event.left}px`,
width: `${event.width}px`,
height: '32px',
overflow: 'hidden',
};
}
const emit = defineEmits(['onClickTime', 'onUpdateDuration']);
function onPrev() { calendar.value.prev(); }
function onNext() { calendar.value.next(); }
function onClickDate(data: EventData) { return data; }
function onClickTime(data: EventData) { emit('onClickTime', data); }
function onUpdateDuration(value: EventData) { emit('onUpdateDuration', value); }
const onClickInterval = () => {};
const onClickHeadResources = () => {};
const onClickResource = () => {};
const onResourceExpanded = () => {};
const onMoved = () => {};
const onChange = () => {};
</script>
<template>
<q-card-section>
<div class="text-caption text-justify">
Use the calendar to pick a date. Tap a box in the grid for the boat and start time.
</div>
<div style="width: 100%; display: flex; justify-content: center">
<div style="width: 50%; max-width: 350px; display: flex; justify-content: space-between">
<span class="q-button" style="cursor: pointer; user-select: none" @click="onPrev">&lt;</span>
{{ formattedMonth }}
<span class="q-button" style="cursor: pointer; user-select: none" @click="onNext">&gt;</span>
</div>
</div>
<div style="display: flex; justify-content: center; align-items: center; flex-wrap: nowrap">
<div style="display: flex; width: 100%">
<q-calendar-month
ref="calendar"
v-model="selectedDate"
:disabled-before="disabledBefore"
animated
bordered
mini-mode
date-type="rounded"
@change="onChange"
@moved="onMoved"
@click-date="onClickDate" />
</div>
</div>
</q-card-section>
<q-calendar-resource
v-model="selectedDate"
:model-resources="boatStore.boats"
resource-key="id"
resource-label="displayName"
resource-width="32"
:interval-start="6"
:interval-count="18"
:interval-minutes="60"
cell-width="48"
style="--calendar-resources-width: 48px"
resource-min-height="40"
animated
bordered
@change="onChange"
@moved="onMoved"
@resource-expanded="onResourceExpanded"
@click-date="onClickDate"
@click-time="onClickTime"
@click-resource="onClickResource"
@click-head-resources="onClickHeadResources"
@click-interval="onClickInterval">
<template #resource-intervals="{ scope }">
<template v-for="(event, index) in getEvents(scope)" :key="index">
<q-badge outline :label="event.title" :style="getStyle(event)" />
</template>
</template>
<template #resource-label="{ scope: { resource } }">
<div class="col-12 .col-md-auto">
{{ resource.displayName }}
<q-icon v-if="resource.defects" name="warning" color="warning" />
</div>
</template>
</q-calendar-resource>
<q-card-section>
<q-select filled v-model="duration" :options="durations" dense @update:model-value="onUpdateDuration" label="Duration (hours)" stack-label>
<template v-slot:append><q-icon name="timelapse" /></template>
</q-select>
</q-card-section>
</template>

View File

@@ -2,5 +2,4 @@
<div>My component</div> <div>My component</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts"></script>
</script>

View File

@@ -1,18 +1,19 @@
<script setup lang="ts">
import { useBoatStore } from '~/stores/boat';
import type { Boat } from '~/utils/boat.types';
const boats = useBoatStore().boats;
const boat = <Boat | undefined>undefined;
</script>
<template> <template>
<q-select <q-select v-model="boat" :options="boats" option-value="id" option-label="name" label="Boat">
v-model="boat"
:options="boats"
option-value="id"
option-label="name"
label="Boat"
>
<template v-slot:prepend> <template v-slot:prepend>
<q-item-section avatar> <q-item-section avatar>
<q-img v-if="boat?.iconsrc" :src="boat?.iconsrc" /> <q-img v-if="boat?.iconSrc" :src="boat?.iconSrc" />
<q-icon v-else name="sailing" /> <q-icon v-else name="sailing" />
</q-item-section> </q-item-section>
</template> </template>
<template v-slot:option="scope"> <template v-slot:option="scope">
<q-item v-bind="scope.itemProps"> <q-item v-bind="scope.itemProps">
<q-item-section avatar> <q-item-section avatar>
@@ -24,17 +25,11 @@
</q-item-section> </q-item-section>
<q-item-section avatar v-if="scope.opt.defects"> <q-item-section avatar v-if="scope.opt.defects">
<q-icon name="warning" color="warning" /> <q-icon name="warning" color="warning" />
<q-tooltip class="bg-amber text-black shadow-7" <q-tooltip class="bg-amber text-black shadow-7">
>This boat has notices. Select it to see details. This boat has notices. Select it to see details.
</q-tooltip> </q-tooltip>
</q-item-section> </q-item-section>
</q-item> </q-item>
</template> </template>
</q-select> </q-select>
</template> </template>
<script setup lang="ts">
import { Boat, useBoatStore } from 'src/stores/boat';
const boats = useBoatStore().boats;
const boat = <Boat | undefined>undefined;
</script>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Boat } from '~/utils/boat.types';
defineProps({ boats: Array<Boat> });
</script>
<template>
<div v-if="boats" class="row">
<q-card
v-for="boat in boats"
:key="boat.$id"
class="q-ma-sm col-xs-12 col-sm-6 col-xl-3">
<q-card-section>
<q-img :src="boat.imgSrc" :fit="'scale-down'">
<div class="row absolute-top">
<div class="col text-h6 text-left">{{ boat.name }}</div>
<div class="col text-right">{{ boat.class }}</div>
</div>
</q-img>
</q-card-section>
<q-separator />
</q-card>
</div>
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
</template>

View File

@@ -0,0 +1 @@
<template><div /></template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
import type { IntervalTemplate } from '~/utils/schedule.types';
import { copyIntervalTemplate, timeTuplesOverlapped } from '~/utils/schedule';
import { ref } from 'vue';
const alert = ref(false);
const overlapped = ref();
const intervalTemplateStore = useIntervalTemplateStore();
const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>();
const edit = ref(props.edit);
const expanded = ref(props.edit);
const template = ref(copyIntervalTemplate(props.modelValue));
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'saved'): void }>();
const revert = () => {
template.value = copyIntervalTemplate(props.modelValue);
edit.value = false;
emit('cancel');
};
const toggleEdit = () => {
edit.value = !edit.value;
};
const deleteTemplate = (event: Event, tmpl: IntervalTemplate | undefined) => {
if (tmpl?.$id) intervalTemplateStore.deleteIntervalTemplate(tmpl.$id);
};
function onDragStart(e: DragEvent, tmpl: IntervalTemplate) {
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('ID', tmpl.$id || '');
}
}
const saveTemplate = (evt: Event, tmpl: IntervalTemplate | undefined) => {
if (!tmpl) return false;
overlapped.value = timeTuplesOverlapped(tmpl.timeTuples);
if (overlapped.value.length > 0) {
alert.value = true;
} else {
edit.value = false;
if (tmpl.$id && tmpl.$id !== 'unsaved') {
intervalTemplateStore.updateIntervalTemplate(tmpl, tmpl.$id);
} else {
intervalTemplateStore.createIntervalTemplate(tmpl);
emit('saved');
}
}
};
</script>
<template>
<q-expansion-item expand-icon-toggle draggable="true" @dragstart="onDragStart($event, template)" v-model="expanded">
<template v-slot:header>
<q-item-section>
<q-input label="Template name" :borderless="!edit" dense v-model="template.name" v-if="edit" />
<q-item-label v-if="!edit" class="cursor-pointer">{{ template.name }}</q-item-label>
</q-item-section>
</template>
<q-card flat>
<q-card-section horizontal>
<q-card-section class="q-pt-xs">
<q-list dense>
<q-item v-for="(item, index) in template.timeTuples" :key="item[0]">
<q-input class="q-mx-sm" dense v-model="item[0]" type="time" label="Start" :borderless="!edit" :readonly="!edit" />
<q-input class="q-mx-sm" dense v-model="item[1]" type="time" label="End" :borderless="!edit" :readonly="!edit">
<template v-slot:after>
<q-btn v-if="edit" round dense flat icon="delete" @click="template.timeTuples.splice(index, 1)" />
</template>
</q-input>
</q-item>
</q-list>
<q-btn v-if="edit" dense color="primary" size="sm" label="Add interval" @click="template.timeTuples.push(['00:00', '00:00'])" />
</q-card-section>
<q-card-actions vertical>
<q-btn v-if="!edit" color="primary" icon="edit" label="Edit" @click="toggleEdit" />
<q-btn v-if="edit" color="primary" icon="save" label="Save" @click="saveTemplate($event, template)" />
<q-btn v-if="edit" color="secondary" icon="cancel" label="Cancel" @click="revert" />
<q-btn color="negative" icon="delete" label="Delete" v-if="template.$id !== ''" @click="deleteTemplate($event, template)" />
</q-card-actions>
</q-card-section>
</q-card>
</q-expansion-item>
<q-dialog v-model="alert">
<q-card>
<q-card-section><div class="text-h6">Overlapped blocks!</div></q-card-section>
<q-card-section class="q-pt-none">
<q-chip square icon="schedule" v-for="item in overlapped" :key="item.start">
{{ item.start }}-{{ item.end }}
</q-chip>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="OK" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</template>

View File

@@ -0,0 +1,13 @@
<template>
<div class="row justify-center">
<div class="q-pa-md q-gutter-sm row">
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">Today</q-btn>
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">&lt; Prev</q-btn>
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">Next &gt;</q-btn>
</div>
</div>
</template>
<script setup lang="ts">
defineEmits(['today', 'prev', 'next']);
</script>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useBoatStore } from '~/stores/boat';
import { useReservationStore } from '~/stores/reservation';
import type { Reservation } from '~/utils/schedule.types';
import { formatDate, isPast } from '~/utils/schedule';
import { ref } from 'vue';
const cancelDialog = ref(false);
const boatStore = useBoatStore();
const reservationStore = useReservationStore();
const reservation = defineModel<Reservation>({ required: true });
const cancelReservation = () => {
cancelDialog.value = true;
};
</script>
<template>
<q-card
bordered
:class="isPast(reservation.end) ? 'text-blue-grey-6' : ''"
class="q-ma-md">
<q-card-section>
<div class="row items-center no-wrap">
<div class="col">
<div class="text-h6">
{{ boatStore.getBoatById(reservation.resource)?.name }}
</div>
<div class="text-subtitle2">
<p>
Start: {{ formatDate(reservation.start) }}<br />
End: {{ formatDate(reservation.end) }}<br />
Type: {{ reservation.reason }}
</p>
</div>
</div>
</div>
</q-card-section>
<q-separator />
<q-card-actions v-if="!isPast(reservation.end)">
<q-btn flat size="lg" :to="`/schedule/edit/${reservation.$id}`">Modify</q-btn>
<q-btn flat size="lg" @click="cancelReservation()">Delete</q-btn>
</q-card-actions>
</q-card>
<q-dialog v-model="cancelDialog">
<q-card>
<q-card-section class="row items-center">
<q-avatar icon="warning" color="negative" text-color="white" />
<span class="q-ml-md">Warning!</span>
<p class="q-pt-md">
This will delete your reservation for
{{ boatStore.getBoatById(reservation?.resource)?.name }} on
{{ formatDate(reservation?.start) }}
</p>
</q-card-section>
<q-card-actions align="right">
<q-btn flat size="lg" label="Cancel" color="primary" v-close-popup />
<q-btn
flat
size="lg"
label="Delete"
color="negative"
@click="reservationStore.deleteReservation(reservation)"
v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</template>

View File

@@ -0,0 +1,236 @@
<script setup lang="ts">
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
QCalendarDay,
diffTimestamp,
today,
parseTimestamp,
parseDate,
addToDate,
} from '@quasar/quasar-ui-qcalendar';
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useBoatStore } from '~/stores/boat';
import { useAuthStore } from '~/stores/auth';
import type { Interval, Reservation } from '~/utils/schedule.types';
import { storeToRefs } from 'pinia';
import { useReservationStore } from '~/stores/reservation';
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
import { useIntervalStore } from '~/stores/interval';
const intervalTemplateStore = useIntervalTemplateStore();
const reservationStore = useReservationStore();
const { boats } = storeToRefs(useBoatStore());
const selectedBlock = defineModel<Interval | null>();
const selectedDate = ref(today());
const { getAvailableIntervals } = useIntervalStore();
const calendar = ref<typeof QCalendarDay | null>(null);
const now = ref(new Date());
let intervalId: ReturnType<typeof setInterval> | undefined;
onMounted(async () => {
await useBoatStore().fetchBoats();
await intervalTemplateStore.fetchIntervalTemplates();
intervalId = setInterval(() => { now.value = new Date(); }, 60000);
});
onUnmounted(() => clearInterval(intervalId));
function handleSwipe({ direction }: { direction: string }) {
if (direction === 'right') {
calendar.value?.prev();
} else {
calendar.value?.next();
}
}
function reservationStyles(
reservation: Reservation,
timeStartPos: (t: string) => string,
timeDurationHeight: (d: number) => string
) {
return genericBlockStyle(
parseDate(new Date(reservation.start)) as Timestamp,
parseDate(new Date(reservation.end)) as Timestamp,
timeStartPos,
timeDurationHeight
);
}
function getUserName(userid: string) {
return useAuthStore().getUserNameById(userid);
}
function blockStyles(
block: Interval,
timeStartPos: (t: string) => string,
timeDurationHeight: (d: number) => string
) {
return genericBlockStyle(
parseDate(new Date(block.start)) as Timestamp,
parseDate(new Date(block.end)) as Timestamp,
timeStartPos,
timeDurationHeight
);
}
function getBoatDisplayName(scope: DayBodyScope) {
return boats.value[scope.columnIndex]?.displayName ?? '';
}
function beforeNow(time: Date) {
return time < now.value || null;
}
function genericBlockStyle(
start: Timestamp,
end: Timestamp,
timeStartPos: (t: string) => string,
timeDurationHeight: (d: number) => string
) {
const s = { top: '', height: '', opacity: '' };
if (timeStartPos && timeDurationHeight) {
s.top = timeStartPos(start.time) + 'px';
s.height =
parseInt(timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)) - 1 + 'px';
}
return s;
}
interface DayBodyScope {
columnIndex: number;
timeDurationHeight: string;
timeStartPos: (time: string, clamp: boolean) => string;
timestamp: Timestamp;
}
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
if (scope.timestamp.disabled || new Date(block.end) < new Date()) return false;
selectedBlock.value = block;
}
const boatReservations = computed((): Record<string, Reservation[]> => {
return reservationStore
.getReservationsByDate(selectedDate.value)
.value.reduce((result, reservation) => {
if (!result[reservation.resource]) result[reservation.resource] = [];
result[reservation.resource]!.push(reservation);
return result;
}, <Record<string, Reservation[]>>{});
});
function getBoatReservations(scope: DayBodyScope): Reservation[] {
const boat = boats.value[scope.columnIndex];
return boat ? boatReservations.value[boat.$id] ?? [] : [];
}
const disabledBefore = computed(() => {
const todayTs = parseTimestamp(today()) as Timestamp;
return addToDate(todayTs, { day: -1 }).date;
});
</script>
<template>
<div>
<q-card>
<q-toolbar>
<q-toolbar-title>Select a Boat and Time</q-toolbar-title>
<q-btn icon="close" flat round dense v-close-popup />
</q-toolbar>
<q-separator />
<CalendarHeaderComponent v-model="selectedDate" />
<div class="boat-schedule-table-component">
<QCalendarDay
ref="calendar"
class="q-pa-xs"
flat
animated
dense
:disabled-before="disabledBefore"
interval-height="24"
interval-count="18"
interval-start="06:00"
:short-interval-label="true"
v-model="selectedDate"
:column-count="boats.length"
v-touch-swipe.left.right="handleSwipe">
<template #head-day="{ scope }">
<div style="text-align: center; font-weight: 800">
{{ getBoatDisplayName(scope) }}
</div>
</template>
<template #day-body="{ scope }">
<div
v-for="block in getAvailableIntervals(scope.timestamp, boats[scope.columnIndex]).value"
:key="block.$id">
<div
class="timeblock"
:disabled="beforeNow(new Date(block.end))"
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
:style="blockStyles(block, scope.timeStartPos, scope.timeDurationHeight)"
:id="block.$id"
@click="selectBlock($event, scope, block)">
{{ boats[scope.columnIndex]?.name }}<br />
{{ selectedBlock?.$id === block.$id ? 'Selected' : 'Available' }}
</div>
</div>
<div v-for="reservation in getBoatReservations(scope)" :key="reservation.$id">
<div
class="reservation column"
:style="reservationStyles(reservation, scope.timeStartPos, scope.timeDurationHeight)">
{{ getUserName(reservation.user) || 'loading...' }}<br />
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
</div>
</div>
</template>
</QCalendarDay>
</div>
</q-card>
</div>
</template>
<style lang="sass">
.boat-schedule-table-component
display: flex
max-height: 60vh
max-width: 98vw
.reservation
display: flex
position: absolute
justify-content: center
align-items: center
text-align: center
width: 100%
opacity: 1
margin: 0px
text-overflow: ellipsis
font-size: 0.8em
cursor: pointer
background: $accent
color: white
border: 1px solid black
.timeblock
display: flex
position: absolute
justify-content: center
text-align: center
align-items: center
width: 100%
opacity: 0.5
margin: 0px
text-overflow: ellipsis
font-size: 0.8em
cursor: pointer
background: $primary
color: white
border: 1px solid black
.selected
opacity: 1 !important
.q-calendar-day__interval--text
font-size: 0.8em
.q-calendar-day__day.q-current-day
padding: 1px
.q-calendar-day__head--days__column
background: $primary
color: white
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
addToDate,
createDayList,
createNativeLocaleFormatter,
getEndOfWeek,
getStartOfWeek,
parseTimestamp,
today,
} from '@quasar/quasar-ui-qcalendar';
import { ref, reactive, computed } from 'vue';
const selectedDate = defineModel<string>();
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]);
const locale = ref('en-CA');
const monthFormatter = monthFormatterFunc();
const dayFormatter = dayFormatterFunc();
const weekdayFormatter = weekdayFormatterFunc();
const today2 = computed(() => parseTimestamp(today()));
const parsedStart = computed(() =>
getStartOfWeek(
parseTimestamp(selectedDate.value || today()) as Timestamp,
weekdays,
today2.value as Timestamp
)
);
const parsedEnd = computed(() =>
getEndOfWeek(
parseTimestamp(selectedDate.value || today()) as Timestamp,
weekdays,
today2.value as Timestamp
)
);
const days = computed(() => {
if (parsedStart.value && parsedEnd.value) {
return createDayList(parsedStart.value, parsedEnd.value, today2.value as Timestamp, weekdays);
}
return [];
});
const dayStyle = computed(() => ({ width: 100 / weekdays.length + '%' }));
function onPrev() {
selectedDate.value = addToDate(parsedStart.value, { day: -7 }).date;
}
function onNext() {
selectedDate.value = addToDate(parsedStart.value, { day: 7 }).date;
}
function dayClass(day: Timestamp) {
return { 'date-button': true, 'selected-date-button': selectedDate.value === day.date };
}
function monthFormatterFunc() {
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'long' };
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'short' };
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
}
function weekdayFormatterFunc() {
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'long' };
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'short' };
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
}
function dayFormatterFunc() {
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: '2-digit' };
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: 'numeric' };
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
}
</script>
<template>
<div class="title-bar" style="display: flex">
<button tabindex="0" class="date-button direction-button direction-button__left" @click="onPrev">
<span class="q-calendar__focus-helper" tabindex="-1" />
</button>
<div class="dates-holder">
<div :key="parsedStart?.date" class="internal-dates-holder">
<div v-for="day in days" :key="day.date" :style="dayStyle">
<button tabindex="0" style="width: 100%" :class="dayClass(day)" @click="selectedDate = day.date">
<span class="q-calendar__focus-helper" tabindex="-1" />
<div style="width: 100%; font-size: 0.9em">{{ monthFormatter(day, true) }}</div>
<div style="width: 100%; font-size: 1.2em; font-weight: 700">{{ dayFormatter(day, false) }}</div>
<div style="width: 100%; font-size: 1em">{{ weekdayFormatter(day, true) }}</div>
</button>
</div>
</div>
</div>
<button tabindex="0" class="date-button direction-button direction-button__right" @click="onNext">
<span class="q-calendar__focus-helper" tabindex="-1" />
</button>
</div>
</template>
<style lang="sass">
.title-bar
position: relative
width: 100%
height: 70px
background: white
display: flex
flex-direction: row
flex: 1 0 100%
justify-content: space-between
align-items: center
overflow: hidden
border-radius: 3px
user-select: none
margin: 2px 0px 2px
.dates-holder
position: relative
width: 100%
align-items: center
display: flex
justify-content: space-between
color: #fff
overflow: hidden
user-select: none
.internal-dates-holder
position: relative
width: 100%
display: inline-flex
flex: 1 1 100%
flex-direction: row
justify-content: space-between
overflow: hidden
user-select: none
.direction-button
background: white
color: $primary
width: 40px
max-width: 50px !important
.direction-button__left
&:before
content: '<'
display: inline-flex
flex-direction: column
justify-content: center
height: 100%
font-weight: 900
font-size: 3em
.direction-button__right
&:before
content: '>'
display: inline-flex
flex-direction: column
justify-content: center
height: 100%
font-weight: 900
font-size: 3em
.date-button
color: $primary
background: white
z-index: 2
height: 100%
outline: 0
cursor: pointer
border-radius: 3px
display: inline-flex
flex: 1 0 auto
flex-direction: column
align-items: stretch
position: relative
border: 0
vertical-align: middle
padding: 0
font-size: 14px
line-height: 1.715em
text-decoration: none
font-weight: 500
text-transform: uppercase
text-align: center
user-select: none
.selected-date-button
color: white !important
background: $primary !important
</style>

28
app/layouts/admin.vue Normal file
View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { ref } from 'vue';
const leftDrawer = ref(false);
</script>
<template>
<q-layout view="hHh Lpr fFf">
<q-header elevated>
<q-toolbar>
<q-btn flat round dense icon="menu" @click="leftDrawer = !leftDrawer" />
<q-toolbar-title>Admin</q-toolbar-title>
</q-toolbar>
<q-tabs>
<q-route-tab icon="person" to="/admin/user" replace label="Users" />
<q-route-tab icon="directions_boat" to="/admin/boat" replace label="Boats" />
</q-tabs>
</q-header>
<q-drawer v-model="leftDrawer" side="left" bordered content-class="bg-grey-2">
<q-scroll-area class="fit q-pa-sm" />
</q-drawer>
<q-page-container>
<slot />
</q-page-container>
</q-layout>
</template>

45
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useQuasar } from 'quasar';
import BottomNavComponent from '~/components/BottomNavComponent.vue';
import LeftDrawer from '~/components/LeftDrawer.vue';
import { APP_VERSION } from '~/utils/version';
const q = useQuasar();
const route = useRoute();
const leftDrawerOpen = ref(false);
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
q.addressbarColor?.set('#14539a');
</script>
<template>
<q-layout view="hHh Lpr fFf">
<q-header elevated>
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
aria-label="Menu"
@click="toggleLeftDrawer" />
<q-toolbar-title>{{ route?.meta?.title as string }}</q-toolbar-title>
<q-space />
<div>v{{ APP_VERSION }}</div>
</q-toolbar>
</q-header>
<LeftDrawer
:drawer="leftDrawerOpen"
@drawer-toggle="toggleLeftDrawer" />
<q-page-container>
<slot />
</q-page-container>
<q-footer>
<BottomNavComponent />
</q-footer>
</q-layout>
</template>

View File

@@ -0,0 +1,27 @@
import { useAuthStore } from '~/stores/auth';
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore();
// Public routes (set via definePageMeta({ public: true }) in each page)
if (to.meta.public === true) {
// Redirect already-authenticated users away from /login
if (to.path === '/login' && authStore.currentUser) {
return navigateTo('/');
}
return;
}
// All other routes require auth
if (!authStore.currentUser) {
return navigateTo('/login');
}
// Role-based access: pages set requiredRoles via definePageMeta
const requiredRoles = to.meta.requiredRoles as string[] | undefined;
if (requiredRoles && requiredRoles.length > 0) {
if (!authStore.hasRequiredRole(requiredRoles)) {
return abortNavigation();
}
}
});

20
app/pages/[...slug].vue Normal file
View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
definePageMeta({ public: true, layout: false });
</script>
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">404</div>
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps />
</div>
</div>
</template>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
definePageMeta({ layout: 'admin', requiredRoles: ['admin'] });
</script>
<template> <template>
<q-page padding> <q-page padding>
<!-- content --> <!-- content -->
</q-page> </q-page>
</template> </template>
<script setup lang="ts">
</script>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
definePageMeta({ layout: 'admin', requiredRoles: ['admin'] });
</script>
<template> <template>
<q-page padding> <q-page padding>
<!-- content --> <!-- content -->
</q-page> </q-page>
</template> </template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useAuthStore } from '~/stores/auth';
definePageMeta({ public: true, layout: false });
const route = useRoute();
const authStore = useAuthStore();
const error = ref<string | null>(null);
onMounted(async () => {
const userId = route.query.userId as string | undefined;
const secret = route.query.secret as string | undefined;
if (!userId || !secret) {
error.value = 'Invalid magic link — missing parameters.';
return;
}
try {
await authStore.magicURLLogin(userId, secret);
await navigateTo('/');
} catch (e) {
console.error(e);
error.value = 'Login failed. The link may have expired.';
}
});
</script>
<template>
<q-page class="flex flex-center">
<div v-if="error" class="text-negative text-body1">{{ error }}</div>
<q-spinner v-else color="primary" size="50px" />
</q-page>
</template>

18
app/pages/boat.vue Normal file
View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
import BoatPreviewComponent from '~/components/boat/BoatPreviewComponent.vue';
import { useBoatStore } from '~/stores/boat';
import { storeToRefs } from 'pinia';
definePageMeta({ title: 'Boats' });
const boatStore = useBoatStore();
const { boats } = storeToRefs(boatStore);
onMounted(() => boatStore.fetchBoats());
</script>
<template>
<q-page>
<boat-preview-component :boats="boats" />
</q-page>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import CertificationComponent from '~/components/CertificationComponent.vue';
definePageMeta({ title: 'Certifications' });
</script>
<template>
<q-page padding>
<CertificationComponent />
</q-page>
</template>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
definePageMeta({ title: 'Checklists' });
</script>
<template> <template>
<toolbar-component pageTitle="Checklists" />
<q-page padding> <q-page padding>
<q-card bordered separator style="max-width: 400px"> <q-card bordered separator style="max-width: 400px">
<q-card-section clickable v-ripple> <q-card-section clickable v-ripple>
@@ -19,7 +22,3 @@
</q-card> </q-card>
</q-page> </q-page>
</template> </template>
<script setup lang="ts">
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
</script>

View File

@@ -1,12 +1,18 @@
<script lang="ts" setup>
import { useNavLinks } from '~/utils/navlinks';
definePageMeta({ title: 'OYS Borrow a Boat' });
const { enabledLinks } = useNavLinks();
</script>
<template> <template>
<ToolbarComponent />
<q-page class="row justify-center"> <q-page class="row justify-center">
<q-img alt="OYS Logo" src="~assets/oysqn_logo.png" fit="scale-down" /> <q-img alt="OYS Logo" src="/oysqn_logo.png" fit="scale-down" />
<q-list class="full-width mobile-only"> <q-list class="full-width mobile-only">
<q-item <q-item
v-for="link in links.filter((x) => x.front_links)" v-for="link in enabledLinks.filter((x) => x.front_links)"
:key="link.name" :key="link.name">
>
<q-btn <q-btn
:icon="link.icon" :icon="link.icon"
color="primary" color="primary"
@@ -15,14 +21,8 @@
:label="link.name" :label="link.name"
rounded rounded
class="full-width" class="full-width"
:align="'left'" :align="'left'" />
/>
</q-item> </q-item>
</q-list> </q-list>
</q-page> </q-page>
</template> </template>
<script lang="ts" setup>
import { links } from 'src/router/navlinks.js';
import ToolbarComponent from 'components/ToolbarComponent.vue';
</script>

127
app/pages/login.vue Normal file
View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Dialog, Notify } from 'quasar';
import { useAuthStore } from '~/stores/auth';
import { AppwriteException } from 'appwrite';
definePageMeta({ public: true, layout: false });
const email = ref('');
const token = ref('');
const userId = ref<string | undefined>();
const authStore = useAuthStore();
const sendMagicLink = async () => {
if (!email.value) {
Dialog.create({ message: 'Please enter your e-mail address.' });
return;
}
try {
await authStore.createMagicURLSession(email.value);
Dialog.create({ message: 'Check your e-mail for a magic login link.' });
} catch {
Dialog.create({ message: 'An error occurred. Please ask for help in Discord.' });
}
};
const doTokenLogin = async () => {
if (!userId.value) {
try {
const sessionToken = await authStore.createTokenSession(email.value);
userId.value = sessionToken.userId;
Dialog.create({ message: 'Check your e-mail for your login code.' });
} catch {
Dialog.create({ message: 'An error occurred. Please ask for help in Discord.' });
}
} else {
const notification = Notify.create({
type: 'primary',
position: 'top',
spinner: true,
message: 'Logging you in...',
timeout: 8000,
group: false,
});
try {
await authStore.tokenLogin(userId.value, token.value);
notification({ type: 'positive', message: 'Logged in!', timeout: 2000, spinner: false, icon: 'check_circle' });
await navigateTo('/');
} catch (error: unknown) {
if (error instanceof AppwriteException) {
if (error.type === 'user_session_already_exists') {
notification({ type: 'positive', message: 'Already logged in!', timeout: 2000, spinner: false, icon: 'check_circle' });
await navigateTo('/');
return;
}
Dialog.create({ title: 'Login Error!', message: error.message, persistent: true });
}
notification({ type: 'negative', message: 'Login failed.', timeout: 2000 });
}
}
};
</script>
<template>
<q-layout>
<q-page-container>
<q-page class="flex bg-image flex-center">
<q-card
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
<q-card-section>
<q-img fit="scale-down" src="/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
<div class="col text-h6">Log in</div>
</div>
</q-card-section>
<q-form @keydown.enter.prevent="doTokenLogin">
<q-card-section class="q-gutter-md">
<q-input
v-model="email"
label="E-Mail"
type="email"
color="darkblue"
filled />
<q-input
v-if="userId"
v-model="token"
label="6-digit code"
type="number"
color="darkblue"
filled />
</q-card-section>
</q-form>
<q-card-section class="q-pa-none">
<div class="row justify-center q-ma-sm">
<q-btn
v-if="!userId"
type="button"
@click="sendMagicLink"
color="secondary"
label="Send Magic Link"
style="width: 300px" />
</div>
<div class="row justify-center q-ma-sm">
<q-btn
type="button"
@click="doTokenLogin"
color="primary"
:label="userId ? 'Login' : 'Send Code'"
style="width: 300px" />
</div>
</q-card-section>
</q-card>
</q-page>
</q-page-container>
</q-layout>
</template>
<style>
.bg-image {
background-image: url('~/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
definePageMeta({ public: true, layout: false });
</script>
<template>
<q-layout>
<q-page-container>
<q-page padding>
<h1>Privacy Policy for Undock.ca</h1>
<p>
At Undock, accessible from https://undock.ca, one of our main priorities is the
privacy of our visitors. This Privacy Policy document contains types of information
that is collected and recorded by Undock and how we use it.
</p>
<h2>General Data Protection Regulation (GDPR)</h2>
<p>We are a Data Controller of your information.</p>
<h2>Log Files</h2>
<p>
Undock follows a standard procedure of using log files. These files log visitors
when they visit websites. The information collected by log files include internet
protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and
time stamp, referring/exit pages, and possibly the number of clicks.
</p>
<h2>Cookies and Web Beacons</h2>
<p>
Like any other website, Undock uses "cookies". These cookies are used to store
information including visitors' preferences, and the pages on the website that
the visitor accessed or visited.
</p>
<h2>Consent</h2>
<p>
By using our website, you hereby consent to our Privacy Policy and agree to its
<a href="/terms-of-service">terms</a>.
</p>
</q-page>
</q-page-container>
</q-layout>
</template>

76
app/pages/profile.vue Normal file
View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { useAuthStore } from '~/stores/auth';
import { ref } from 'vue';
definePageMeta({ title: 'Member Profile' });
const authStore = useAuthStore();
const newName = ref<string | undefined>();
const editName = async () => {
if (newName.value) {
try {
await authStore.updateName(newName.value);
newName.value = undefined;
} catch (e) {
console.log(e);
}
} else {
newName.value = authStore.currentUser?.name || '';
}
};
</script>
<template>
<q-page padding class="row">
<q-list class="col-sm-4 col-12">
<q-separator />
<q-item>
<q-item-section avatar>
<q-avatar icon="person" />
</q-item-section>
<q-item-section>
<q-item-label caption>Name</q-item-label>
<q-input
filled
v-model="newName"
@keydown.enter.prevent="editName"
v-if="newName !== undefined" />
<div v-else>{{ authStore.currentUser?.name }}</div>
</q-item-section>
<q-item-section avatar>
<q-btn
square
@click="editName"
:icon="newName !== undefined ? 'check' : 'edit'" />
<q-btn
v-if="newName !== undefined"
square
color="negative"
@click="newName = undefined"
icon="cancel" />
</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<q-avatar icon="email" />
</q-item-section>
<q-item-section>
<q-item-label caption>E-mail</q-item-label>
{{ authStore.currentUser?.email }}
</q-item-section>
</q-item>
<q-separator />
<q-item>
<q-item-section>
<q-item-label overline>Certifications</q-item-label>
<div>
<q-chip square icon="verified" color="green" text-color="white">J/27</q-chip>
<q-chip square icon="verified" color="blue" text-color="white">Capri25</q-chip>
<q-chip square icon="verified" color="grey-9" text-color="white">Night</q-chip>
</div>
</q-item-section>
</q-item>
</q-list>
</q-page>
</template>

38
app/pages/pwreset.vue Normal file
View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
// NOTE: Password reset removed — auth is magic link + OTP only.
definePageMeta({ public: true, layout: false });
</script>
<template>
<q-layout>
<q-page-container>
<q-page class="flex bg-image flex-center">
<q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
<q-card-section>
<q-img fit="scale-down" src="/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
<div class="text-h6">Password Reset</div>
<div class="text-body2 q-mt-md">
This application uses magic link and email code login no password is required.
</div>
</div>
</q-card-section>
<q-card-section class="text-center">
<q-btn flat color="primary" label="Back to Login" to="/login" />
</q-card-section>
</q-card>
</q-page>
</q-page-container>
</q-layout>
</template>
<style>
.bg-image {
background-image: url('~/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
}
</style>

7
app/pages/reference.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
definePageMeta({ title: 'Reference' });
</script>
<template>
<NuxtPage />
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import ReferencePreviewComponent from '~/components/ReferencePreviewComponent.vue';
import { ref } from 'vue';
import { useReferenceStore } from '~/stores/reference';
const items = ref(useReferenceStore().allItems);
</script>
<template>
<q-page padding>
<ReferencePreviewComponent :entries="items" />
</q-page>
</template>

View File

@@ -4,8 +4,7 @@
<q-video <q-video
title="Engine Starting" title="Engine Starting"
:ratio="16 / 9" :ratio="16 / 9"
src="https://www.youtube.com/embed/GMHMLDlkKcE" src="https://www.youtube.com/embed/GMHMLDlkKcE" />
></q-video>
</q-page> </q-page>
</template> </template>

7
app/pages/schedule.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
definePageMeta({ title: 'Schedule' });
</script>
<template>
<NuxtPage />
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import BoatReservationComponent from '~/components/BoatReservationComponent.vue';
import { useIntervalStore } from '~/stores/interval';
import type { Interval, Reservation } from '~/utils/schedule.types';
import { ref } from 'vue';
const route = useRoute();
const newReservation = ref<Reservation>();
if (typeof route.query.interval === 'string') {
useIntervalStore()
.fetchInterval(route.query.interval)
.then(
(interval: Interval) =>
(newReservation.value = <Reservation>{
resource: interval.resource,
start: interval.start,
end: interval.end,
})
);
}
</script>
<template>
<q-page>
<BoatReservationComponent v-model="newReservation" />
</q-page>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import BoatReservationComponent from '~/components/BoatReservationComponent.vue';
import { useReservationStore } from '~/stores/reservation';
import type { Reservation } from '~/utils/schedule.types';
import { onMounted, ref } from 'vue';
const route = useRoute();
const reservation = ref<Reservation>();
onMounted(async () => {
const id = route.params.id as string;
reservation.value = await useReservationStore().getReservationById(id);
});
</script>
<template>
<q-page>
<BoatReservationComponent v-model="reservation" />
</q-page>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { useNavLinks } from '~/utils/navlinks';
const { enabledLinks } = useNavLinks();
const navlinks = enabledLinks.find((link) => link.name === 'Schedule')?.sublinks;
</script>
<template>
<q-page padding>
<q-item v-for="link in navlinks" :key="link.name">
<q-btn
:icon="link.icon"
:color="link.color ? link.color : 'primary'"
size="1.25em"
:to="link.to"
:label="link.name"
rounded
class="full-width"
align="left" />
</q-item>
</q-page>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useReservationStore } from '~/stores/reservation';
import ReservationCardComponent from '~/components/scheduling/ReservationCardComponent.vue';
import { onMounted, ref } from 'vue';
const reservationStore = useReservationStore();
onMounted(() => reservationStore.fetchUserReservations());
const tab = ref('upcoming');
</script>
<template>
<q-page>
<q-tabs v-model="tab" inline-label class="text-primary">
<q-tab name="upcoming" icon="schedule" label="Upcoming" />
<q-tab name="past" icon="history" label="Past" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="tab" animated>
<q-tab-panel name="upcoming" class="q-pa-none">
<q-card clas="q-ma-md" v-if="!reservationStore.futureUserReservations.length">
<q-card-section>
<div class="text-h6">You don't have any upcoming bookings!</div>
<div class="text-h8">Why don't you go make one?</div>
</q-card-section>
<q-card-actions>
<q-btn
color="primary"
icon="event"
:size="`1.25em`"
label="Book Now"
rounded
class="full-width"
:align="'left'"
to="/schedule/book" />
</q-card-actions>
</q-card>
<div v-else>
<div
v-for="reservation in reservationStore.futureUserReservations"
:key="reservation.$id">
<ReservationCardComponent :modelValue="reservation" />
</div>
</div>
</q-tab-panel>
<q-tab-panel name="past" class="q-pa-none">
<div
v-for="reservation in reservationStore.pastUserReservations"
:key="reservation.$id">
<ReservationCardComponent :modelValue="reservation" />
</div>
</q-tab-panel>
</q-tab-panels>
</q-page>
</template>

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
QCalendarScheduler,
today,
} from '@quasar/quasar-ui-qcalendar';
import type { Boat } from '~/utils/boat.types';
import { useIntervalStore } from '~/stores/interval';
import { computed, onMounted, ref } from 'vue';
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
import { date } from 'quasar';
import IntervalTemplateComponent from '~/components/scheduling/IntervalTemplateComponent.vue';
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
import { storeToRefs } from 'pinia';
import { buildInterval, intervalsOverlapped } from '~/utils/schedule';
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
definePageMeta({ requiredRoles: ['Schedule Admins'] });
const selectedDate = ref(today());
const { fetchBoats } = useBoatStore();
const intervalStore = useIntervalStore();
const intervalTemplateStore = useIntervalTemplateStore();
const { boats } = storeToRefs(useBoatStore());
const intervalTemplates = intervalTemplateStore.getIntervalTemplates();
const calendar = ref();
const overlapped = ref();
const alert = ref(false);
const newTemplate = ref<IntervalTemplate>({
$id: '',
name: 'NewTemplate',
timeTuples: [['09:00', '12:00']],
});
onMounted(async () => {
await fetchBoats();
await intervalTemplateStore.fetchIntervalTemplates();
});
const filteredIntervals = (ts: Timestamp, boat: Boat) => {
return intervalStore.getIntervals(ts, boat);
};
const sortedIntervals = (ts: Timestamp, boat: Boat) => {
return computed(() =>
filteredIntervals(ts, boat).value.sort(
(a, b) => Date.parse(a.start) - Date.parse(b.start)
)
);
};
function resetNewTemplate() {
newTemplate.value = {
$id: 'unsaved',
name: 'NewTemplate',
timeTuples: [['09:00', '12:00']],
};
}
function createTemplate() {
newTemplate.value.$id = 'unsaved';
}
function createIntervals(boat: Boat, templateId: string, dateStr: string) {
const intervals = intervalsFromTemplate(boat, templateId, dateStr);
intervals.forEach((interval) => intervalStore.createInterval(interval));
}
function getIntervals(ts: Timestamp, boat: Boat) {
return intervalStore.getIntervals(ts, boat);
}
function intervalsFromTemplate(
boat: Boat,
templateId: string,
dateStr: string
): Interval[] {
const template = intervalTemplateStore
.getIntervalTemplates()
.value.find((t) => t.$id === templateId);
return template
? template.timeTuples.map((timeTuple: TimeTuple) =>
buildInterval(boat, timeTuple, dateStr)
)
: [];
}
function deleteBlock(block: Interval) {
if (block.$id) {
intervalStore.deleteInterval(block.$id);
}
}
function onDragEnter(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
if (e.target instanceof HTMLDivElement)
e.target.classList.add('bg-secondary');
}
}
function onDragOver(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
}
}
function onDragLeave(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
if (e.target instanceof HTMLDivElement)
e.target.classList.remove('bg-secondary');
}
}
function onDrop(
e: DragEvent,
type: string,
scope: { resource: Boat; timestamp: Timestamp }
) {
if (e.target instanceof HTMLDivElement)
e.target.classList.remove('bg-secondary');
if ((type === 'day' || type === 'head-day') && e.dataTransfer) {
const templateId = e.dataTransfer.getData('ID');
const dateStr = scope.timestamp.date;
const resource = scope.resource;
const existingIntervals = getIntervals(scope.timestamp, resource);
const boatsToApply = type === 'head-day' ? boats.value : [resource];
overlapped.value = boatsToApply
.map((boat) =>
intervalsOverlapped(
existingIntervals.value.concat(
intervalsFromTemplate(boat, templateId, dateStr)
)
)
)
.flat(1);
if (overlapped.value.length === 0) {
boatsToApply.map((b) => createIntervals(b, templateId, dateStr));
} else {
alert.value = true;
}
}
if (e.target instanceof HTMLDivElement)
e.target.classList.remove('bg-secondary');
return false;
}
function onToday() { calendar.value.moveToToday(); }
function onPrev() { calendar.value.prev(); }
function onNext() { calendar.value.next(); }
</script>
<template>
<div class="fit row">
<div class="q-pa-md">
<div class="scheduler col-12">
<NavigationBar @next="onNext" @today="onToday" @prev="onPrev" />
<q-calendar-scheduler
ref="calendar"
v-model="selectedDate"
v-model:model-resources="boats"
resource-key="$id"
resource-label="name"
view="week"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
animated
bordered
:drag-enter-func="onDragEnter"
:drag-over-func="onDragOver"
:drag-leave-func="onDragLeave"
:drop-func="onDrop"
day-min-height="50px"
cell-width="150px">
<template #day="{ scope }">
<div
v-if="filteredIntervals(scope.timestamp, scope.resource).value.length"
style="display: flex; flex-wrap: wrap; justify-content: space-evenly; align-items: center; font-size: 12px;">
<template
v-for="block in sortedIntervals(scope.timestamp, scope.resource).value"
:key="block.$id">
<q-chip class="cursor-pointer">
{{ date.formatDate(block.start, 'HH:mm') }} -
{{ date.formatDate(block.end, 'HH:mm') }}
</q-chip>
<q-btn size="xs" icon="delete" round @click="deleteBlock(block)" />
</template>
</div>
</template>
</q-calendar-scheduler>
</div>
</div>
<div class="q-pa-md" style="width: 400px">
<q-list padding bordered class="rounded-borders">
<q-item>
<q-item-section>
<q-item-label overline>Availability Templates</q-item-label>
<q-item-label caption>
Drag and drop a template to a boat / date to create booking availability
</q-item-label>
</q-item-section>
</q-item>
<q-card-actions align="right">
<q-btn label="Add Template" color="primary" @click="createTemplate" />
</q-card-actions>
<q-item v-if="newTemplate.$id === 'unsaved'">
<IntervalTemplateComponent
:model-value="newTemplate"
:edit="true"
@cancel="resetNewTemplate"
@saved="resetNewTemplate" />
</q-item>
<q-separator spaced />
<IntervalTemplateComponent
v-for="template in intervalTemplates"
:key="template.$id"
:model-value="template" />
</q-list>
</div>
<q-dialog v-model="alert">
<q-card>
<q-card-section>
<div class="text-h6">Warning!</div>
</q-card-section>
<q-card-section class="q-pt-none">
Conflicting times! Please delete overlapped items!
<q-chip v-for="item in overlapped" :key="item.index">
{{ boats.find((b) => b.$id === item.boatId)?.name }}:
{{ date.formatDate(item.start, 'hh:mm') }} -
{{ date.formatDate(item.end, 'hh:mm') }}
</q-chip>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="OK" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>

120
app/pages/schedule/view.vue Normal file
View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { useReservationStore } from '~/stores/reservation';
import { ref } from 'vue';
import { useAuthStore } from '~/stores/auth';
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { getDate, QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
import type { Boat } from '~/utils/boat.types';
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
import { useQuasar } from 'quasar';
import { formatTime } from '~/utils/schedule';
import { useIntervalStore } from '~/stores/interval';
import type { Interval, Reservation } from '~/utils/schedule.types';
import { storeToRefs } from 'pinia';
const reservationStore = useReservationStore();
const boatStore = useBoatStore();
const calendar = ref();
const $q = useQuasar();
const { getAvailableIntervals } = useIntervalStore();
const { selectedDate } = storeToRefs(useIntervalStore());
const currentUser = useAuthStore().currentUser;
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
return getAvailableIntervals(timestamp, boat)
.value.concat(boatReservationEvents(timestamp, boat))
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
};
const createReservationFromInterval = (interval: Interval | Reservation) => {
if (interval.user) {
if (interval.user === currentUser?.$id) {
navigateTo(`/schedule/edit/${interval.$id}`);
} else {
return false;
}
} else {
navigateTo({ path: '/schedule/book', query: { interval: interval.$id } });
}
};
function handleSwipe({ ...event }: { direction: string }) {
if (event.direction === 'right') {
calendar.value?.prev();
} else {
calendar.value?.next();
}
}
const boatReservationEvents = (
timestamp: Timestamp,
resource: Boat | undefined
): Reservation[] => {
if (!resource) return [] as Reservation[];
return reservationStore.getReservationsByDate(
getDate(timestamp),
(resource as Boat).$id
).value;
};
function onToday() { calendar.value.moveToToday(); }
function onPrev() { calendar.value.prev(); }
function onNext() { calendar.value.next(); }
</script>
<template>
<q-page>
<div class="col">
<navigation-bar @today="onToday" @prev="onPrev" @next="onNext" />
</div>
<div class="col q-ma-sm">
<q-calendar-scheduler
ref="calendar"
v-model="selectedDate"
v-model:model-resources="boatStore.boats"
resource-key="$id"
resource-label="displayName"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
:view="$q.screen.gt.md ? 'week' : 'day'"
v-touch-swipe.mouse.left.right="handleSwipe"
:max-days="$q.screen.lt.sm ? 3 : 7"
animated
bordered
style="--calendar-resources-width: 40px">
<template #day="{ scope }">
<div
v-for="interval in getSortedIntervals(scope.timestamp, scope.resource)"
:key="interval.$id"
class="q-pb-xs row"
@click="createReservationFromInterval(interval)">
<q-badge
multi-line
:class="!interval.user ? 'cursor-pointer' : null"
class="col-12 q-pa-sm"
:transparent="interval.user != undefined"
:color="interval.user ? 'secondary' : 'primary'"
:outline="!interval.user"
:id="interval.$id">
{{
interval.user
? useAuthStore().getUserNameById(interval.user)
: 'Available'
}}
<br />
{{ formatTime(interval.start) }} to
<br />
{{ formatTime(interval.end) }}
</q-badge>
</div>
</template>
</q-calendar-scheduler>
</div>
</q-page>
</template>
<style lang="sass">
.q-calendar-scheduler__resource
background-color: $primary
color: white
font-weight: bold
</style>

40
app/pages/signup.vue Normal file
View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
// NOTE: Password-based registration removed (magic link + OTP only).
// This page is a stub — registration is handled by admin invitation.
definePageMeta({ public: true, layout: false });
</script>
<template>
<q-layout>
<q-page-container>
<q-page class="flex bg-image flex-center">
<q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
<q-card-section>
<q-img fit="scale-down" src="/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
<div class="text-h6">Sign Up</div>
<div class="text-body2 q-mt-md">
Account registration is managed by the club administrator.
Please contact your club admin to request access.
</div>
</div>
</q-card-section>
<q-card-section class="text-center">
<q-btn flat color="primary" label="Back to Login" to="/login" />
</q-card-section>
</q-card>
</q-page>
</q-page-container>
</q-layout>
</template>
<style>
.bg-image {
background-image: url('~/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
}
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
definePageMeta({ public: true, layout: false });
</script>
<template>
<q-layout>
<q-page-container>
<q-page padding>
<h1>Website Terms and Conditions of Use</h1>
<h2>1. Terms</h2>
<p>
By accessing this Website, accessible from https://undock.ca, you are
agreeing to be bound by these Website Terms and Conditions of Use and
agree that you are responsible for the agreement with any applicable
local laws. If you disagree with any of these terms, you are
prohibited from accessing this site. The materials contained in this
Website are protected by copyright and trade mark law.
</p>
<h2>2. Use License</h2>
<p>
Permission is granted to temporarily download one copy of the
materials on undock.ca's Website for personal, non-commercial
transitory viewing only. This is the grant of a license, not a
transfer of title, and under this license you may not:
</p>
<ul>
<li>modify or copy the materials;</li>
<li>use the materials for any commercial purpose or for any public display;</li>
<li>attempt to reverse engineer any software contained on undock.ca's Website;</li>
<li>remove any copyright or other proprietary notations from the materials; or</li>
<li>transferring the materials to another person or "mirror" the materials on any other server.</li>
</ul>
<h2>3. Disclaimer</h2>
<p>
All the materials on undock.ca's Website are provided "as is". undock.ca makes no
warranties, may it be expressed or implied, therefore negates all other warranties.
</p>
<h2>4. Limitations</h2>
<p>
undock.ca or its suppliers will not be hold accountable for any damages that will
arise with the use or inability to use the materials on undock.ca's Website.
</p>
<h2>5. Revisions and Errata</h2>
<p>
The materials appearing on undock.ca's Website may include technical, typographical,
or photographic errors. undock.ca will not promise that any of the materials in this
Website are accurate, complete, or current.
</p>
<h2>6. Links</h2>
<p>
undock.ca has not reviewed all of the sites linked to its Website and is not
responsible for the contents of any such linked site.
</p>
<h2>7. Site Terms of Use Modifications</h2>
<p>
undock.ca may revise these Terms of Use for its Website at any time without prior notice.
</p>
<h2>8. Your Privacy</h2>
<p>Please read our <a href="/privacy-policy">Privacy Policy.</a></p>
<h2>9. Governing Law</h2>
<p>
Any claim related to undock.ca's Website shall be governed by the laws of ca without
regards to its conflict of law provisions.
</p>
</q-page>
</q-page-container>
</q-layout>
</template>

View File

@@ -0,0 +1,15 @@
import { useAuthStore } from '~/stores/auth';
import { initAppwriteClient } from '~/utils/appwrite';
export default defineNuxtPlugin(async () => {
const config = useRuntimeConfig();
const endpoint = config.public.appwriteEndpoint as string;
const projectId = config.public.appwriteProjectId as string;
if (!endpoint || !projectId) {
console.error('Appwrite config missing — check NUXT_PUBLIC_APPWRITE_ENDPOINT and NUXT_PUBLIC_APPWRITE_PROJECT_ID');
return;
}
initAppwriteClient(endpoint, projectId);
const authStore = useAuthStore();
await authStore.init();
});

111
app/stores/auth.ts Normal file
View File

@@ -0,0 +1,111 @@
import { defineStore } from 'pinia';
import { ID, account, functions, teams } from '~/utils/appwrite';
import { ExecutionMethod, type Models } from 'appwrite';
import { computed, ref } from 'vue';
import { useBoatStore } from './boat';
import { useReservationStore } from './reservation';
export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<Models.User<Models.Preferences> | null>(null);
const currentUserTeams = ref<Models.TeamList<Models.Preferences> | null>(
null
);
const userNames = ref<Record<string, string>>({});
async function init() {
try {
currentUser.value = await account.get();
currentUserTeams.value = await teams.list();
await useBoatStore().fetchBoats();
await useReservationStore().fetchUserReservations();
} catch {
currentUser.value = null;
currentUserTeams.value = null;
}
}
const currentUserTeamNames = computed(() =>
currentUserTeams.value
? currentUserTeams.value.teams.map((team) => team.name)
: []
);
const hasRequiredRole = (requiredRoles: string[]): boolean => {
return requiredRoles.some((role) =>
currentUserTeamNames.value.includes(role)
);
};
async function createTokenSession(email: string) {
return await account.createEmailToken(ID.unique(), email);
}
async function createMagicURLSession(email: string) {
return await account.createMagicURLToken(
ID.unique(),
email,
window.location.origin + '/auth/callback'
);
}
async function tokenLogin(userId: string, token: string) {
await account.createSession(userId, token);
await init();
}
async function magicURLLogin(userId: string, secret: string) {
await account.updateMagicURLSession(userId, secret);
await init();
}
function getUserNameById(id: string | undefined | null): string {
if (!id) return 'No User';
try {
if (!userNames.value[id]) {
userNames.value[id] = 'Loading...';
functions
.createExecution(
'userinfo',
'',
false,
'/userinfo/' + id,
ExecutionMethod.GET
)
.then((res) => {
if (res.responseBody) {
userNames.value[id] = JSON.parse(res.responseBody).name;
} else {
console.error(res, id);
}
});
}
} catch (e) {
console.error('Failed to get username. Error: ' + e);
}
return userNames.value[id] ?? 'Unknown';
}
function logout() {
return account.deleteSession('current').then(() => {
currentUser.value = null;
});
}
async function updateName(name: string) {
await account.updateName(name);
currentUser.value = await account.get();
}
return {
currentUser,
getUserNameById,
hasRequiredRole,
updateName,
createTokenSession,
createMagicURLSession,
tokenLogin,
magicURLLogin,
logout,
init,
};
});

29
app/stores/boat.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineStore } from 'pinia';
import { AppwriteIds, databases } from '~/utils/appwrite';
import { ref } from 'vue';
import type { Boat } from '~/utils/boat.types';
export { type Boat } from '~/utils/boat.types';
export const useBoatStore = defineStore('boat', () => {
const boats = ref<Boat[]>([]);
async function fetchBoats() {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.boat
);
boats.value = response.documents as unknown as Boat[];
} catch (error) {
console.error('Failed to fetch boats', error);
}
}
const getBoatById = (id: string | null | undefined): Boat | null => {
if (!id) return null;
return boats.value?.find((b) => b.$id === id) || null;
};
return { boats, fetchBoats, getBoatById };
});

163
app/stores/interval.ts Normal file
View File

@@ -0,0 +1,163 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type { Boat } from '~/utils/boat.types';
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { today } from '@quasar/quasar-ui-qcalendar';
import type { Interval } from '~/utils/schedule.types';
import { AppwriteIds, databases } from '~/utils/appwrite';
import { ID, Query } from 'appwrite';
import { useReservationStore } from './reservation';
import type { LoadingTypes } from '~/utils/misc';
import { useRealtimeStore } from './realtime';
export const useIntervalStore = defineStore('interval', () => {
const intervals = ref(new Map<string, Interval>());
const dateStatus = ref(new Map<string, LoadingTypes>());
const selectedDate = ref<string>(today());
const reservationStore = useReservationStore();
const realtimeStore = useRealtimeStore();
realtimeStore.register(
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.interval}.documents`,
(response) => {
const payload = response.payload as unknown as Interval;
if (!payload.$id) return;
if (
response.events.includes('databases.*.collections.*.documents.*.delete')
) {
intervals.value.delete(payload.$id);
} else {
intervals.value.set(payload.$id, payload);
}
}
);
const getIntervals = (date: Timestamp | string, boat?: Boat) => {
const searchDate = typeof date === 'string' ? date : date.date;
const dayStart = new Date(searchDate + 'T00:00');
const dayEnd = new Date(searchDate + 'T23:59');
if (dateStatus.value.get(searchDate) === undefined) {
dateStatus.value.set(searchDate, 'pending');
fetchIntervals(searchDate);
}
return computed(() => {
return Array.from(intervals.value.values()).filter((interval) => {
const intervalStart = new Date(interval.start);
const intervalEnd = new Date(interval.end);
const isWithinDay = intervalStart < dayEnd && intervalEnd > dayStart;
const matchesBoat = boat ? boat.$id === interval.resource : true;
return isWithinDay && matchesBoat;
});
});
};
const getAvailableIntervals = (date: Timestamp | string, boat?: Boat) => {
return computed(() =>
getIntervals(date, boat).value.filter((interval) => {
return !reservationStore.isResourceTimeOverlapped(
interval.resource,
new Date(interval.start),
new Date(interval.end)
);
})
);
};
async function fetchInterval(id: string): Promise<Interval> {
return (await databases.getDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.interval,
id
)) as Interval;
}
async function fetchIntervals(dateString: string) {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.interval,
[
Query.greaterThanEqual(
'end',
new Date(dateString + 'T00:00').toISOString()
),
Query.lessThanEqual(
'start',
new Date(dateString + 'T23:59').toISOString()
),
Query.limit(50),
]
);
response.documents.forEach((d) =>
intervals.value.set(d.$id, d as unknown as Interval)
);
dateStatus.value.set(dateString, 'loaded');
console.info(`Loaded ${response.documents.length} intervals from server`);
} catch (error) {
console.error('Failed to fetch intervals', error);
dateStatus.value.set(dateString, 'error');
}
}
const createInterval = async (interval: Interval) => {
try {
const response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.interval,
ID.unique(),
interval
);
intervals.value.set(response.$id, response as unknown as Interval);
} catch (e) {
console.error('Error creating Interval: ' + e);
}
};
const updateInterval = async (interval: Interval) => {
try {
if (interval.$id) {
const response = await databases.updateDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.interval,
interval.$id,
{ ...interval, $id: undefined }
);
intervals.value.set(response.$id, response as unknown as Interval);
console.info(`Saved Interval: ${interval.$id}`);
} else {
console.error('Update interval called without an ID');
}
} catch (e) {
console.error('Error updating Interval: ' + e);
}
};
const deleteInterval = async (id: string) => {
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.interval,
id
);
intervals.value.delete(id);
console.info(`Deleted interval: ${id}`);
} catch (e) {
console.error('Error deleting Interval: ' + e);
}
};
return {
getIntervals,
getAvailableIntervals,
fetchIntervals,
fetchInterval,
createInterval,
updateInterval,
deleteInterval,
selectedDate,
intervals,
};
});

View File

@@ -0,0 +1,101 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
import type { IntervalTemplate } from '~/utils/schedule.types';
import { defineStore } from 'pinia';
import { AppwriteIds, databases } from '~/utils/appwrite';
import type { Models } from 'appwrite';
import { ID } from 'appwrite';
import { arrayToTimeTuples } from '~/utils/schedule';
export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
const intervalTemplates = ref<IntervalTemplate[]>([]);
const getIntervalTemplates = (): Ref<IntervalTemplate[]> => {
if (!intervalTemplates.value) fetchIntervalTemplates();
return intervalTemplates;
};
async function fetchIntervalTemplates() {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.intervalTemplate
);
intervalTemplates.value = response.documents.map((d): IntervalTemplate => {
const doc = d as unknown as { timeTuple: string[] } & Models.Document;
return {
...doc,
timeTuples: arrayToTimeTuples(doc.timeTuple),
} as unknown as IntervalTemplate;
});
} catch (error) {
console.error('Failed to fetch timeblock templates', error);
}
}
const createIntervalTemplate = async (template: IntervalTemplate) => {
try {
const response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.intervalTemplate,
ID.unique(),
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
);
intervalTemplates.value.push(response as unknown as IntervalTemplate);
} catch (e) {
console.error('Error creating IntervalTemplate: ' + e);
}
};
const deleteIntervalTemplate = async (id: string) => {
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.intervalTemplate,
id
);
intervalTemplates.value = intervalTemplates.value.filter(
(template) => template.$id !== id
);
} catch (e) {
console.error('Error deleting IntervalTemplate: ' + e);
}
};
const updateIntervalTemplate = async (
template: IntervalTemplate,
id: string
) => {
try {
const response = await databases.updateDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.intervalTemplate,
id,
{
name: template.name,
timeTuple: template.timeTuples.flat(2),
}
);
intervalTemplates.value = intervalTemplates.value.map((b) =>
b.$id !== id
? b
: ({
...response,
timeTuples: arrayToTimeTuples(
(response as unknown as { timeTuple: string[] }).timeTuple
),
} as unknown as IntervalTemplate)
);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);
}
};
return {
getIntervalTemplates,
fetchIntervalTemplates,
createIntervalTemplate,
deleteIntervalTemplate,
updateIntervalTemplate,
};
});

View File

@@ -18,16 +18,4 @@ export const useMemberProfileStore = defineStore('memberProfile', {
state: () => ({ state: () => ({
...getSampleData(), ...getSampleData(),
}), }),
// getters: {
// doubleCount (state) {
// return state.counter * 2;
// }
// },
// actions: {
// increment () {
// this.counter++;
// }
// }
}); });

20
app/stores/realtime.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineStore } from 'pinia';
import { client } from '~/utils/appwrite';
import { ref } from 'vue';
import type { RealtimeResponseEvent } from 'appwrite';
export const useRealtimeStore = defineStore('realtime', () => {
const subscriptions = ref<Map<string, () => void>>(new Map());
const register = (
channel: string,
fn: (response: RealtimeResponseEvent<unknown>) => void
) => {
if (subscriptions.value.has(channel)) return;
subscriptions.value.set(channel, client.subscribe(channel, fn));
};
return {
register,
};
});

View File

@@ -20,26 +20,26 @@ function getSampleData(): ReferenceEntry[] {
content: `Its hard to imagine that a modern 27 foot sailboat with a classic look, superb content: `Its hard to imagine that a modern 27 foot sailboat with a classic look, superb
stability, and easy to manage rig is such a fast boat. 123-126 PHRF. No longer do you stability, and easy to manage rig is such a fast boat. 123-126 PHRF. No longer do you
have to substitute speed for comfort, or own separate boats for racing and cruising. The have to substitute speed for comfort, or own separate boats for racing and cruising. The
8\ long cockpit seats 4 to 5 comfortably. Below deck you can sleep 5. And with head and 8' long cockpit seats 4 to 5 comfortably. Below deck you can sleep 5. And with head and
stove, the J/27 is the perfect weekend cruiser. stove, the J/27 is the perfect weekend cruiser.
Fun and Fast. There are some impressive victories to back this up, but that doesn\t Fun and Fast. There are some impressive victories to back this up, but that doesn't
tell the whole story. The J/27 is fun and responsive. Nothing is more exhilarating than tell the whole story. The J/27 is fun and responsive. Nothing is more exhilarating than
popping the J/27\s kite in a good breeze for a downhill sleigh ride. 15+ knots planing popping the J/27's kite in a good breeze for a downhill sleigh ride. 15+ knots planing
off the wave-tops is easy. And most importantly, this off-wind speed doesn\t sacrifice off the wave-tops is easy. And most importantly, this off-wind speed doesn't sacrifice
upwind performance. Going to windward in the J/27 is a dream, it has the solid, balanced upwind performance. Going to windward in the J/27 is a dream, it has the solid, balanced
"feel" of a traditional keelboat. The J/27 points higher and goes faster than many 30-35 "feel" of a traditional keelboat. The J/27 points higher and goes faster than many 30-35
footers! footers!
One-Design Racing. Even more fun is sailing a one-design race around the buoys. The One-Design Racing. Even more fun is sailing a one-design race around the buoys. The
J/27\s close-windedness makes it very tactical, as even 5 degree wind shifts bring J/27's close-windedness makes it very tactical, as even 5 degree wind shifts bring
significant gains. Then off wind, you quickly learn to play gibe angles as the boat\s significant gains. Then off wind, you quickly learn to play gibe angles as the boat's
acceleration gains you valuable ground on the competition. The J/27 is remarkably agile acceleration gains you valuable ground on the competition. The J/27 is remarkably agile
and responsive in lighter winds, which is unusual for a boat that feels so solid. and responsive in lighter winds, which is unusual for a boat that feels so solid.
All-Day Comfort. Sailing past larger boats is always satisfying... especially when it\s All-Day Comfort. Sailing past larger boats is always satisfying... especially when it's
effortless and you can\t be written off as being wet and uncomfortable. Design is the effortless and you can't be written off as being wet and uncomfortable. Design is the
difference. It\s all done from a cockpit which holds several people more than is possible difference. It's all done from a cockpit which holds several people more than is possible
on other 27-footers. Correctly angled backrests and decks at elbow level provide restful on other 27-footers. Correctly angled backrests and decks at elbow level provide restful
and secure seating. Harken mainsheet, vang, traveler, and backstay systems; four Barient and secure seating. Harken mainsheet, vang, traveler, and backstay systems; four Barient
winches; a beautiful double spreader, tapered, fractional rig spar by Hall . . . make winches; a beautiful double spreader, tapered, fractional rig spar by Hall . . . make
@@ -57,14 +57,14 @@ function getSampleData(): ReferenceEntry[] {
starboard is a comfortable quarter berth. Enough room below for a family of four or a starboard is a comfortable quarter berth. Enough room below for a family of four or a
couple for a nice weekend romp to your favorite sailing anchorage. couple for a nice weekend romp to your favorite sailing anchorage.
Durable and Stable. The J/27\s secure big boat feel is created by concentrating 1530 Durable and Stable. The J/27's secure big boat feel is created by concentrating 1530
pounds of lead very low in the keel while using high strength to eight ratio laminates pounds of lead very low in the keel while using high strength to eight ratio laminates
in the hull. Unidirectional E-glass on either side of pre-sealed Baltek CK57 aircraft in the hull. Unidirectional E-glass on either side of pre-sealed Baltek CK57 aircraft
grade, Lloyd\s approved, end grain balsa sandwich construction means superior torsion and grade, Lloyd's approved, end grain balsa sandwich construction means superior torsion and
impact resistance. Light ends, low freeboard, and the low center of gravity of a lead keel impact resistance. Light ends, low freeboard, and the low center of gravity of a lead keel
coupled with low wetted surface and a generous sailplan of 362 sq. ft. achieves exceptional coupled with low wetted surface and a generous sailplan of 362 sq. ft. achieves exceptional
sail area and stability relative to displacement. Hence, sparkling performance in both sail area and stability relative to displacement. Hence, sparkling performance in both
light and heavy air...something that doesn\t happen with iron keels and box-like hulls. light and heavy air...something that doesn't happen with iron keels and box-like hulls.
Strong Class Strict Rules. The J/27 Class Association, owner driven and over 190 boats Strong Class Strict Rules. The J/27 Class Association, owner driven and over 190 boats
strong, sail in North American, Midwinter, and Regional championships. A superb J/27 Class strong, sail in North American, Midwinter, and Regional championships. A superb J/27 Class
@@ -113,15 +113,11 @@ export const useReferenceStore = defineStore('reference', {
getters: { getters: {
getCategory(state) { getCategory(state) {
(category: string) => { return (category: string) => {
return state.allItems.filter((c) => c.category === category); return state.allItems.filter((c) => c.category === category);
}; };
}, },
}, },
actions: { actions: {},
// increment () {
// this.counter++;
// }
},
}); });

283
app/stores/reservation.ts Normal file
View File

@@ -0,0 +1,283 @@
import { defineStore } from 'pinia';
import type { Reservation } from '~/utils/schedule.types';
import type { ComputedRef } from 'vue';
import { computed, reactive } from 'vue';
import { AppwriteIds, databases } from '~/utils/appwrite';
import { ID, Query } from 'appwrite';
import { date, useQuasar } from 'quasar';
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { parseDate, today } from '@quasar/quasar-ui-qcalendar';
import type { LoadingTypes } from '~/utils/misc';
import { useAuthStore } from './auth';
import { isPast } from '~/utils/schedule';
import { useRealtimeStore } from './realtime';
export const useReservationStore = defineStore('reservation', () => {
const reservations = reactive<Map<string, Reservation>>(new Map());
const datesLoaded = reactive<Record<string, LoadingTypes>>({});
const userReservations = reactive<Map<string, Reservation>>(new Map());
const authStore = useAuthStore();
const $q = useQuasar();
const realtimeStore = useRealtimeStore();
realtimeStore.register(
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.reservation}.documents`,
(response) => {
const payload = response.payload as unknown as Reservation;
if (payload.$id) {
if (
response.events.includes(
'databases.*.collections.*.documents.*.delete'
)
) {
reservations.delete(payload.$id);
userReservations.delete(payload.$id);
} else {
reservations.set(payload.$id, payload);
if (payload.user === authStore.currentUser?.$id)
userReservations.set(payload.$id, payload);
}
}
}
);
const fetchReservationsForDateRange = async (
start: string = today(),
end: string = start
) => {
const startDate = new Date(start < end ? start : end + 'T00:00');
const endDate = new Date(start < end ? end : start + 'T23:59');
if (getUnloadedDates(startDate, endDate).length === 0) return;
setDateLoaded(startDate, endDate, 'pending');
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
[
Query.greaterThanEqual('end', startDate.toISOString()),
Query.lessThanEqual('start', endDate.toISOString()),
]
);
response.documents.forEach((d) =>
reservations.set(d.$id, d as unknown as Reservation)
);
setDateLoaded(startDate, endDate, 'loaded');
} catch (error) {
console.error('Failed to fetch reservations', error);
setDateLoaded(startDate, endDate, 'error');
}
};
const getReservationById = async (id: string) => {
try {
const response = await databases.getDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
id
);
return response as unknown as Reservation;
} catch (error) {
console.error('Failed to fetch reservation: ', error);
}
};
const createOrUpdateReservation = async (
reservation: Reservation
): Promise<Reservation> => {
let response;
try {
if (reservation.$id) {
response = await databases.updateDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
reservation.$id,
reservation
);
} else {
response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
ID.unique(),
reservation
);
}
reservations.set(response.$id, response as unknown as Reservation);
userReservations.set(response.$id, response as unknown as Reservation);
console.info('Reservation booked: ', response);
return response as unknown as Reservation;
} catch (e) {
console.error('Error creating Reservation: ' + e);
throw e;
}
};
const deleteReservation = async (
reservation: string | Reservation | null | undefined
) => {
if (!reservation) return false;
const id = typeof reservation === 'string' ? reservation : reservation.$id;
if (!id) return false;
const status = $q.notify({
color: 'secondary',
textColor: 'white',
message: 'Deleting Reservation',
spinner: true,
closeBtn: 'Dismiss',
position: 'top',
timeout: 0,
group: false,
});
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
id
);
reservations.delete(id);
userReservations.delete(id);
console.info(`Deleted reservation: ${id}`);
status({
color: 'warning',
message: 'Reservation Deleted',
spinner: false,
icon: 'delete',
timeout: 4000,
});
} catch (e) {
console.error('Error deleting reservation: ' + e);
status({
color: 'negative',
message: 'Failed to Delete Reservation',
spinner: false,
icon: 'error',
});
}
};
const setDateLoaded = (start: Date, end: Date, state: LoadingTypes) => {
if (start > end) return [];
let curDate = start;
while (curDate < end) {
datesLoaded[(parseDate(curDate) as Timestamp).date] = state;
curDate = date.addToDate(curDate, { days: 1 });
}
};
const getUnloadedDates = (start: Date, end: Date): string[] => {
if (start > end) return [];
let curDate = start;
const unloaded = [];
while (curDate < end) {
const parsedDate = (parseDate(curDate) as Timestamp).date;
if (datesLoaded[parsedDate] === undefined) unloaded.push(parsedDate);
curDate = date.addToDate(curDate, { days: 1 });
}
return unloaded;
};
const getReservationsByDate = (
searchDate: string,
boat?: string
): ComputedRef<Reservation[]> => {
if (!datesLoaded[searchDate]) {
fetchReservationsForDateRange(searchDate);
}
const dayStart = new Date(searchDate + 'T00:00');
const dayEnd = new Date(searchDate + 'T23:59');
return computed(() => {
return Array.from(reservations.values()).filter((reservation) => {
const reservationStart = new Date(reservation.start);
const reservationEnd = new Date(reservation.end);
const isWithinDay =
reservationStart < dayEnd && reservationEnd > dayStart;
const matchesBoat = boat ? boat === reservation.resource : true;
return isWithinDay && matchesBoat;
});
});
};
const getConflictingReservations = (
resource: string,
start: Date,
end: Date
): Reservation[] => {
return Array.from(reservations.values()).filter(
(entry) =>
entry.resource === resource &&
new Date(entry.start) < end &&
new Date(entry.end) > start
);
};
const isResourceTimeOverlapped = (
resource: string,
start: Date,
end: Date
): boolean => {
return getConflictingReservations(resource, start, end).length > 0;
};
const isReservationOverlapped = (res: Reservation): boolean => {
return isResourceTimeOverlapped(
res.resource,
new Date(res.start),
new Date(res.end)
);
};
const fetchUserReservations = async () => {
if (!authStore.currentUser) return;
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.reservation,
[Query.equal('user', authStore.currentUser.$id)]
);
response.documents.forEach((d) =>
userReservations.set(d.$id, d as unknown as Reservation)
);
} catch (error) {
console.error('Failed to fetch reservations for user: ', error);
}
};
const sortedUserReservations = computed((): Reservation[] =>
[...userReservations.values()].sort(
(a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()
)
);
const futureUserReservations = computed((): Reservation[] => {
if (!sortedUserReservations.value) return [];
return sortedUserReservations.value.filter((b) => !isPast(b.end));
});
const pastUserReservations = computed((): Reservation[] => {
if (!sortedUserReservations.value) return [];
return sortedUserReservations.value?.filter((b) => isPast(b.end));
});
return {
getReservationsByDate,
getReservationById,
createOrUpdateReservation,
deleteReservation,
fetchReservationsForDateRange,
isReservationOverlapped,
isResourceTimeOverlapped,
getConflictingReservations,
fetchUserReservations,
sortedUserReservations,
futureUserReservations,
pastUserReservations,
userReservations,
};
});

41
app/utils/appwrite.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Client, Account, Databases, Functions, ID, Teams } from 'appwrite';
const client = new Client();
function initAppwriteClient(endpoint: string, projectId: string) {
client.setEndpoint(endpoint).setProject(projectId);
}
type AppwriteIDConfig = {
databaseId: string;
collection: {
boat: string;
reservation: string;
interval: string;
intervalTemplate: string;
// task, taskTags, skillTags — parked; collections not yet created in bab_prod
};
function: {
userinfo: string;
};
};
const AppwriteIds: AppwriteIDConfig = {
databaseId: 'bab_prod',
collection: {
boat: 'boat',
reservation: 'reservation',
interval: 'interval',
intervalTemplate: 'intervalTemplate',
},
function: {
userinfo: 'userinfo',
},
};
const account = new Account(client);
const databases = new Databases(client);
const functions = new Functions(client);
const teams = new Teams(client);
export { client, account, databases, functions, teams, ID, AppwriteIds, initAppwriteClient };

20
app/utils/boat.types.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { Models } from 'appwrite';
export interface Boat extends Models.Document {
$id: string;
name: string;
displayName?: string;
class?: string;
year?: number;
imgSrc?: string;
iconSrc?: string;
bookingAvailable: boolean;
requiredCerts: string[];
maxPassengers: number;
defects: {
type: string;
severity: string;
description: string;
detail?: string;
}[];
}

7
app/utils/misc.ts Normal file
View File

@@ -0,0 +1,7 @@
export function getNewId(): string {
return [...Array(20)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join('');
}
export type LoadingTypes = 'loaded' | 'pending' | 'error' | undefined;

112
app/utils/navlinks.ts Normal file
View File

@@ -0,0 +1,112 @@
import { useAuthStore } from '~/stores/auth';
export type Link = {
name: string;
to?: string;
icon: string;
front_links?: boolean;
enabled?: boolean;
color?: string;
sublinks?: Link[];
requiredRoles?: string[];
};
export const links: Link[] = [
{
name: 'Home',
to: '/',
icon: 'home',
front_links: false,
enabled: true,
},
{
name: 'Profile',
to: '/profile',
icon: 'account_circle',
front_links: false,
enabled: true,
},
{
name: 'Boats',
to: '/boat',
icon: 'sailing',
front_links: true,
enabled: true,
},
{
name: 'Schedule',
to: '/schedule',
icon: 'calendar_month',
front_links: true,
enabled: true,
sublinks: [
{ name: 'My View', to: '/schedule/list', icon: 'list', front_links: false, enabled: true },
{ name: 'Book', to: '/schedule/book', icon: 'more_time', front_links: false, enabled: true },
{ name: 'Calendar', to: '/schedule/view', icon: 'calendar_month', front_links: false, enabled: true },
],
},
{
name: 'Certifications',
to: '/certification',
icon: 'verified',
front_links: true,
enabled: false,
},
{
name: 'Checklists',
to: '/checklist',
icon: 'checklist',
front_links: true,
enabled: false,
},
{
name: 'Reference',
to: '/reference',
icon: 'info_outline',
front_links: true,
enabled: false,
},
{
name: 'Manage',
icon: 'tune',
enabled: true,
requiredRoles: ['Schedule Admins'],
color: 'negative',
sublinks: [
{
name: 'Schedule',
to: '/schedule/manage',
icon: 'edit_calendar',
front_links: false,
enabled: true,
color: 'accent',
requiredRoles: ['Schedule Admins'],
},
],
},
];
export function useNavLinks() {
const authStore = useAuthStore();
function hasRole(roles: string[] | undefined) {
if (roles === undefined) return true;
return authStore.hasRequiredRole(roles);
}
const enabledLinks = links
.filter((link) => link.enabled)
.map((link) => {
if (link.sublinks) {
return {
...link,
sublinks: link.sublinks.filter(
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
),
};
}
return link;
});
return { enabledLinks };
}

75
app/utils/schedule.ts Normal file
View File

@@ -0,0 +1,75 @@
import { date } from 'quasar';
import type { Boat } from '~/utils/boat.types';
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
export function arrayToTimeTuples(arr: string[]) {
const timeTuples: TimeTuple[] = [];
for (let i = 0; i < arr.length; i += 2) {
timeTuples.push([arr[i]!, arr[i + 1]!]);
}
return timeTuples;
}
export function timeTuplesOverlapped(tuples: TimeTuple[]): Interval[] {
return intervalsOverlapped(
tuples.map((tuples) => {
return {
resource: '',
start: '01/01/2001 ' + tuples[0],
end: '01/01/2001 ' + tuples[1],
};
})
).map((t) => {
return { ...t, start: t.start.split(' ')[1]!, end: t.end.split(' ')[1]! };
});
}
export function intervalsOverlapped(blocks: Interval[]): Interval[] {
return Array.from(
new Set(
blocks
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
.reduce((acc: Interval[], block, i, arr) => {
if (i > 0 && block.start < arr[i - 1]!.end)
acc.push(arr[i - 1]!, block);
return acc;
}, [])
)
);
}
export function copyTimeTuples(tuples: TimeTuple[]): TimeTuple[] {
return tuples.map((t) => Object.assign([], t));
}
export function copyIntervalTemplate(template: IntervalTemplate): IntervalTemplate {
return {
...template,
timeTuples: copyTimeTuples(template.timeTuples || []),
};
}
export function buildInterval(resource: Boat, time: TimeTuple, blockDate: string): Interval {
return {
resource: resource.$id,
start: new Date(blockDate + 'T' + time[0]).toISOString(),
end: new Date(blockDate + 'T' + time[1]).toISOString(),
};
}
export const isPast = (itemDate: Date | string): boolean => {
if (!(itemDate instanceof Date)) {
itemDate = new Date(itemDate);
}
return itemDate < new Date();
};
export function formatDate(inputDate: string | undefined): string {
if (!inputDate) return '';
return date.formatDate(new Date(inputDate), 'ddd MMM Do hh:mm A');
}
export function formatTime(inputDate: string | undefined): string {
if (!inputDate) return '';
return date.formatDate(new Date(inputDate), 'hh:mm A');
}

View File

@@ -0,0 +1,28 @@
import type { Models } from 'appwrite';
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
export type Reservation = Interval & {
user: string;
status?: StatusTypes;
reason: string;
comment: string;
members?: string[];
guests?: string[];
};
// 24 hrs in advance only 2 weekday, and 1 weekend slot
// Within 24 hrs, any available slot
export type TimeTuple = [start: string, end: string];
export type Interval = Partial<Models.Document> & {
resource: string;
start: string;
end: string;
user?: string;
};
export type IntervalTemplate = Partial<Models.Document> & {
name: string;
timeTuples: TimeTuple[];
};

1
app/utils/version.ts Normal file
View File

@@ -0,0 +1 @@
export const APP_VERSION = '0.0.0';

4
appwrite.json Normal file
View File

@@ -0,0 +1,4 @@
{
"projectId": "65ede55a213134f2b688",
"projectName": ""
}

3
backup/.env Normal file
View File

@@ -0,0 +1,3 @@
APPWRITE_ENDPOINT=https://apidev.bab.toal.ca/v1
APPWRITE_PROJECT_ID=65ede55a213134f2b688
APPWRITE_API_KEY=71f7f899ca605b39a3f24a80a23b34f580fd7e735316152bc0d5ed042bd452e7116c4d0a7f3c77d343690d6cce229020c76de1733c754a402f15bbe9b2cab5a6cd7b3a7c1c0d66cede4f6aee99cdfac14898b7a2006a5eaae24529bbcb19b4c2f6563adff5688dda9c15357c9e98b449e50b6794dfb8cc6ab61e9f073b08a11e

4
backup/appwrite.json Normal file
View File

@@ -0,0 +1,4 @@
{
"projectId": "65ede55a213134f2b688",
"projectName": ""
}

View File

@@ -0,0 +1,88 @@
# Session Handoff: Auth Refactor — Magic Link & Cleanup
**Date:** 2026-03-15
**Session Duration:** ~1 hour
**Session Focus:** Remove Google/Discord OAuth, add magic link login, add About dialog
**Context Usage at Handoff:** ~30%
## What Was Accomplished
1. Analyzed full auth flow (store, boot, login page, router guard) → no output file, inline analysis
2. Removed Google OAuth → deleted `src/components/GoogleOauthComponent.vue`
3. Removed Discord OAuth → deleted `src/components/DiscordOauthComponent.vue`
4. Removed `googleLogin`, `discordLogin` from auth store → `src/stores/auth.ts`
5. Removed `OAuthProvider` import from auth store → `src/stores/auth.ts`
6. Added `createMagicURLSession()` to auth store (calls `account.createMagicURLToken`) → `src/stores/auth.ts`
7. Added `magicURLLogin()` to auth store (calls `account.updateMagicURLSession`) → `src/stores/auth.ts`
8. Updated `LoginPage.vue` — removed OAuth component imports/usage, added "Send Magic Link" button, added `onMounted` handler to detect magic link callback params and call `magicURLLogin``src/pages/LoginPage.vue`
9. Added "About" item to left drawer → `src/components/LeftDrawer.vue` — opens a Quasar Dialog with app name, version, description
10. Converted `src/version.js``src/version.ts` to eliminate TS hint
11. Updated `generate-version.js` to write to `src/version.ts``generate-version.js`
12. Fixed stale import in `SignupPage.vue` (`src/version.js``src/version`) → `src/pages/SignupPage.vue`
13. Fixed `LeftDrawer.vue` import path `boot/appwrite``src/boot/appwrite` (was a TS module resolution error)
## Exact State of Work in Progress
- `.env.local` not being picked up by `quasar dev`: user interrupted the fix (was about to add `require('dotenv').config(...)` to `quasar.config.js`). **Status: UNRESOLVED.** User stopped this change — may prefer a different approach or wants to investigate themselves.
## Decisions Made This Session
- Use `account.updateMagicURLSession(userId, secret)` (not `createSession`) for magic link completion — BECAUSE Appwrite SDK v14 uses a separate method for magic URL vs OTP token sessions. `createSession` is for OTP only.
- Magic link callback URL = `window.location.origin + '/login'` — Appwrite appends `?userId=xxx&secret=xxx` and the `onMounted` handler in LoginPage detects and consumes these.
- Keep OTP code flow alongside magic link — user did not ask to remove it; both are available.
- About dialog placed in LeftDrawer (not a separate page) — appropriate pattern for simple info display in a mobile app.
## Key Numbers Generated or Discovered This Session
- Appwrite SDK version: `^14.0.1`
- `@quasar/app-vite` version: `^1.9.1`
- App version string source: `src/version.ts`, written by `generate-version.js` (takes version as CLI arg)
- Dev server port: `4000` (set in `quasar.config.js` `devServer.strictport`)
## Conditional Logic Established
- IF magic link callback detected (`query.userId && query.secret` in route on LoginPage mount) THEN call `magicURLLogin(userId, secret)` BECAUSE this uses `updateMagicURLSession` which is the correct Appwrite v14 API.
- IF user enters email and clicks "Send Code" THEN OTP flow runs (6-digit code emailed, entered in second input field).
- IF user enters email and clicks "Send Magic Link" THEN magic link email sent; user clicks link; page reloads at `/login?userId=xxx&secret=xxx`; `onMounted` auto-completes login.
## Files Created or Modified
| File Path | Action | Description |
|-----------|--------|-------------|
| `src/stores/auth.ts` | Modified | Removed `googleLogin`, `discordLogin`, `OAuthProvider` import; added `createMagicURLSession`, `magicURLLogin` |
| `src/pages/LoginPage.vue` | Modified | Removed OAuth components; added magic link button and `onMounted` callback handler |
| `src/components/LeftDrawer.vue` | Modified | Added "About" menu item that opens info dialog with version; fixed boot import path |
| `src/components/GoogleOauthComponent.vue` | Deleted | No longer used |
| `src/components/DiscordOauthComponent.vue` | Deleted | No longer used |
| `src/version.ts` | Renamed (was `.js`) | Eliminates TypeScript implicit-any hint |
| `src/pages/SignupPage.vue` | Modified | Updated `version.js` import to `version` |
| `generate-version.js` | Modified | Now writes to `src/version.ts` instead of `src/version.js` |
## What the NEXT Session Should Do
1. **First**: Verify magic link flow end-to-end in dev environment (send link, click it, confirm auto-login works)
2. **Then**: Resolve `.env.local` not being picked up — options are: (a) add `require('dotenv').config({ path: '.env.local' })` to top of `quasar.config.js`, or (b) use `.env` instead of `.env.local`, or (c) investigate if `@quasar/app-vite` 1.9.x has a bug
3. **Then**: Check if `SignupPage.vue` / `register()` flow is still intended — it creates email+password accounts but the login page only offers passwordless flows; this is an inconsistency
## Open Questions Requiring User Input
- [ ] Should the OTP (6-digit code) flow be kept, or replaced entirely by magic link? — impacts LoginPage UX
- [ ] Should the SignupPage (email+password registration) be removed in favour of magic link only? — impacts `src/pages/SignupPage.vue`, `src/stores/auth.ts` `register()`, router `/signup` route
- [ ] Should "Forgot Password?" link be removed from LoginPage now that magic link is the primary flow? — it was already removed from LoginPage in this session (not present in current code)
- [ ] `.env.local` fix approach — user stopped the `dotenv` approach; confirm preferred method
## Assumptions That Need Validation
- ASSUMED: `window.location.origin` is correct for magic link callback URL in all deployment environments — validate that prod URL matches what Appwrite Console has whitelisted as a redirect domain
- ASSUMED: Appwrite project has magic URL tokens enabled — validate in Appwrite Console → Auth settings
## What NOT to Re-Read
- `src/components/GoogleOauthComponent.vue` — deleted
- `src/components/DiscordOauthComponent.vue` — deleted
## Files to Load Next Session
- `src/stores/auth.ts` — primary auth logic, fully refactored this session
- `src/pages/LoginPage.vue` — magic link + OTP UI, modified this session
- `src/boot/appwrite.ts` — contains duplicate `login()` function (email/password) that may be dead code post-refactor
- `quasar.config.js` — if resolving `.env.local` issue

View File

@@ -0,0 +1,101 @@
# Session Handoff: Build Fixes & Dev Environment
**Date:** 2026-03-15
**Session Duration:** ~2 hours
**Session Focus:** Resolve all TypeScript/ESLint build errors from dependency updates; fix dev server startup
**Context Usage at Handoff:** Medium
## What Was Accomplished
1. Fixed 30 TypeScript errors (14 files) → build now passes with 0 errors
2. Fixed 12 ESLint problems (6 errors, 6 warnings) → 0 remaining
3. Fixed `quasar dev` startup error (`FlatESLint is not a constructor`) → downgraded ESLint v10→v9
4. Fixed missing Appwrite env vars in `.env.local` → app connects to backend on dev
## Exact State of Work in Progress
- **Build**: CLEAN — `yarn quasar build` exits 0, no TS or ESLint errors
- **Dev server**: FUNCTIONAL — `quasar dev` starts without errors; ESLint inline checking via `vite-plugin-checker` is restored
- **Runtime**: UNTESTED this session — app has not been manually tested against the dev Appwrite backend
## Decisions Made This Session
- **`as unknown as Type` for all Appwrite store casts** — CONFIRMED: Appwrite v23 made `DefaultDocument` strict; it no longer overlaps domain types, so the double-cast is required. Applied to: `boat.ts`, `interval.ts`, `intervalTemplate.ts`, `reservation.ts`, `task.ts`
- **ESLint downgraded v10.0.3 → v9.39.4** — CONFIRMED: `vite-plugin-checker` v0.12.0 calls `FlatESLint` which was merged back into `ESLint` in v10; v9 preserves the API. Also downgraded `@eslint/js` (v10→v9) and `eslint-plugin-vue` (v10→v9)
- **`getWeekdaySkips` removed** — CONFIRMED: removed from `@quasar/quasar-ui-qcalendar` API; `createDayList` now takes `weekdays` array directly as 4th param (previously took `weekdaySkips` computed value)
- **`subtasks` removed from TaskCardComponent template** — ASSUMED SAFE: `Task` type has no `subtasks` field; template refs were dead code. See open question.
- **`no-debugger: 'off'`** — CONFIRMED: hardcoded because `process` is not available in ESLint globals when linting `.js` files (config file context)
- **`.env.local` variable names corrected** — CONFIRMED: file had `VITE_APPWRITE_ENDPOINT` / `VITE_APPWRITE_PROJECT`; `appwrite.ts` reads `VITE_APPWRITE_API_ENDPOINT` / `VITE_APPWRITE_API_PROJECT`
## Key Numbers Generated or Discovered This Session
- TypeScript errors at session start: 30 (across 14 files)
- ESLint problems at session start: 12 (6 errors, 6 warnings)
- TypeScript errors at session end: 0
- ESLint problems at session end: 0
- ESLint: v10.0.3 → v9.39.4
- `@eslint/js`: v10 → v9
- `eslint-plugin-vue`: v10 → v9
- `register-service-worker`: newly added (was missing from package.json)
## Conditional Logic Established
- IF Appwrite SDK returns `DefaultDocument` THEN cast via `as unknown as DomainType` BECAUSE v23 `DefaultDocument` is strict and no longer assignable to domain types that extend `Partial<Models.Document>`
- IF `vite-plugin-checker` is v0.12.x THEN ESLint must be v9.x BECAUSE v0.12.x uses `FlatESLint` constructor removed in ESLint v10
- IF `createDayList` is called from qcalendar THEN pass `weekdays` array as 4th arg directly BECAUSE `getWeekdaySkips` was removed from the qcalendar public API
- IF `.env.local` is updated THEN variable names must match `import.meta.env.VITE_APPWRITE_API_ENDPOINT` / `VITE_APPWRITE_API_PROJECT` as read in `src/boot/appwrite.ts`
## Files Created or Modified
| File Path | Action | Description |
|-----------|--------|-------------|
| `src/stores/boat.ts` | Modified | `as unknown as Boat[]` |
| `src/stores/interval.ts` | Modified | `as unknown as Interval` (3 places) |
| `src/stores/intervalTemplate.ts` | Modified | Map callback cast + `as unknown as IntervalTemplate` (3 places); `timeTuple` cast |
| `src/stores/reservation.ts` | Modified | `as unknown as Reservation` (5 places) |
| `src/stores/task.ts` | Modified | `as unknown as Task[]`, `TaskTag[]`, `SkillTag[]`, `Task` (5 places) |
| `src/stores/sampledata/schedule.ts` | Modified | `id``$id`, `blocks``timeTuples`, removed `reservationDate` |
| `src/components/boat/BoatPreviewComponent.vue` | Modified | `boat.id``boat.$id` |
| `src/components/scheduling/boat/BoatScheduleTableComponent.vue` | Modified | `block.id``block.$id`; `NodeJS.Timeout``ReturnType<typeof setInterval>`; ternary→if/else |
| `src/components/scheduling/boat/CalendarHeaderComponent.vue` | Modified | Removed `getWeekdaySkips` import+computed; `createDayList` now passes `weekdays` directly |
| `src/components/task/TaskCardComponent.vue` | Modified | Removed `defineProps` explicit import; removed `subtasks` template refs |
| `src/components/task/TaskListComponent.vue` | Modified | `task.id``task.$id` |
| `src/components/task/TaskTableComponent.vue` | Modified | Removed `defineProps` from explicit import |
| `src/components/ResourceScheduleViewerComponent.vue` | Modified | Removed `|| undefined`; `catch { }`; removed stale eslint-disable comments |
| `src/pages/LoginPage.vue` | Modified | `catch { }` |
| `src/pages/schedule/ManageCalendar.vue` | Modified | `block.id``block.$id` |
| `src/boot/appwrite.ts` | Modified | Removed stale `console.log(API_ENDPOINT)` |
| `eslint.config.js` | Modified | `no-debugger` hardcoded to `'off'` |
| `quasar.config.ts` | Modified | ESLint checker restored (had been temporarily removed) |
| `package.json` / `yarn.lock` | Modified | ESLint v10→v9; `@eslint/js` v10→v9; `eslint-plugin-vue` v10→v9; added `register-service-worker` |
| `.env.local` | Modified | Variable names corrected: `VITE_APPWRITE_ENDPOINT``VITE_APPWRITE_API_ENDPOINT`, `VITE_APPWRITE_PROJECT``VITE_APPWRITE_API_PROJECT`; endpoint URL updated to include `/v1` |
| `docs/summaries/handoff-2026-03-15-build-fixes.md` | Created | This file |
| `docs/archive/handoffs/handoff-2026-03-15-dependency-updates.md` | Archived | Superseded by this handoff |
## What the NEXT Session Should Do
1. **First**: Run `quasar dev` and manually test the login flow against the dev Appwrite backend to validate v23 API calls work at runtime
2. **Validate**: Boat listing, reservation creation/cancellation, interval loading — confirm no runtime errors from the v23 positional-param deprecations
3. **Commit**: Stage all modified files and commit as `"fix: Resolve build errors from dependency updates"` (single clean commit covering all TS/ESLint/qcalendar/env fixes)
4. **Optional**: Migrate Appwrite calls from deprecated positional-param style to object-param style (affects all stores — low priority, they still work)
5. **Optional**: Add `subtasks?: Task[]` to `Task` interface in `src/stores/task.ts` if that feature is planned
## Open Questions Requiring User Input
- [ ] `task.subtasks` removed from `TaskCardComponent` template — should `subtasks?: Task[]` be added to the `Task` interface for future use, or is subtask support not planned?
- [ ] Appwrite v23 deprecated positional-param overloads (hints in every store call). Migrate now or leave for later?
## Assumptions That Need Validation
- ASSUMED: Appwrite v23 positional-param API calls behave identically at runtime to v14 — validate by doing a full login + reservation flow against the dev backend
- ASSUMED: `subtasks` in `TaskCardComponent` was dead/future code — no user confirmed this
## What NOT to Re-Read
- `docs/archive/handoffs/handoff-2026-03-15-dependency-updates.md` — archived; superseded by this file
- `docs/archive/handoffs/handoff-2026-03-15-auth-magic-link.md` — archived; auth work complete
## Files to Load Next Session
- `src/stores/task.ts` — if adding `subtasks` to Task interface
- `src/boot/appwrite.ts` — if migrating to Appwrite v23 object-param style
- Any store file (`boat.ts`, `interval.ts`, `reservation.ts`, etc.) — if migrating Appwrite calls

View File

@@ -0,0 +1,114 @@
# Session Handoff: Dependency Updates & ESLint Cleanup
**Date:** 2026-03-15
**Session Focus:** Complete Quasar v1→v2 migration, Appwrite SDK v14→v23 update, ESLint flat config cleanup
**Status:** BUILD PASSING — 0 errors, 0 warnings
## What Was Accomplished
### From prior sessions (captured in archived handoffs):
1. Auth flow refactored to passwordless (magic link + OTP), OAuth removed
2. Google/Discord OAuth components deleted
3. About dialog with version info added → `src/components/LeftDrawer.vue`
4. `quasar.config.js``quasar.config.ts` (ESM TypeScript)
5. `"type": "module"` added to `package.json`
6. Yarn 1.x → Yarn 4.13.0
7. ESLint legacy `.eslintrc.cjs` → flat config `eslint.config.js`
8. QCalendar app extension removed → direct npm package import
9. Boot/router/store wrappers updated to `#q-app/wrappers` imports
10. Appwrite SDK updated v14.0.1 → v23.0.0
11. `globals` package installed; browser + ES2021 globals added to ESLint config
### This session (build cleanup — all 30 TS errors + 12 ESLint issues resolved):
**TypeScript `as unknown as` casts** (Appwrite v23 `DefaultDocument` no longer overlaps domain types):
- `src/stores/boat.ts:36``as unknown as Boat[]`
- `src/stores/interval.ts:95,113,127``as unknown as Interval`
- `src/stores/intervalTemplate.ts` — map callback cast + `as unknown as IntervalTemplate` (3 places)
- `src/stores/reservation.ts:65,80,247``as unknown as Reservation`
- `src/stores/task.ts:53,65,77,109,132``as unknown as Task[]`, `TaskTag[]`, `SkillTag[]`, `Task`
**`.id``.$id` fixes** (Appwrite uses `$id`, not `id`):
- `src/components/boat/BoatPreviewComponent.vue:7`
- `src/components/scheduling/boat/BoatScheduleTableComponent.vue:54`
- `src/components/task/TaskListComponent.vue:4`
- `src/pages/schedule/ManageCalendar.vue:40`
- `src/stores/sampledata/schedule.ts:19,29,138` — also `id:``$id:` in object literals
**`defineProps` import conflict** (auto-imported in `<script setup>`, cannot also be explicitly imported):
- `src/components/task/TaskCardComponent.vue:20` — removed import; also removed `subtasks` template refs (not in Task type)
- `src/components/task/TaskTableComponent.vue:215` — removed `defineProps` from import
**ESLint fixes:**
- `eslint.config.js:52``process.env.NODE_ENV === 'production' ? 'error' : 'off'``'off'` (process not defined in .js ESLint globals)
- `src/components/ResourceScheduleViewerComponent.vue:173` — removed `|| undefined` (always truthy)
- `src/components/ResourceScheduleViewerComponent.vue:177``catch (e)``catch { }` (unused binding)
- `src/components/ResourceScheduleViewerComponent.vue:237-247` — removed unused `eslint-disable-next-line` comments
- `src/components/scheduling/boat/BoatScheduleTableComponent.vue:116``NodeJS.Timeout``ReturnType<typeof setInterval>`
- `src/components/scheduling/boat/BoatScheduleTableComponent.vue:129` — ternary as statement → `if/else`; also destructured `{ direction }` to fix unused `event` hint
- `src/pages/LoginPage.vue:131``catch (e)``catch { }`
**Other fixes:**
- `src-pwa/register-service-worker.ts` — installed `register-service-worker` package (was missing from package.json)
- `src/stores/sampledata/schedule.ts:50``template.blocks``template.timeTuples` (property was renamed)
- `src/stores/sampledata/schedule.ts:145` — removed `reservationDate` (not in Reservation type)
- `src/stores/intervalTemplate.ts:27``d.timeTuple` cast via `as unknown as { timeTuple: string[] }`
- `src/stores/intervalTemplate.ts:82``response.timeTuple` cast via `as unknown as { timeTuple: string[] }`
- `src/components/scheduling/boat/CalendarHeaderComponent.vue` — removed `getWeekdaySkips` (removed from qcalendar API); `createDayList` now takes `weekdays` directly as 4th arg; removed `weekdaySkips` computed
## Decisions Made
- **`as unknown as Type` pattern** — correct approach for Appwrite v23 `DefaultDocument` casts; v23 made `DefaultDocument` strict, no longer assignable to domain types without double-cast — STATUS: CONFIRMED
- **`getWeekdaySkips` removed from qcalendar** — `createDayList` now accepts `weekdays` array directly as 4th param — STATUS: CONFIRMED (verified from ESM source)
- **`subtasks` removed from TaskCardComponent template** — `Task` type has no `subtasks` field; feature was dead code — STATUS: ASSUMED safe (see open question)
- **`no-debugger: 'off'`** — hardcoded instead of `process.env.NODE_ENV` conditional because `process` is not in ESLint globals for `.js` files — STATUS: CONFIRMED
## Key Numbers
- TS errors at session start: 30 (in 14 files)
- ESLint errors at session start: 12 (6 errors, 6 warnings)
- TS errors at session end: 0
- ESLint errors at session end: 0
- Packages added: `register-service-worker`
## Files Modified This Session
| File | Change |
|------|--------|
| `eslint.config.js` | `no-debugger` rule hardcoded to `'off'` |
| `src/components/ResourceScheduleViewerComponent.vue` | 3 ESLint fixes |
| `src/components/scheduling/boat/BoatScheduleTableComponent.vue` | `.id``.$id`, NodeJS.Timeout, if/else |
| `src/components/scheduling/boat/CalendarHeaderComponent.vue` | Removed `getWeekdaySkips`; updated `createDayList` call |
| `src/pages/LoginPage.vue` | `catch { }` |
| `src/components/boat/BoatPreviewComponent.vue` | `.id``.$id` |
| `src/components/task/TaskCardComponent.vue` | Removed `defineProps` import + subtasks refs |
| `src/components/task/TaskListComponent.vue` | `.id``.$id` |
| `src/components/task/TaskTableComponent.vue` | Removed `defineProps` from import |
| `src/pages/schedule/ManageCalendar.vue` | `.id``.$id` |
| `src/stores/boat.ts` | `as unknown as Boat[]` |
| `src/stores/interval.ts` | `as unknown as Interval` (3 places) |
| `src/stores/intervalTemplate.ts` | Multiple cast fixes + `timeTuple` access |
| `src/stores/reservation.ts` | `as unknown as Reservation` (multiple) |
| `src/stores/sampledata/schedule.ts` | `id``$id`, `blocks``timeTuples`, removed `reservationDate` |
| `src/stores/task.ts` | `as unknown as` for Task/TaskTag/SkillTag |
| `package.json` / `yarn.lock` | Added `register-service-worker` |
## Open Questions
- [ ] `src/components/task/TaskCardComponent.vue``subtasks` removed from template. Should `subtasks?: Task[]` be added to the `Task` interface in `task.ts` for future use? OPEN
- [ ] Appwrite v23 deprecated positional-param overloads (hints in every store). Should stores be migrated to new object-param style? Low priority — code still works. OPEN
## Assumptions
- ASSUMED: `subtasks` feature in TaskCardComponent was dead/future code — safe to remove template refs
- ASSUMED: `no-debugger: 'off'` is fine for devel branch
## What NOT to Re-Read
- `docs/archive/handoffs/handoff-2026-03-15-auth-magic-link.md` — archived
## Next Session
- Commit all dependency update + build fix changes
- Test the app against the dev Appwrite backend (validate v23 API calls work at runtime)
- Consider migrating Appwrite calls from deprecated positional-param to object-param style (optional)
- Consider adding `subtasks?: Task[]` to `Task` interface if the feature is planned

View File

@@ -0,0 +1,186 @@
# Handoff: Nuxt 3 Migration Plan
Date: 2026-03-18
Type: Architecture / Planning
## Decision
Migrate bab-app from Quasar 2 + Vue 3 PWA to **Nuxt 3 + Capacitor + FullCalendar**.
Retain Appwrite backend unchanged. Auth simplified to magic link / email OTP only.
## Current Stack (Source of Truth)
- Quasar 2 / Vue 3 / TypeScript / Pinia / Appwrite
- Build: PWA only (`quasar build -m pwa`)
- Appwrite project: `65ede55a213134f2b688` @ `https://appwrite.toal.ca/v1`
- Database: `bab_prod`
- Collections: boat, reservation, interval, intervalTemplate, task, taskTags, skillTags
- 9 Pinia stores, 21 components, 2 layouts, ~20 pages/routes
- Auth: email token + magic link + password (vue3-google-login present but UNUSED)
## Target Stack
- **Nuxt 3** (file-based routing, Nitro, Vite)
- **nuxt-quasar-ui** module — keeps all Quasar components during migration, de-risks UI rewrite
- **@fullcalendar/vue3** — replaces @quasar/quasar-ui-qcalendar
- **@capacitor/core** + plugins — camera, geolocation (native mobile future)
- **Pinia** — unchanged
- **Appwrite SDK** — unchanged, via Nuxt plugin
- **Auth**: magic link + email OTP only; remove vue3-google-login
## Key Architectural Decisions
- Use `nuxt-quasar-ui` to avoid a big-bang UI rewrite — keeps q-* components working
- File-based routing replaces routes.ts — directory structure maps 1:1 to current routes
- Boot file → Nuxt plugin (appwrite.ts becomes plugins/appwrite.ts)
- Router guards → Nuxt middleware (auth.ts global middleware)
- **PWA only initially** — Capacitor deferred until native camera/GPS features are needed
- FullCalendar resource-timeline view for multi-boat scheduling (replaces QCalendarResource)
- **FullCalendar non-commercial license confirmed** — key: `CC-Attribution-NonCommercial-NoDerivatives` (OYS is a registered Ontario not-for-profit #000082982)
- **Static output** — `nuxt generate`, served via nginx, consistent with current Ansible deploy
- **TestFlight / App Store deferred** — revisit when PWA hits UX or capability limits
## Migration Phases (see plan in this file)
### Phase 1 — Foundation (1-2 days) ✅ COMPLETE 2026-03-19
Scaffold new Nuxt 3 project alongside existing. Do NOT delete old project until Phase 6 complete.
New project: `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/`
Tasks:
- `npx nuxi@latest init bab-app-nuxt`
- Add modules: `nuxt-quasar-ui`, `@pinia/nuxt`, `@vite-pwa/nuxt`
- Configure `nuxt.config.ts`: Quasar options, Vite aliases, env vars
- Migrate `.env.local` vars (VITE_ prefix → NUXT_PUBLIC_ for public vars)
- Capacitor: DEFERRED — PWA only until native features needed
### Phase 2 — Appwrite Plugin + Types (0.5 days)
- Copy `src/boot/appwrite.ts``plugins/appwrite.ts`
- Change export pattern to Nuxt plugin format (`defineNuxtPlugin`)
- Provide `$appwrite` via `useNuxtApp()`
- Copy all TypeScript type files verbatim
### Phase 3 — Auth (1 day)
- Migrate `stores/auth.ts``stores/auth.ts` (Pinia, near-verbatim)
- Remove all password auth methods (createEmailPasswordSession, etc.)
- Keep: `createMagicURLToken`, `updateMagicURLSession`, `createEmailToken`, `updatePhoneSession`
- Remove `vue3-google-login` from package.json
- Create `middleware/auth.global.ts` — replaces router/index.ts navigation guard
- Public routes: `/login`, `/signup`, `/pwreset`, `/terms-of-service`, `/privacy-policy`
- Magic link callback: `pages/auth/callback.vue` — handles `?userId=&secret=` params
### Phase 4 — Remaining Stores (0.5 days)
All stores are framework-agnostic Pinia — direct copy with minor import path updates:
- stores/boat.ts
- stores/reservation.ts
- stores/interval.ts
- stores/intervalTemplate.ts
- stores/task.ts
- stores/reference.ts
- stores/realtime.ts
- stores/memberProfile.ts
### Phase 5 — File-Based Routing (Page Scaffold) (0.5 days)
Create empty page files matching Nuxt convention. Route mapping:
| Old path | Nuxt file |
|---|---|
| `/` | `pages/index.vue` |
| `/boat` | `pages/boat.vue` |
| `/certification` | `pages/certification.vue` |
| `/profile` | `pages/profile.vue` |
| `/checklist` | `pages/checklist.vue` |
| `/reference` | `pages/reference/index.vue` |
| `/reference/:id/view` | `pages/reference/[id]/view.vue` |
| `/schedule` | `pages/schedule/index.vue` |
| `/schedule/book` | `pages/schedule/book.vue` |
| `/schedule/view` | `pages/schedule/view.vue` |
| `/schedule/list` | `pages/schedule/list.vue` |
| `/schedule/edit/:id` | `pages/schedule/edit/[id].vue` |
| `/schedule/manage` | `pages/schedule/manage.vue` |
| `/task` | PARKED — task feature not migrated (collections absent from bab_prod) |
| `/task/:id/edit` | PARKED |
| `/admin/user` | `pages/admin/user.vue` |
| `/admin/boat` | `pages/admin/boat.vue` |
| `/login` | `pages/login.vue` |
| `/signup` | `pages/signup.vue` |
| `/pwreset` | `pages/pwreset.vue` |
| `/terms-of-service` | `pages/terms-of-service.vue` |
| `/privacy-policy` | `pages/privacy-policy.vue` |
Admin route guard: `definePageMeta({ middleware: ['auth', 'admin'] })` in admin pages.
Schedule manage guard: `definePageMeta({ middleware: ['auth', 'schedule-admin'] })`.
### Phase 6 — Layouts + Global Components (1 day)
- `layouts/default.vue` — replaces MainLayout.vue (q-layout, q-header, LeftDrawer, BottomNav)
- `layouts/admin.vue` — replaces AdminLayout.vue
- Migrate components verbatim first; replace Quasar components later if needed:
- components/BottomNavComponent.vue
- components/LeftDrawer.vue (OPEN: was listed in agent findings but not in original glob — confirm file exists)
- components/ToolbarComponent.vue
### Phase 7 — Page Migration (3-5 days)
Migrate pages in this order (lowest to highest complexity):
1. Public pages: login, signup, pwreset, terms, privacy (simple forms)
2. Profile, Certification, Checklist, Boat (read-only/simple CRUD)
3. Reference pages
4. Admin pages (UserAdmin, BoatAdmin — q-table heavy)
5. Schedule pages (most complex — last)
6. Task pages — PARKED. Skip until bab_prod collections (task, taskTags, skillTags) are created.
Affected: stores/task.ts, components/task/*, pages/task/*, BottomNav task link, navlinks.ts task entry.
### Phase 8 — FullCalendar (2-3 days)
Replace QCalendar with FullCalendar Vue 3.
Packages:
```
@fullcalendar/vue3
@fullcalendar/core
@fullcalendar/resource-timeline # multi-boat resource view
@fullcalendar/daygrid # month/day grid
@fullcalendar/timegrid # week/day time grid
@fullcalendar/interaction # drag and drop
@fullcalendar/list # list view
```
Mapping:
- `ResourceScheduleViewerComponent.vue` → new `FullCalendarResourceView.vue`
- Resources = boats (one row per boat)
- Events = reservations + intervals
- DnD for admin interval management (replaces IntervalTemplateComponent drag behavior)
- `CalendarHeaderComponent.vue` — FullCalendar has built-in header toolbar, simplify or remove
- `BoatScheduleTableComponent.vue` — replace with FullCalendar list/grid view
FullCalendar license: open-source plugins are free; resource-timeline requires **FullCalendar Premium** license ($0 for open-source/non-commercial projects — confirm this applies, otherwise budget ~$200/yr).
OPEN: Confirm if OYS Borrow a Boat qualifies for FullCalendar open-source license.
### Phase 9 — Capacitor (1 day)
- `npx cap add ios` / `npx cap add android`
- Install plugins (not wired to features yet, just scaffold):
- `@capacitor/camera`
- `@capacitor/geolocation`
- Create `composables/useCamera.ts` and `composables/useGeolocation.ts`
- Detect Capacitor.isNativePlatform() and fall back to browser APIs on web
- Update `nuxt.config.ts` ssr: false (Capacitor requires SPA mode)
- capacitor.config.ts: `webDir: '.output/public'`
### Phase 10 — CI/CD (0.5 days)
Update `.gitea/workflows/build.yaml`:
- Replace `quasar build -m pwa` with `nuxt build`
- Output dir: `.output/public/` (static) or `.output/` (server)
- Ansible deploy: serve `.output/public` as static site (nginx), same as current
## Open Questions
- OPEN: Does OYS qualify for FullCalendar non-commercial license? (resource-timeline is premium)
- OPEN: Confirm `LeftDrawer.vue` exists in src/layouts/ or src/components/ — agent referenced it but not in initial glob
- OPEN: Is Capacitor native app publishing (App Store / Play Store) in scope, or just Capacitor for future native API access with PWA as primary delivery?
- ASSUMED: SSR is not needed — Appwrite client SDK runs browser-side; deploy as SPA/static
- ASSUMED: Nuxt deployed as static output (not Node server) — consistent with current nginx/Ansible deploy
## Effort Estimate
| Phase | Days |
|---|---|
| 1-4 (Foundation, Plugin, Auth, Stores) | 3 |
| 5-6 (Routing, Layouts) | 1.5 |
| 7 (Page migration) | 4 |
| 8 (FullCalendar) | 2.5 |
| 9 (Capacitor) | 1 |
| 10 (CI/CD) | 0.5 |
| **Total** | **~12.5 days** |

View File

@@ -0,0 +1,82 @@
# Session Handoff: Nuxt Migration — Phases 3 & 4 Complete
**Date:** 2026-03-19
**Session Focus:** Auth store, middleware, callback page, and remaining Pinia stores
**Context Usage at Handoff:** ~50%
## What Was Accomplished
1. Phase 3 — Auth complete
2. Phase 4 — All remaining stores complete (except `task.ts`, still parked)
## Exact State of Work in Progress
- **Migration plan**: 10-phase plan. Phases 14 marked complete. Phase 5 (Pages/Components) is next.
- **New project**: `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/` — stores and middleware in place. No pages migrated yet (other than `auth/callback.vue`).
- **Old project**: `/home/ptoal/Dev/mobile-projects/bab-app/` — untouched. Do not delete until Phase 6.
## Decisions Made This Session
- **Magic link redirect URL**: changed from `/login` to `/auth/callback` — dedicated handler, cleaner separation. CONFIRMED.
- **realtime.ts generic type**: changed from `RealtimeResponseEvent<Interval>` (incorrect) to `RealtimeResponseEvent<unknown>` — original typed the callback fn parameter too narrowly since multiple channels use the same store. CONFIRMED.
- **Boat interface not re-exported from boat.ts**: `boat.ts` re-exports `Boat` from `~/utils/boat.types` via `export { type Boat }` to preserve import compatibility with components that do `import { Boat } from '~/stores/boat'`. CONFIRMED.
## Key Numbers
- **7 stores created**: `auth`, `boat`, `reservation`, `interval`, `intervalTemplate`, `reference`, `memberProfile`
- **2 middleware/pages created**: `middleware/auth.global.ts`, `pages/auth/callback.vue`
- **1 plugin updated**: `plugins/appwrite.client.ts`
- **Task stores**: still 0 — `task`, `taskTags`, `skillTags` collections not in `bab_prod`
## Files Created or Modified
| File Path | Action | Description |
|-----------|--------|-------------|
| `bab-app-nuxt/app/stores/auth.ts` | Created | Magic link + OTP only; `register()`/`login()` removed; boat/reservation init wired |
| `bab-app-nuxt/app/stores/boat.ts` | Created | Re-exports `Boat` from `~/utils/boat.types`; near-verbatim port |
| `bab-app-nuxt/app/stores/reservation.ts` | Created | Near-verbatim port; imports updated |
| `bab-app-nuxt/app/stores/interval.ts` | Created | Near-verbatim port; `Boat` imported from `~/utils/boat.types` |
| `bab-app-nuxt/app/stores/intervalTemplate.ts` | Created | Near-verbatim port |
| `bab-app-nuxt/app/stores/reference.ts` | Created | Verbatim port (static data, no Appwrite) |
| `bab-app-nuxt/app/stores/realtime.ts` | Created | Generic type corrected to `unknown` |
| `bab-app-nuxt/app/stores/memberProfile.ts` | Created | Verbatim port (static data, no Appwrite) |
| `bab-app-nuxt/app/middleware/auth.global.ts` | Created | Global Nuxt route guard; public via `to.meta.public`; roles via `to.meta.requiredRoles` |
| `bab-app-nuxt/app/pages/auth/callback.vue` | Created | Handles `?userId=&secret=` magic link params; redirects to `/` |
| `bab-app-nuxt/app/plugins/appwrite.client.ts` | Modified | `authStore.init()` wired in |
## What the NEXT Session Should Do
1. **Execute Phase 5 — Pages & Components**
- Identify pages in old `src/pages/` — migrate one at a time
- Key pages: `LoginPage.vue`, `IndexPage.vue`, `BoatPage.vue`, `ProfilePage.vue`, `CertificationPage.vue`, `ChecklistPage.vue`
- Schedule pages: `SchedulePageView.vue`, `ScheduleIndexPage.vue`, `BoatReservationPage.vue`, `BoatScheduleView.vue`, `ListReservationsPage.vue`, `ModifyBoatReservation.vue`, `ManageCalendar.vue`
- Admin pages: `UserAdminPage.vue`, `BoatAdminPage.vue`
- Static pages: `TermsOfServicePage.vue`, `PrivacyPolicyPage.vue`, `ErrorNotFound.vue`
- Add `definePageMeta({ public: true })` to: `login.vue`, `signup.vue`, `pwreset.vue`, `terms-of-service.vue`, `privacy-policy.vue`, `auth/callback.vue` (already done)
- Add `definePageMeta({ requiredRoles: ['Schedule Admins'] })` to `schedule/manage.vue`
- Add `definePageMeta({ requiredRoles: ['admin'] })` to all `admin/*.vue` pages
2. **Execute Phase 6 — Layout**
- Migrate `MainLayout.vue` and `AdminLayout.vue`
- Confirm `LeftDrawer.vue` exists in `src/components/` (ASSUMED — not yet confirmed)
## Open Questions
- [ ] **OPEN**: `task`/`taskTags`/`skillTags` collections — will they ever be created in `bab_prod`?
- [ ] **OPEN**: What pages are in `src/pages/` — exact list not yet read (read on demand per CLAUDE.md rule 6)
## Assumptions
- ASSUMED: `LeftDrawer.vue` exists in `src/components/`. Verify before Phase 6.
- ASSUMED: `nuxt generate` (static) is sufficient. Still unvalidated.
- ASSUMED: `LoginPage.vue` in old app handles both magic link and OTP login UI — new app needs `pages/login.vue` built from scratch with `definePageMeta({ public: true })`.
## What NOT to Re-Read
- All stores in `bab-app-nuxt/app/stores/` — just created, content known
- `src/stores/auth.ts`, `src/stores/boat.ts`, etc. — fully migrated; do not re-read
## Files to Load Next Session
- `docs/summaries/handoff-2026-03-19-nuxt-migration-phases-3-4.md` (this file)
- `src/pages/` — read one page at a time per processing protocol
- `src/layouts/MainLayout.vue` — needed for Phase 6 planning
- `bab-app-nuxt/app/stores/auth.ts` — if login page needs store shape reference

View File

@@ -0,0 +1,16 @@
# Archive Rules
## Raw File Archival
After creating a Source Document Summary for any raw file:
1. Move the raw file to `docs/archive/`
2. Record the move in the source summary's header: `Archived From: [original path]`
3. Do not read from `docs/archive/` unless the user explicitly says "go back to the original [filename]"
## Summary Lifecycle Rules
1. **Session handoffs expire**: After a new handoff is written, the previous handoff moves to `docs/archive/handoffs/`. Only the latest handoff stays in `docs/summaries/`.
2. **Decision records persist**: Decision records (DR-*) stay in `docs/summaries/` permanently — they are institutional memory.
3. **Source summaries persist**: Source document summaries stay until the project ends — they replace raw documents.
4. **Analysis summaries**: Keep only the latest version. If re-run, the new one replaces the old (archive the old one).
5. **Maximum active summaries**: If `docs/summaries/` exceeds 15 files, consolidate older source summaries into a single `project-digest.md` and archive the originals.

Some files were not shown because too many files have changed in this diff Show More