145 Commits

Author SHA1 Message Date
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
39a6ab5fcc New workflow steps to use version
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m9s
2023-12-02 23:19:02 -05:00
81 changed files with 9804 additions and 2644 deletions

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,9 +1,9 @@
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:
push:
branches:
- main
- devel
jobs:
build:
@@ -15,16 +15,37 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: '20.x'
- name: Install dependencies
run: npm install
- name: Install yarn
run: npm install --global yarn
- name: Install yarn dependencies
run: yarn install
- name: Install Quasar CLI
run: npm install -g @quasar/cli
run: yarn global add @quasar/cli
- name: Temporary - Invoke custom qcalendar build
run: quasar ext invoke @quasar/qcalendar
- name: Create env file
run: |
echo "${{ vars.ENV_FILE }}" > .env.local
- name: Show env file
run: |
/bin/cat .env.local
- name: Build Project
run: quasar build -m pwa
# - name: Archive Production Artifact
# uses: actions/upload-artifact@v2
# with:
# name: build-artifact
# path: dist/pwa
- name: Get Version Number
id: get_version
run: echo "::set-output name=VERSION::$(node -p "require('./package.json').version")"
- name: Tarfile
run: |
cd dist/pwa
tar czf ../../build-${{ steps.get_version.outputs.VERSION }}.tar.gz .
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: build-artifact-${{ steps.get_version.outputs.VERSION }}.${{ gitea.run_number }}
path: build-${{ steps.get_version.outputs.VERSION }}.tar.gz
- name: Trigger Ansible Deploy Playbook
uses: https://github.com/distributhor/workflow-webhook@v3
with:
webhook_url: ${{ vars.WEBHOOK_URL }}
verbose: true
data: '{ "artifact_url": "${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id}}/artifacts/build-artifact-${{ steps.get_version.outputs.VERSION }}.${{ gitea.run_number }}" }'

2
.gitignore vendored
View File

@@ -34,4 +34,4 @@ yarn-error.log*
*.sln
# local .env files
.env.local*
.env*

View File

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

View File

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

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,8 @@
# Personas
- BAB Member
- Certified Skipper
- Program Administrator
- Boatswain
- Volunteer
- Instructor

20
docs/time.md Normal file
View File

@@ -0,0 +1,20 @@
# Dealing with Time
Dealing with time sucks, okay? We have three different formats we need to deal with:
1. ES Date - The native ECMAScript Date object. This is saddled with all the legacy of the decades. Hopefully, we will be able to retire this one day... Ref: https://tc39.es/proposal-temporal/docs/index.html
2. ISO 8601 Date - Used by Appwrite backend. This is just a string, but can represent any date, with or without a timezone.
3. Timestamp - Used internally by QCalendar.
We can't just use one format. We need ISO8601 format for Appwrite, and we get passed Timestamp objects by QCalendar. In the middle of that, we need ES Date objects to do some underlying math.
Componentization:
In order to make things clean and modular, we will rely on Timestamp as the main format in a component.
In data that comes from, or goes to the backend, we will store absolute dates in ISO format.
For any user-facing dates / times, the data will be rendered in the users local time.
For time-only data (as used in Intervals, eg: '09:00'), the template will be stored as a string of 'hh:mm', and represent the users local time. We may want to change this in the future, as this could prove a problem when a user is travelling, but wants to apply a template to their home location.
For now, we'll use the Timestamp object provided by QCalendar. We might need to refactor this in the future.

View File

@@ -0,0 +1,40 @@
# Users, Roles and Permissions
This is the design document for https://gitea.toal.ca/oys/bab-app/issues/11
## Backend Concepts
Utilizing the AppWrite backend provides us with some basic concepts we can use:
### Users, Groups, and Labels
#### Teams
Teams are AppWrite groups of users. Teams can be assigned roles, which can be assigned permissions. Teams "contain" users. A team has more permissions to manage it's members than labels, which are assigned / removed, rather than 'invited / left'.
#### Labels
Labels are AppWrite tags for users. Users have Labels as attributes. Like teams, labels can be used for Role / Permission mapping.
### Permissions
https://appwrite.io/docs/advanced/platform/permissions
Permissions are fine-grained access control for users and objects. They follow standard "CRUD" patterns.
## BAB Concepts
For teams, there will, to start, be the following:
- `staff` : Individuals with authority / responsibilities
- `maintenance` : Staff responsible for maintenance (eg: Boatswain)
- `admin`: Administrators of the program / application
- `school` : Members of the Sailing School (Instructors & Students)
- `student` role : A student in the school
- `instructor` role: An instructor in the school
- `bab` : Members of the BAB program
- `skipper` role: A member who has passed skipper certification
The following are the initial labels:
- TBD

36
nohup.out Normal file
View File

@@ -0,0 +1,36 @@
2024-06-06 07:42:15,841 - vorta.i18n - DEBUG - Loading translation failed for ['en-CA', 'en-Latn-CA'].
QObject::connect: No such signal QPlatformNativeInterface::systemTrayWindowChanged(QScreen*)
2024-06-06 07:42:15,884 - root - DEBUG - Not a private SSH key file: authorized_keys
2024-06-06 07:42:15,885 - root - DEBUG - Not a private SSH key file: github_rsa.pub_bak-github
2024-06-06 07:42:15,886 - root - DEBUG - Not a private SSH key file: other_keys.seahorse
2024-06-06 07:42:16,077 - root - INFO - Using NetworkManagerMonitor NetworkStatusMonitor implementation.
Requested decoration "adwaita" not found, falling back to default
qt.qpa.wayland: Wayland does not support QWindow::requestActivate()
2024-06-06 07:42:16,209 - vorta.borg.jobs_manager - DEBUG - Add job for site default
2024-06-06 07:42:16,210 - vorta.borg.jobs_manager - DEBUG - Start job on site: default
2024-06-06 07:42:16,237 - vorta.borg.borg_job - INFO - Running command /usr/bin/borg --version
2024-06-06 07:42:20,564 - vorta.borg.jobs_manager - DEBUG - Finish job for site: default
2024-06-06 07:42:20,565 - vorta.borg.jobs_manager - DEBUG - No more jobs for site: default
2024-06-06 07:42:20,566 - vorta.scheduler - DEBUG - Refreshing all scheduler timers
2024-06-06 07:42:20,568 - vorta.scheduler - DEBUG - Nothing scheduled for profile 1 because of unset repo.
qt.qpa.wayland: Wayland does not support QWindow::requestActivate()
2024-06-06 07:42:23,190 - root - DEBUG - Not a private SSH key file: authorized_keys
2024-06-06 07:42:23,191 - root - DEBUG - Not a private SSH key file: github_rsa.pub_bak-github
2024-06-06 07:42:23,191 - root - DEBUG - Not a private SSH key file: other_keys.seahorse
2024-06-06 07:42:23,204 - vorta.keyring.abc - DEBUG - Only available on macOS
2024-06-06 07:42:23,244 - asyncio - DEBUG - Using selector: EpollSelector
2024-06-06 07:42:23,245 - vorta.keyring.abc - DEBUG - Using VortaSecretStorageKeyring
2024-06-06 07:49:53,786 - vorta.keyring.abc - DEBUG - Only available on macOS
2024-06-06 07:49:53,788 - asyncio - DEBUG - Using selector: EpollSelector
2024-06-06 07:49:53,788 - vorta.keyring.abc - DEBUG - Using VortaSecretStorageKeyring
2024-06-06 07:49:53,789 - asyncio - DEBUG - Using selector: EpollSelector
2024-06-06 07:49:53,790 - vorta.keyring.secretstorage - DEBUG - Found 0 passwords matching repo URL.
qt.qpa.wayland: Wayland does not support QWindow::requestActivate()
2024-06-06 07:50:10,009 - vorta.keyring.abc - DEBUG - Only available on macOS
2024-06-06 07:50:10,011 - asyncio - DEBUG - Using selector: EpollSelector
2024-06-06 07:50:10,012 - vorta.keyring.abc - DEBUG - Using VortaSecretStorageKeyring
2024-06-06 07:50:10,012 - vorta.borg.borg_job - DEBUG - Using VortaSecretStorageKeyring keyring to store passwords.
2024-06-06 07:50:10,013 - asyncio - DEBUG - Using selector: EpollSelector
2024-06-06 07:50:10,013 - vorta.keyring.secretstorage - DEBUG - Found 0 passwords matching repo URL.
2024-06-06 07:50:10,013 - vorta.borg.borg_job - DEBUG - Password not found in primary keyring. Falling back to VortaDBKeyring.
2024-06-06 07:50:10,029 - vorta.borg.borg_job - INFO - Running command /usr/bin/borg info --info --json --log-json ssh://borg@borg.toal.ca:12022/./ptoal-linux

View File

@@ -1,6 +1,6 @@
{
"name": "oys_bab",
"version": "0.0.2",
"version": "0.6.2",
"description": "Manage a Borrow a Boat program for a Yacht Club",
"productName": "OYS Borrow a Boat",
"author": "Patrick Toal <ptoal@takeflight.ca>",
@@ -13,35 +13,48 @@
"build": "quasar build"
},
"dependencies": {
"@quasar/extras": "^1.16.4",
"appwrite": "^13.0.0",
"@quasar/extras": "^1.16.11",
"@quasar/quasar-app-extension-qcalendar": "https://github.com/ptoal/quasar-ui-qcalendar/releases/download/v4.0.0-beta.19/app-extension.tgz",
"@quasar/quasar-ui-qcalendar": "https://github.com/ptoal/quasar-ui-qcalendar/releases/download/v4.0.0-beta.19/qcalendar-ui.tgz",
"appwrite": "^14.0.1",
"axios": "^1.6.8",
"file": "^0.2.2",
"pinia": "^2.1.7",
"quasar": "^2.6.0",
"vue": "^3.0.0",
"vue-router": "^4.0.0"
"vue": "3",
"vue-router": "4",
"vue3-google-login": "^2.0.26"
},
"devDependencies": {
"@quasar/app-vite": "^1.3.0",
"@quasar/quasar-app-extension-qcalendar": "^4.0.0-beta.15",
"@quasar/app-vite": "^1.9.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/github": "^10.0.6",
"@semantic-release/npm": "^12.0.1",
"@types/node": "^12.20.21",
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.0",
"autoprefixer": "^10.4.2",
"dotenv": "^16.3.1",
"eslint": "^8.10.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-vue": "^9.0.0",
"git-commit-info": "^2.0.2",
"prettier": "^2.5.1",
"typescript": "^4.5.4",
"quasar": "^2.16.0",
"semantic-release": "^24.0.0",
"typescript": "~5.3.0",
"vite-plugin-checker": "^0.6.4",
"vue-tsc": "^1.8.22",
"workbox-build": "^7.0.0",
"workbox-cacheable-response": "^7.0.0",
"workbox-core": "^7.0.0",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0"
"workbox-strategies": "^7.0.0",
"yarn": "^1.22.21"
},
"engines": {
"node": "^18 || ^16 || ^14.19",
"node": "^20 || ^18 || ^16 || ^14.19",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
}

BIN
public/tmpimg/JMI.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
public/tmpimg/projectX.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -9,8 +9,10 @@
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
const { configure } = require('quasar/wrappers');
const packageJson = require('./package.json');
const gitCommitInfo = require('git-commit-info');
module.exports = configure(function (/* ctx */) {
module.exports = configure(function ({ dev }) {
return {
eslint: {
// fix: true,
@@ -48,12 +50,16 @@ module.exports = configure(function (/* ctx */) {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: {
env: {
VUE_APP_VERSION: dev
? 'dev-' + gitCommitInfo().shortHash
: packageJson.version,
},
target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node16',
},
vueRouterMode: 'hash', // available values: 'hash', 'history'
vueRouterMode: 'history', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,
@@ -72,9 +78,20 @@ module.exports = configure(function (/* ctx */) {
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
// vitePlugins: [
// [ 'package-name', { ..options.. } ]
// ]
vitePlugins: [
[
'vite-plugin-checker',
{
vueTsc: {
tsconfigPath: 'tsconfig.vue-tsc.json',
},
eslint: {
lintCommand: 'eslint "./**/*.{js,ts,mjs,cjs,vue}"',
},
},
{ server: false },
],
],
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
@@ -83,6 +100,28 @@ module.exports = configure(function (/* ctx */) {
// open: true, // opens browser window automatically
port: 4000,
strictport: true,
// This works around CORS problems when developing locally, using the Appwrite backend
proxy: {
'/api': {
target: 'https://apidev.bab.toal.ca/',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/api/v1/realtime': {
target: 'wss://apidev.bab.toal.ca',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
secure: false,
ws: true,
},
// '/function': {
// target: 'https://6640382951eacb568371.f.appwrite.toal.ca/',
// changeOrigin: true,
// secure: false,
// rewrite: (path) => path.replace(/^\/function/, ''),
// },
},
// For reverse-proxying via haproxy
// hmr: {
// clientPort: 443,
@@ -93,7 +132,9 @@ module.exports = configure(function (/* ctx */) {
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: {
config: {},
config: {
autoImportComponentCase: 'kebab', // or 'kebab' (default) or 'combined'
},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack

View File

@@ -1,5 +1,5 @@
{
"orientation": "portrait",
"orientation": "natural",
"background_color": "#ffffff",
"theme_color": "#027be3",
"icons": [

View File

@@ -9,33 +9,35 @@ register(process.env.SERVICE_WORKER_FILE, {
// to ServiceWorkerContainer.register()
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter
// registrationOptions: { scope: './' },
registrationOptions: { scope: './' },
ready (/* registration */) {
// console.log('Service worker is active.')
ready(/* registration */) {
console.log('Service worker is active.');
},
registered (/* registration */) {
// console.log('Service worker has been registered.')
registered(/* registration */) {
console.log('Service worker has been registered.');
},
cached (/* registration */) {
// console.log('Content has been cached for offline use.')
cached(/* registration */) {
console.log('Content has been cached for offline use.');
},
updatefound (/* registration */) {
// console.log('New content is downloading.')
updatefound(/* registration */) {
console.log('New content is downloading.');
},
updated (/* registration */) {
// console.log('New content is available; please refresh.')
updated(/* registration */) {
console.log('New content is available; please refresh.');
},
offline () {
// console.log('No internet connection found. App is running in offline mode.')
offline() {
console.log(
'No internet connection found. App is running in offline mode.'
);
},
error (/* err */) {
// console.error('Error during service worker registration:', err)
error(err) {
console.error('Error during service worker registration:', err);
},
});

View File

@@ -2,10 +2,15 @@
<router-view />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import { defineComponent, onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
export default defineComponent({
defineComponent({
name: 'OYS Borrow-a-Boat',
});
onMounted(async () => {
await useAuthStore().init();
});
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -1,26 +1,91 @@
import { boot } from 'quasar/wrappers';
import { Client, Account, Databases, ID } from 'appwrite';
import {
Client,
Account,
Databases,
Functions,
ID,
AppwriteException,
Teams,
} from 'appwrite';
import { useAuthStore } from 'src/stores/auth';
import { Dialog, Notify } from 'quasar';
import type { Router } from 'vue-router';
const client = new Client();
// appwrite.io SaaS
// client
// .setEndpoint('https://api.bab.toal.ca/v1')
// .setProject('653ef6f76baf06d68034');
// const appDatabaseId = '654ac5044d1c446feb71';
const API_ENDPOINT = import.meta.env.VITE_APPWRITE_API_ENDPOINT;
const API_PROJECT = import.meta.env.VITE_APPWRITE_API_PROJECT;
// Private self-hosted appwrite
client
.setEndpoint('https://apidev.bab.toal.ca/v1')
.setProject('655a7116479b4d5a815f');
//TODO
const appDatabaseId = '';
if (API_ENDPOINT && API_PROJECT) {
client.setEndpoint(API_ENDPOINT).setProject(API_PROJECT);
} else {
console.error(
'Must configure VITE_APPWRITE_API_ENDPOINT and VITE_APPWRITE_API_PROJECT'
);
}
type AppwriteIDConfig = {
databaseId: string;
collection: {
boat: string;
reservation: string;
skillTags: string;
task: string;
taskTags: string;
interval: string;
intervalTemplate: string;
};
function: {
userinfo: string;
};
};
let AppwriteIds = <AppwriteIDConfig>{};
console.log(API_ENDPOINT);
if (
API_ENDPOINT === 'https://apidev.bab.toal.ca/v1' ||
API_ENDPOINT === 'http://localhost:4000/api/v1'
) {
AppwriteIds = {
databaseId: '65ee1cbf9c2493faf15f',
collection: {
boat: 'boat',
reservation: 'reservation',
skillTags: 'skillTags',
task: 'task',
taskTags: 'taskTags',
interval: 'interval',
intervalTemplate: 'intervalTemplate',
},
function: {
userinfo: 'userinfo',
},
};
} else if (API_ENDPOINT === 'https://appwrite.oys.undock.ca/v1') {
AppwriteIds = {
databaseId: 'bab_prod',
collection: {
boat: 'boat',
reservation: 'reservation',
skillTags: 'skillTags',
task: 'task',
taskTags: 'taskTags',
interval: 'interval',
intervalTemplate: 'intervalTemplate',
},
function: {
userinfo: '664038294b5473ef0c8d',
},
};
}
const account = new Account(client);
const databases = new Databases(client);
const functions = new Functions(client);
const teams = new Teams(client);
let appRouter: Router;
export default boot(async ({ router }) => {
@@ -50,7 +115,7 @@ async function logout() {
});
}
function login(email: string, password: string) {
async function login(email: string, password: string) {
const notification = Notify.create({
type: 'primary',
position: 'top',
@@ -60,30 +125,57 @@ function login(email: string, password: string) {
group: false,
});
const authStore = useAuthStore();
authStore
.login(email, password)
.then(() => {
notification({
type: 'positive',
message: 'Logged in!',
timeout: 2000,
spinner: false,
icon: 'check_circle',
});
console.log('Redirecting to index page');
appRouter.replace({ name: 'index' });
})
.catch(function (reason: Error) {
notification({
type: 'negative',
message: 'Login failed.',
timeout: 1,
});
try {
await authStore.login(email, password);
notification({
type: 'positive',
message: 'Logged in!',
timeout: 2000,
spinner: false,
icon: 'check_circle',
});
appRouter.replace({ name: 'index' });
} catch (error: unknown) {
console.log(error);
if (error instanceof AppwriteException) {
if (error.type === 'user_session_already_exists') {
appRouter.replace({ name: 'index' });
notification({
type: 'positive',
message: 'Already Logged in!',
timeout: 2000,
spinner: false,
icon: 'check_circle',
});
return;
}
Dialog.create({
title: 'Login Error!',
message: reason.message,
message: error.message,
persistent: true,
});
}
notification({
type: 'negative',
message: 'Login failed.',
timeout: 2000,
});
}
}
export { client, account, databases, ID, appDatabaseId, login, logout };
async function resetPassword(email: string) {
await account.createRecovery(email, window.location.origin + '/pwreset');
}
export {
client,
account,
teams,
databases,
functions,
ID,
AppwriteIds,
login,
logout,
resetPassword,
};

View File

@@ -0,0 +1,285 @@
<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>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useAuthStore } from 'src/stores/auth';
import { Boat, useBoatStore } from 'src/stores/boat';
import { Interval, Reservation } from 'src/stores/schedule.types';
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
import { formatDate } from 'src/utils/schedule';
import { useReservationStore } from 'src/stores/reservation';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
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 {
const updatedReservation = {
...newReservation,
user: auth.currentUser?.$id,
interval: {
start: newReservation.start,
end: newReservation.end,
resource: newReservation.resource,
},
};
bookingForm.value = updatedReservation;
}
});
const updateInterval = (interval: Interval | null) => {
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: hours, minutes: 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
)
) {
// TODO: Make a proper validator
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>

View File

@@ -7,7 +7,7 @@
icon="calendar_month"
to="/schedule"
></q-route-tab>
<q-route-tab
<!-- <q-route-tab
name="Checklists"
icon="checklist"
to="/checklist"
@@ -19,7 +19,7 @@
></q-route-tab>
<q-route-tab name="Tasks" icon="build" to="/task">
<q-badge color="red" floating> NEW </q-badge>
</q-route-tab>
</q-route-tab> -->
</q-tabs>
</template>

View File

@@ -0,0 +1,19 @@
<template>
<q-btn
@click="auth.discordLogin()"
style="width: 300px">
<q-avatar
left
size="sm"
class="q-ma-xs">
<img
src="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/636e0a6a49cf127bf92de1e2_icon_clyde_blurple_RGB.png" />
</q-avatar>
Login with Discord
</q-btn>
</template>
<script setup lang="ts">
import { useAuthStore } from 'src/stores/auth';
const auth = useAuthStore();
</script>

View File

@@ -1,64 +0,0 @@
<template>
<div>
<p>{{ title }}</p>
<ul>
<li v-for="todo in todos" :key="todo.id" @click="increment">
{{ todo.id }} - {{ todo.content }}
</li>
</ul>
<p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
<p>Active: {{ active ? 'yes' : 'no' }}</p>
<p>Clicks on todos: {{ clickCount }}</p>
</div>
</template>
<script lang="ts">
import {
defineComponent,
PropType,
computed,
ref,
toRef,
Ref,
} from 'vue';
import { Todo, Meta } from './models';
function useClickCount() {
const clickCount = ref(0);
function increment() {
clickCount.value += 1
return clickCount.value;
}
return { clickCount, increment };
}
function useDisplayTodo(todos: Ref<Todo[]>) {
const todoCount = computed(() => todos.value.length);
return { todoCount };
}
export default defineComponent({
name: 'ExampleComponent',
props: {
title: {
type: String,
required: true
},
todos: {
type: Array as PropType<Todo[]>,
default: () => []
},
meta: {
type: Object as PropType<Meta>,
required: true
},
active: {
type: Boolean
}
},
setup (props) {
return { ...useClickCount(), ...useDisplayTodo(toRef(props, 'todos')) };
},
});
</script>

View File

@@ -1,5 +1,16 @@
<template>
<div @click="auth.googleLogin()">Login with Google</div>
<q-btn
@click="auth.googleLogin()"
style="width: 300px">
<q-avatar
left
class="q-ma-xs"
size="sm">
<img
src="https://lh3.googleusercontent.com/COxitqgJr1sJnIDe8-jiKhxDx1FrYbtRHKJ9z_hELisAlapwE9LUPh6fcXIfb5vwpbMl4xl9H9TRFPc5NOO8Sb3VSgIBrfRYvW6cUA" />
</q-avatar>
<div>Login with Google</div>
</q-btn>
</template>
<script setup lang="ts">

View File

@@ -4,22 +4,57 @@
show-if-above
:width="200"
:breakpoint="1024"
@update:model-value="$emit('drawer-toggle')"
>
@update:model-value="$emit('drawer-toggle')">
<q-scroll-area class="fit">
<q-list padding class="menu-list">
<template v-for="link in links" :key="link.name">
<q-item clickable v-ripple :to="link.to">
<q-list
padding
class="menu-list">
<template
v-for="link in enabledLinks"
:key="link.name">
<!-- TODO: Template this to be DRY -->
<q-item
clickable
v-ripple
:to="link.to">
<q-item-section avatar>
<q-icon :name="link.icon" />
</q-item-section>
<q-item-section> {{ link.name }} </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="logout()">
<q-item-section avatar><q-icon name="logout" /></q-item-section
><q-item-section>Logout</q-item-section>
<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>
@@ -28,7 +63,7 @@
<script lang="ts" setup>
import { defineComponent } from 'vue';
import { links } from 'src/router/navlinks.js';
import { enabledLinks } from 'src/router/navlinks.js';
import { logout } from 'boot/appwrite';
defineProps(['drawer']);

View File

@@ -0,0 +1,62 @@
<template>
<q-card-section class="q-ma-sm">
<q-input
v-model="password"
label="New Password"
type="password"
color="darkblue"
:rules="[validatePasswordStrength]"
lazy-rules
filled></q-input>
<q-input
v-model="confirmPassword"
label="Confirm New Password"
type="password"
color="darkblue"
:rules="[validatePasswordStrength]"
lazy-rules
filled></q-input>
<div class="text-caption q-py-md">Enter a new password.</div>
</q-card-section>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
const password = ref('');
const confirmPassword = ref('');
const newPassword = defineModel();
const validatePasswordStrength = (val: string) => {
const hasUpperCase = /[A-Z]/.test(val);
const hasLowerCase = /[a-z]/.test(val);
const hasNumbers = /[0-9]/.test(val);
const hasNonAlphas = /[\W_]/.test(val);
const isValidLength = val.length >= 8;
return (
(hasUpperCase &&
hasLowerCase &&
hasNumbers &&
hasNonAlphas &&
isValidLength) ||
'Password must be at least 8 characters long and include uppercase, lowercase, number, and special character.'
);
};
const validatePasswordsMatch = (val: string) => {
return val === password.value || 'Passwords do not match.';
};
watch([password, confirmPassword], ([newpw, newpw1]) => {
if (
validatePasswordStrength(newpw) === true &&
validatePasswordsMatch(newpw1) === true
) {
newPassword.value = newpw;
} else {
newPassword.value = '';
}
});
</script>

View File

@@ -1,3 +1,4 @@
<!-- This has been abandoned for now. Going to block-based booking. Will probably need the schedule viewer functionality at some point in the future, though -->
<template>
<q-card-section>
<div class="text-caption text-justify">
@@ -11,21 +12,20 @@
max-width: 350px;
display: flex;
justify-content: space-between;
"
>
">
<span
class="q-button"
style="cursor: pointer; user-select: none"
@click="onPrev"
>&lt;</span
>
@click="onPrev">
&lt;
</span>
{{ formattedMonth }}
<span
class="q-button"
style="cursor: pointer; user-select: none"
@click="onNext"
>&gt;</span
>
@click="onNext">
&gt;
</span>
</div>
</div>
<div
@@ -34,8 +34,7 @@
justify-content: center;
align-items: center;
flex-wrap: nowrap;
"
>
">
<div style="display: flex; width: 100%">
<q-calendar-month
ref="calendar"
@@ -47,19 +46,21 @@
date-type="rounded"
@change="onChange"
@moved="onMoved"
@click-date="onClickDate"
/>
</div></div
></q-card-section>
@click-date="onClickDate" />
</div>
</div>
</q-card-section>
<q-calendar-resource
v-model="selectedDate"
:model-resources="boatStore.boats"
resource-key="id"
resource-label="name"
:interval-start="12"
:interval-count="36"
:interval-minutes="30"
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
@@ -70,18 +71,25 @@
@click-time="onClickTime"
@click-resource="onClickResource"
@click-head-resources="onClickHeadResources"
@click-interval="onClickInterval"
>
@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
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">
{{ resource.name }}
<q-icon v-if="resource.defects" name="warning" color="warning" />
<div class="col-12 .col-md-auto">
{{ resource.displayName }}
<q-icon
v-if="resource.defects"
name="warning"
color="warning" />
</div>
</template>
</q-calendar-resource>
@@ -94,35 +102,48 @@
dense
@update:model-value="onUpdateDuration"
label="Duration (hours)"
stack-label
><template v-slot:append><q-icon name="timelapse" /></template></q-select
></q-card-section>
stack-label>
<template v-slot:append><q-icon name="timelapse" /></template>
</q-select>
</q-card-section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import {
QCalendarResource,
TimestampOrNull,
today,
parseDate,
parseTimestamp,
addToDate,
Timestamp,
parsed,
} from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat';
import { useScheduleStore } from 'src/stores/schedule';
import { useReservationStore } from 'src/stores/reservation';
import { date } from 'quasar';
import { computed } from 'vue';
import type { StatusTypes } from 'src/stores/schedule.types';
import { useIntervalStore } from 'src/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];
type ResourceIntervalScope = {
interface ResourceIntervalScope {
resource: Boat;
intervals: [];
timeStartPosX(start: TimestampOrNull): number;
timeDurationWidth(duration: number): number;
};
}
const statusLookup = {
confirmed: ['#14539a', 'white'],
@@ -132,8 +153,8 @@ const statusLookup = {
const calendar = ref();
const boatStore = useBoatStore();
const scheduleStore = useScheduleStore();
const selectedDate = ref(today());
const reservationStore = useReservationStore();
const { selectedDate } = storeToRefs(useIntervalStore());
const duration = ref(1);
const formattedMonth = computed(() => {
@@ -158,14 +179,14 @@ function monthFormatter() {
}
function getEvents(scope: ResourceIntervalScope) {
const resourceEvents = scheduleStore.getBoatReservations(
scope.resource.id,
date.extractDate(selectedDate.value, 'YYYY-MM-DD')
const resourceEvents = reservationStore.getReservationsByDate(
selectedDate.value,
scope.resource.$id
);
return resourceEvents.map((event) => {
return resourceEvents.value.map((event) => {
return {
left: scope.timeStartPosX(parseDate(event.start)),
left: scope.timeStartPosX(parsed(event.start)),
width: scope.timeDurationWidth(
date.getDateDiff(event.end, event.start, 'minutes')
),
@@ -179,7 +200,7 @@ function getStyle(event: {
left: number;
width: number;
title: string;
status: 'tentative' | 'confirmed' | 'pending';
status: StatusTypes;
}) {
return {
position: 'absolute',
@@ -200,14 +221,16 @@ function onPrev() {
function onNext() {
calendar.value.next();
}
function onClickDate(data) {
return;
function onClickDate(data: EventData) {
return data;
}
function onClickTime(data) {
function onClickTime(data: EventData) {
// TODO: Add a duration picker, here.
emit('onClickTime', data);
}
function onUpdateDuration(value) {
function onUpdateDuration(value: EventData) {
emit('onUpdateDuration', value);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function

View File

@@ -7,22 +7,24 @@
round
icon="menu"
aria-label="Menu"
@click="toggleLeftDrawer"
/>
@click="toggleLeftDrawer" />
<q-toolbar-title> {{ pageTitle }} </q-toolbar-title>
<q-tabs shrink>
<q-tab> </q-tab>
</q-tabs>
<q-toolbar-title>{{ pageTitle }}</q-toolbar-title>
<q-space />
<div>v{{ VERSION }}</div>
</q-toolbar>
</q-header>
<LeftDrawer :drawer="leftDrawerOpen" @drawer-toggle="toggleLeftDrawer" />
<LeftDrawer
:drawer="leftDrawerOpen"
@drawer-toggle="toggleLeftDrawer" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import LeftDrawer from 'components/LeftDrawer.vue';
const VERSION = process.env.VUE_APP_VERSION;
const leftDrawerOpen = ref(false);
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;

View File

@@ -8,7 +8,7 @@
>
<template v-slot:prepend>
<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-item-section>
</template>

View File

@@ -1,23 +1,31 @@
<template>
<q-card v-for="boat in boats" :key="boat.id" flat class="mobile-card">
<q-card-section>
<q-img :src="boat.imgsrc" :fit="'scale-down'">
<div class="row absolute-top">
<div class="col text-h5 text-left">{{ boat.name }}</div>
<div class="col text-right">{{ boat.class }}</div>
</div>
</q-img>
</q-card-section>
<div v-if="boats">
<q-card
v-for="boat in boats"
:key="boat.id"
class="q-ma-sm">
<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-separator />
<q-card-actions align="evenly">
<q-btn flat>Info</q-btn>
<q-btn flat>Book</q-btn>
<q-btn flat>Check-Out</q-btn>
<q-btn flat>Check-In</q-btn>
</q-card-actions>
</q-card>
<!-- <q-card-actions align="evenly">
<q-btn flat>Info</q-btn>
<q-btn flat>Book</q-btn>
<q-btn flat>Check-Out</q-btn>
<q-btn flat>Check-In</q-btn>
</q-card-actions> -->
</q-card>
</div>
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
</template>
<script setup lang="ts">

View File

@@ -0,0 +1,173 @@
<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>
<script setup lang="ts">
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
import { IntervalTemplate } from 'src/stores/schedule.types';
import { copyIntervalTemplate, timeTuplesOverlapped } from 'src/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,
template: IntervalTemplate | undefined
) => {
if (template?.$id) intervalTemplateStore.deleteIntervalTemplate(template.$id);
};
function onDragStart(e: DragEvent, template: IntervalTemplate) {
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('ID', template.$id || '');
}
}
const saveTemplate = (evt: Event, template: IntervalTemplate | undefined) => {
if (!template) return false;
overlapped.value = timeTuplesOverlapped(template.timeTuples);
if (overlapped.value.length > 0) {
alert.value = true;
} else {
edit.value = false;
if (template.$id && template.$id !== 'unsaved') {
intervalTemplateStore.updateIntervalTemplate(template, template.$id);
} else {
intervalTemplateStore.createIntervalTemplate(template);
emit('saved');
}
}
};
</script>

View File

@@ -0,0 +1,19 @@
<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,116 @@
<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 class="col-auto">
<q-btn
color="grey-7"
round
flat
icon="more_vert">
<q-menu
cover
auto-close>
<q-list>
<q-item clickable>
<q-item-section>remove card</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-menu>
</q-btn>
</div> -->
</div>
</q-card-section>
<!-- <q-card-section>Some more information here...</q-card-section> -->
<q-separator />
<q-card-actions v-if="!isPast(reservation.end)">
<q-btn
flat
size="lg"
:to="{ name: 'edit-reservation', params: { id: 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>
<script setup lang="ts">
import { useBoatStore } from 'src/stores/boat';
import { useReservationStore } from 'src/stores/reservation';
import type { Reservation } from 'src/stores/schedule.types';
import { formatDate, isPast } from 'src/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>

View File

@@ -0,0 +1,271 @@
<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>
<script setup lang="ts">
import {
QCalendarDay,
Timestamp,
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 'src/stores/boat';
import { useAuthStore } from 'src/stores/auth';
import { Interval, Reservation } from 'src/stores/schedule.types';
import { storeToRefs } from 'pinia';
import { useReservationStore } from 'src/stores/reservation';
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
import { useIntervalStore } from 'src/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<QCalendarDay | null>(null);
const now = ref(new Date());
let intervalId: string | number | NodeJS.Timeout | undefined;
onMounted(async () => {
await useBoatStore().fetchBoats();
await intervalTemplateStore.fetchIntervalTemplates();
intervalId = setInterval(function () {
now.value = new Date();
}, 60000);
});
onUnmounted(() => clearInterval(intervalId));
function handleSwipe({ ...event }) {
event.direction === 'right' ? calendar.value?.prev() : 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 && boats.value[scope.columnIndex]
? 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>
<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,259 @@
<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>
<script setup lang="ts">
import {
Timestamp,
addToDate,
createDayList,
createNativeLocaleFormatter,
getEndOfWeek,
getStartOfWeek,
getWeekdaySkips,
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]),
locale = ref('en-CA'),
monthFormatter = monthFormatterFunc(),
dayFormatter = dayFormatterFunc(),
weekdayFormatter = weekdayFormatterFunc();
const weekdaySkips = computed(() => {
return getWeekdaySkips(weekdays);
});
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 today2 = computed(() => {
return parseTimestamp(today());
});
const days = computed(() => {
if (parsedStart.value && parsedEnd.value) {
return createDayList(
parsedStart.value,
parsedEnd.value,
today2.value as Timestamp,
weekdaySkips.value
);
}
return [];
});
const dayStyle = computed(() => {
const width = 100 / weekdays.length + '%';
return {
width,
};
});
function onPrev() {
const ts = addToDate(parsedStart.value, { day: -7 });
selectedDate.value = ts.date;
}
function onNext() {
const ts = addToDate(parsedStart.value, { day: 7 });
selectedDate.value = ts.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>
<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>

View File

@@ -0,0 +1,24 @@
<template>
<q-card>
<q-item-section>
<q-item-label overline>{{ task.title }}</q-item-label>
<q-item-label caption lines="2">{{ task.description }} </q-item-label>
<q-item-label caption>Due: {{ task.due_date }}</q-item-label>
</q-item-section>
<q-expansion-item
v-if="task.subtasks && task.subtasks.length"
expand-separator
label="Subtasks"
default-opened
>
<TaskListComponent :tasks="task.subtasks" />
</q-expansion-item>
</q-card>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
import type { Task } from 'src/stores/task';
defineProps<{ task: Task }>();
</script>

View File

@@ -0,0 +1,266 @@
<template>
<q-form @submit="onSubmit" class="q-gutter-md">
<q-input
filled
v-model="modifiedTask.title"
label="Task Title"
hint="A short description of the task"
lazy-rules
:rules="[
(val: string | any[]) =>
(val && val.length > 0) || 'Please enter a title for the task',
]"
/>
<q-editor
filled
v-model="modifiedTask.description"
label="Detailed Description"
hint="A detailed description of the task"
lazy-rules
placeholder="Enter a detailed description..."
/>
<q-input
filled
v-model="modifiedTask.due_date"
mask="date"
:rules="[dateRule]"
hint="Enter the due date"
lazy-rules
>
<template v-slot:append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date
v-model="modifiedTask.due_date"
@input="updateDateISO"
today-btn
>
<div class="row items-center justify-end">
<q-btn v-close-popup label="Close" color="primary" flat />
</div>
</q-date>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
<div>
<q-select
label="Skills Required"
hint="Add a list of required skills, to help people find things in their ability"
v-model="modifiedTask.required_skills"
use-input
use-chips
multiple
clearable
emit-value
map-options
input-debounce="250"
:options="skillTagOptions"
option-label="name"
option-value="$id"
@filter="filterSkillTags"
>
</q-select>
</div>
<div>
<q-select
label="Tags"
hint="Add Tags to help with searching"
v-model="modifiedTask.tags"
use-input
use-chips
multiple
clearable
emit-value
map-options
input-debounce="250"
:options="taskTagOptions"
option-label="name"
option-value="$id"
@filter="filterTaskTags"
>
</q-select>
</div>
<q-input
label="Estimated Duration"
v-model.number="modifiedTask.duration"
type="number"
filled
suffix="hrs"
style="max-width: 200px"
/>
<q-input
label="Number of Required Volunteers"
v-model.number="modifiedTask.volunteers_required"
type="number"
filled
style="max-width: 200px"
/>
<q-select
label="Status of Task"
v-model="modifiedTask.status"
:options="TASKSTATUS"
>
</q-select>
<div>
<q-select
label="Dependencies"
hint="Add a list of tasks that need to be complete before this one"
v-model="modifiedTask.depends_on"
use-input
multiple
clearable
emit-value
map-options
input-debounce="250"
:options="tasks"
option-label="title"
option-value="$id"
@filter="filterTasks"
>
</q-select>
</div>
<div>
<q-select
label="Boat"
hint="Add a boat, if applicable"
v-model="modifiedTask.boat"
use-input
clearable
emit-value
map-options
input-debounce="250"
:options="boatList"
option-label="name"
option-value="$id"
>
</q-select>
</div>
<div>
<q-btn
label="Submit"
type="submit"
color="primary"
flat
class="q-ml-sm"
/>
<q-btn
label="Cancel"
color="secondary"
flat
class="q-ml-sm"
@click="$router.go(-1)"
/>
</div>
</q-form>
</template>
<script setup lang="ts">
import { computed, reactive, ref, Ref } from 'vue';
import { useRouter } from 'vue-router';
import { useTaskStore, TASKSTATUS } from 'src/stores/task';
import type { TaskTag, SkillTag, Task } from 'src/stores/task';
import { date } from 'quasar';
import { useBoatStore } from 'src/stores/boat';
const props = defineProps<{ taskId?: string }>();
const taskStore = useTaskStore();
const defaultTask = <Task>{
description: '',
due_date: date.formatDate(Date.now(), 'YYYY-MM-DD'),
required_skills: [],
title: '',
tags: [],
duration: 0,
volunteers: [],
volunteers_required: 0,
status: 'ready',
depends_on: [],
};
taskStore.fetchTasks();
const { taskId } = props;
const targetTask = taskId && taskStore.tasks.find((t) => t.$id === taskId);
const modifiedTask = reactive(targetTask ? targetTask : defaultTask);
let tasks = taskStore.tasks;
const boatList = useBoatStore().boats;
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
const filterSkillTags = computed(
() =>
(val: string, update: (cb: () => void) => void): void => {
return filterTags(skillTagOptions, taskStore.skillTags, val, update);
}
);
const filterTaskTags = computed(
() =>
(val: string, update: (cb: () => void) => void): void => {
return filterTags(taskTagOptions, taskStore.taskTags, val, update);
}
);
const filterTasks = computed(
() =>
(val: string, update: (cb: () => void) => void): void => {
if (val === '') {
update(() => {
tasks = taskStore.tasks;
});
return;
}
update(() => {
tasks = taskStore.filterTasksByTitle(val);
});
}
);
function filterTags(
optionVar: Ref<(SkillTag | TaskTag)[] | undefined>,
optionSrc: SkillTag[] | TaskTag[],
val: string,
update: (cb: () => void) => void
): void {
if (val === '') {
update(() => {
optionVar.value = optionSrc;
});
return;
}
update(() => {
optionVar.value = optionSrc.filter((v) =>
v.name.toLowerCase().includes(val.toLowerCase())
);
});
}
// Method to update the model in ISO 8601 format
const updateDateISO = (value: string) => {
modifiedTask.due_date = date.formatDate(value, 'YYYY-MM-DD');
};
const dateRule = (val: string) => {
// Check if the date is valid using Quasar's date utils if needed
// For simplicity, we are directly checking the date string validity
return (val && !isNaN(Date.parse(val))) || 'Please enter a valid date';
};
const router = useRouter();
async function onSubmit() {
try {
if (modifiedTask.$id) {
await taskStore.updateTask(modifiedTask);
} else {
await taskStore.addTask(modifiedTask);
}
router.go(-1);
} catch (error) {
console.error('Failed to create new Task: ', error);
}
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="q-pa-md" style="max-width: 350px">
<q-list>
<div v-for="task in tasks" :key="task.id">
<TaskCardComponent :task="task" />
</div>
</q-list>
</div>
</template>
<script setup lang="ts">
import type { Task } from 'src/stores/task';
import TaskCardComponent from './TaskCardComponent.vue';
defineProps<{ tasks: Task[] }>();
</script>

View File

@@ -0,0 +1,367 @@
<template>
<div class="q-pa-sm">
<q-table
:rows="tasks"
:columns="columns"
:grid="$q.screen.xs"
dense
row-key="$id"
flatten
no-data-label="I didn't find anything for you"
no-results-label="The filter didn't uncover any results"
selection="multiple"
v-model:selected="selected"
:filter="searchFilter"
:filter-method="filterRows"
>
<template v-slot:top>
<q-select
style="width: 250px"
multiple
use-chips
clearable
label="Skills Filter"
input-debounce="250"
:options="skillTagOptions"
v-model="searchFilter.skillTags"
option-label="name"
option-value="$id"
>
</q-select>
<q-select
style="width: 250px"
multiple
use-chips
clearable
label="Tag Filter"
input-debounce="250"
:options="taskTagOptions"
v-model="searchFilter.taskTags"
option-label="name"
option-value="$id"
>
<template v-slot: prepend>
<q-icon name="wrench"></q-icon>
</template>
</q-select>
<q-space />
<q-input
flatten
debounce="300"
color="primary"
clearable
v-model="searchFilter.title"
>
<template v-slot:append>
<q-icon name="search" />
</template>
</q-input>
</template>
<template v-slot:header="props">
<q-tr :props="props">
<q-th key="desc" auto-width>
<q-checkbox dense v-model="props.selected"></q-checkbox>
</q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body-cell-skills="props">
<q-td :props="props" class="q-gutter-sm">
<q-badge
v-for="skill in props.value"
:key="skill"
:color="skill.tagColour"
text-color="white"
:label="skill.name"
/>
</q-td>
</template>
<template v-slot:body-cell-tags="props">
<q-td :props="props" class="q-gutter-sm">
<q-badge
v-for="tag in props.value"
:key="tag"
:color="tag.colour"
text-color="white"
:label="tag.name"
/>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="q-gutter-sm">
<q-btn
label="Sign Up"
size="sm"
color="primary"
:to="{ name: 'signup-task', params: { id: props.value } }"
/>
<q-btn
label="Edit"
size="sm"
color="primary"
:to="{ name: 'edit-task', params: { id: props.value } }"
/>
</q-td>
</template>
<template v-slot:item="props">
<div
class="q-pa-xs col-xs-12 col-sm-6 col-md-4 col-lg-3 grid-style-transition"
:style="props.selected ? 'transform: scale(0.95);' : ''"
>
<q-card
bordered
flat
:class="
props.selected
? $q.dark.isActive
? 'bg-grey-9'
: 'bg-grey-2'
: ''
"
>
<q-card-section>
<q-checkbox
dense
v-model="props.selected"
:label="props.row.name"
/>
</q-card-section>
<q-separator />
<q-list dense>
<q-item
v-for="col in props.cols.filter((col:Boat) => col.name !== 'desc')"
:key="col.name"
>
<q-item-section>
<q-item-label>{{ col.label }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption v-if="col.name === 'skills'">
<q-chip
size="sm"
v-for="skill in col.value"
outline
color="primary"
:key="skill.$id"
>{{ skill.name }}</q-chip
></q-item-label
>
<q-item-label caption v-else-if="col.name === 'tags'">
<q-chip
size="sm"
v-for="tag in col.value"
outline
color="primary"
:key="tag.$id"
>{{ tag.name }}</q-chip
></q-item-label
>
<q-item-label caption v-else-if="col.name === 'actions'">
<q-btn
label="Sign Up"
size="sm"
color="primary"
:to="{ name: 'signup-task', params: { id: col.value } }"
/>
<q-btn
label="Edit"
size="sm"
color="primary"
:to="{ name: 'edit-task', params: { id: col.value } }"
/>
</q-item-label>
<q-item-label v-else caption>{{ col.value }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
</div>
</template>
</q-table>
<q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-fab
v-model="fabShow"
vertical-actions-align="right"
color="primary"
glossy
icon="keyboard_arrow_up"
direction="up"
>
<q-fab-action
color="primary"
:disable="loading"
label="New Task"
to="/task/edit"
icon="add"
/>
<q-fab-action
v-if="tasks.length !== 0"
class="q-ml-sm"
color="primary"
:disable="loading"
label="Delete task(s)"
@click="deleteTasks"
icon="delete"
/> </q-fab
></q-page-sticky>
</div>
</template>
<script setup lang="ts">
import { computed, defineProps, ref } from 'vue';
import { useTaskStore, Task, SkillTag, TaskTag } from 'src/stores/task';
import { QTableProps, date, useQuasar } from 'quasar';
import { Boat, useBoatStore } from 'src/stores/boat';
const selected = ref([]);
const loading = ref(false); // Placeholder
const fabShow = ref(false);
const columns = <QTableProps['columns']>[
{
name: 'title',
required: true,
label: 'Title',
align: 'left',
field: 'title',
sortable: true,
},
{
name: 'due_date',
align: 'left',
label: 'Due Date',
field: 'due_date',
format: (val) => date.formatDate(val, 'MMM DD, YYYY'),
sortable: true,
},
{
name: 'status',
align: 'left',
label: 'Status',
field: 'status',
sortable: true,
},
{
name: 'skills',
align: 'left',
label: 'Skills',
field: (row) =>
row.required_skills.map((s: string) => taskStore.getSkillById(s)),
sortable: false,
},
{
name: 'tags',
align: 'left',
label: 'Tags',
field: (row) => row.tags.map((s: string) => taskStore.getTaskTagById(s)),
sortable: false,
},
{
name: 'boat',
align: 'left',
label: 'Boat',
field: (row) =>
useBoatStore().boats.find((boat) => boat.$id === row.boat)?.name,
sortable: true,
},
{
name: 'volunteers',
align: 'left',
label: "People Req'd",
field: 'volunteers_required',
sortable: false,
},
{
name: 'signedup',
align: 'left',
label: 'Signed Up',
field: (row) => row.volunteers.length,
sortable: false,
},
{
name: 'depends',
align: 'left',
label: 'Dependent Tasks',
field: 'depends_on',
format: (val) => {
return (
val
.map((t: string) => taskStore.getTaskById(t))
.filter((t: Task) => t)
.map((t: Task) => t.title)
.join(', ') || null
);
},
},
{ name: 'actions', align: 'center', label: 'Actions', field: '$id' },
];
defineProps<{ tasks: Task[] }>();
const taskStore = useTaskStore();
const $q = useQuasar();
interface SearchObject {
title: string;
skillTags: SkillTag[];
taskTags: TaskTag[];
}
const searchFilter = ref<SearchObject>({
title: '',
skillTags: [],
taskTags: [],
});
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
// function onRowClick(evt: Event, row: Task) {
// router.push({ name: 'edit-task', params: { id: row.$id } });
// }
// TODO: Implement server side search
const filterRows = computed(
() => (rows: readonly Task[], terms: SearchObject) => {
return rows
.filter((row) =>
terms.title
? row.title.toLowerCase().includes(terms.title.toLowerCase())
: true
)
.filter((row) =>
terms.skillTags && terms.skillTags.length > 0
? row.required_skills.some((req_skill) =>
terms.skillTags.map((t) => t.$id).includes(req_skill)
)
: true
)
.filter((row) =>
terms.taskTags && terms.taskTags.length > 0
? row.tags.some((tag) =>
terms.taskTags.map((t) => t.$id).includes(tag)
)
: true
);
}
);
function deleteTasks() {
confirmDelete(selected.value);
}
function confirmDelete(tasks: Task[]) {
$q.dialog({
title: 'Confirm',
message:
'You are about to delete ' + tasks.length + ' tasks. Are you sure?',
cancel: true,
persistent: true,
}).onOk(() => {
selected.value.map((task: Task) => {
taskStore.deleteTask(task);
return;
});
});
}
</script>

View File

@@ -7,9 +7,13 @@
<script lang="ts" setup>
import BoatPreviewComponent from 'src/components/boat/BoatPreviewComponent.vue';
import { ref } from 'vue';
import { onMounted } from 'vue';
import { useBoatStore } from 'src/stores/boat';
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
import { storeToRefs } from 'pinia';
const boats = ref(useBoatStore().boats);
const boatStore = useBoatStore();
const { boats } = storeToRefs(boatStore);
onMounted(() => boatStore.fetchBoats());
</script>

View File

@@ -4,7 +4,7 @@
<q-img alt="OYS Logo" src="~assets/oysqn_logo.png" fit="scale-down" />
<q-list class="full-width mobile-only">
<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"
>
<q-btn
@@ -23,6 +23,6 @@
</template>
<script lang="ts" setup>
import { links } from 'src/router/navlinks.js';
import { enabledLinks } from 'src/router/navlinks.js';
import ToolbarComponent from 'components/ToolbarComponent.vue';
</script>

View File

@@ -3,48 +3,57 @@
<q-page-container>
<q-page class="flex bg-image flex-center">
<q-card
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }"
>
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
<q-card-section>
<q-img fit="scale-down" src="~assets/oysqn_logo.png" />
<q-img
fit="scale-down"
src="~assets/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-card-section>
<q-form class="q-gutter-md">
<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>
filled></q-input>
<q-input
v-model="password"
label="Password"
type="password"
v-if="userId"
v-model="token"
label="6-digit code"
type="number"
color="darkblue"
filled
></q-input>
filled></q-input>
</q-card-section>
</q-form>
<q-card-section class="q-pa-none">
<div class="row justify-center q-ma-sm">
<q-btn
type="submit"
@click="login(email, password)"
label="Login"
color="primary"
></q-btn>
<!-- <q-btn
type="button"
@click="register"
color="secondary"
label="Register"
@click="doTokenLogin"
color="primary"
label="Login with E-mail"
style="width: 300px" />
</div>
<div class="row justify-center q-ma-sm">
<GoogleOauthComponent />
</div>
<div class="row justify-center q-ma-sm">
<DiscordOauthComponent />
</div>
<div class="row justify-center">
<q-btn
flat
></q-btn> -->
</q-form>
color="secondary"
to="/pwreset"
label="Forgot Password?" />
</div>
</q-card-section>
<q-card-section><GoogleOauthComponent /></q-card-section>
</q-card>
</q-page>
</q-page-container>
@@ -68,9 +77,76 @@
<script setup lang="ts">
import { ref } from 'vue';
import { login } from 'boot/appwrite';
import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
import DiscordOauthComponent from 'src/components/DiscordOauthComponent.vue';
import { Dialog, Notify } from 'quasar';
import { useAuthStore } from 'src/stores/auth';
import { useRouter } from 'vue-router';
import { AppwriteException } from 'appwrite';
const email = ref('');
const password = ref('');
const token = ref('');
const userId = ref();
const router = useRouter();
console.log('version:' + process.env.VUE_APP_VERSION);
const doTokenLogin = async () => {
const authStore = useAuthStore();
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 (e) {
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',
});
router.replace({ name: 'index' });
} catch (error: unknown) {
if (error instanceof AppwriteException) {
if (error.type === 'user_session_already_exists') {
useRouter().replace({ name: 'index' });
notification({
type: 'positive',
message: 'Already Logged in!',
timeout: 2000,
spinner: false,
icon: 'check_circle',
});
return;
}
Dialog.create({
title: 'Login Error!',
message: error.message,
persistent: true,
});
}
notification({
type: 'negative',
message: 'Login failed.',
timeout: 2000,
});
}
}
};
</script>

View File

@@ -1,163 +1,173 @@
<template>
<q-page padding>
<h1>Privacy Policy for bab.toal.ca</h1>
<q-layout>
<q-page-container>
<q-page padding>
<h1>Privacy Policy for Undock.ca</h1>
<p>
At OYS BAB Test, accessible from https://bab.toal.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 OYS BAB
Test and how we use it.
</p>
<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>
<p>
If you have additional questions or require more information about our
Privacy Policy, do not hesitate to contact us. Our Privacy Policy was
generated with the help of
<a href="https://www.gdprprivacypolicy.net/"
>GDPR Privacy Policy Generator</a
>
</p>
<p>
If you have additional questions or require more information about our
Privacy Policy, do not hesitate to contact us. Our Privacy Policy was
generated with the help of
<a href="https://www.gdprprivacypolicy.net/">
GDPR Privacy Policy Generator
</a>
</p>
<h2>General Data Protection Regulation (GDPR)</h2>
<p>We are a Data Controller of your information.</p>
<h2>General Data Protection Regulation (GDPR)</h2>
<p>We are a Data Controller of your information.</p>
<p>
bab.toal.ca legal basis for collecting and using the personal information
described in this Privacy Policy depends on the Personal Information we
collect and the specific context in which we collect the information:
</p>
<ul>
<li>bab.toal.ca needs to perform a contract with you</li>
<li>You have given bab.toal.ca permission to do so</li>
<li>
Processing your personal information is in bab.toal.ca legitimate
interests
</li>
<li>bab.toal.ca needs to comply with the law</li>
</ul>
<p>
Undock's legal basis for collecting and using the personal information
described in this Privacy Policy depends on the Personal Information
we collect and the specific context in which we collect the
information:
</p>
<ul>
<li>Undock needs to perform a contract with you</li>
<li>You have given Undock permission to do so</li>
<li>
Processing your personal information is in Undock legitimate
interests
</li>
<li>Undock needs to comply with the law</li>
</ul>
<p>
bab.toal.ca will retain your personal information only for as long as is
necessary for the purposes set out in this Privacy Policy. We will retain
and use your information to the extent necessary to comply with our legal
obligations, resolve disputes, and enforce our policies.
</p>
<p>
Undock will retain your personal information only for as long as is
necessary for the purposes set out in this Privacy Policy. We will
retain and use your information to the extent necessary to comply with
our legal obligations, resolve disputes, and enforce our policies.
</p>
<p>
If you are a resident of the European Economic Area (EEA), you have
certain data protection rights. If you wish to be informed what Personal
Information we hold about you and if you want it to be removed from our
systems, please contact us.
</p>
<p>
If you are a resident of the European Economic Area (EEA), you have
certain data protection rights. If you wish to be informed what
Personal Information we hold about you and if you want it to be
removed from our systems, please contact us.
</p>
<p>
In certain circumstances, you have the following data protection rights:
</p>
<ul>
<li>
The right to access, update or to delete the information we have on you.
</li>
<li>The right of rectification.</li>
<li>The right to object.</li>
<li>The right of restriction.</li>
<li>The right to data portability</li>
<li>The right to withdraw consent</li>
</ul>
<p>
In certain circumstances, you have the following data protection
rights:
</p>
<ul>
<li>
The right to access, update or to delete the information we have on
you.
</li>
<li>The right of rectification.</li>
<li>The right to object.</li>
<li>The right of restriction.</li>
<li>The right to data portability</li>
<li>The right to withdraw consent</li>
</ul>
<h2>Log Files</h2>
<h2>Log Files</h2>
<p>
OYS BAB Test follows a standard procedure of using log files. These files
log visitors when they visit websites. All hosting companies do this and a
part of hosting services' analytics. 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. These are not linked to any information
that is personally identifiable. The purpose of the information is for
analyzing trends, administering the site, tracking users' movement on the
website, and gathering demographic information.
</p>
<p>
Undock follows a standard procedure of using log files. These files
log visitors when they visit websites. All hosting companies do this
and a part of hosting services' analytics. 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. These are not linked to any
information that is personally identifiable. The purpose of the
information is for analyzing trends, administering the site, tracking
users' movement on the website, and gathering demographic information.
</p>
<h2>Cookies and Web Beacons</h2>
<h2>Cookies and Web Beacons</h2>
<p>
Like any other website, OYS BAB Test uses "cookies". These cookies are
used to store information including visitors' preferences, and the pages
on the website that the visitor accessed or visited. The information is
used to optimize the users' experience by customizing our web page content
based on visitors' browser type and/or other information.
</p>
<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. The information is
used to optimize the users' experience by customizing our web page
content based on visitors' browser type and/or other information.
</p>
<h2>Privacy Policies</h2>
<h2>Privacy Policies</h2>
<P
>You may consult this list to find the Privacy Policy for each of the
advertising partners of OYS BAB Test.</P
>
<P>
You may consult this list to find the Privacy Policy for each of the
advertising partners of Undock.
</P>
<p>
Third-party ad servers or ad networks uses technologies like cookies,
JavaScript, or Web Beacons that are used in their respective
advertisements and links that appear on OYS BAB Test, which are sent
directly to users' browser. They automatically receive your IP address
when this occurs. These technologies are used to measure the effectiveness
of their advertising campaigns and/or to personalize the advertising
content that you see on websites that you visit.
</p>
<p>
Third-party ad servers or ad networks uses technologies like cookies,
JavaScript, or Web Beacons that are used in their respective
advertisements and links that appear on Undock, which are sent
directly to users' browser. They automatically receive your IP address
when this occurs. These technologies are used to measure the
effectiveness of their advertising campaigns and/or to personalize the
advertising content that you see on websites that you visit.
</p>
<p>
Note that OYS BAB Test has no access to or control over these cookies that
are used by third-party advertisers.
</p>
<p>
Note that Undock has no access to or control over these cookies that
are used by third-party advertisers.
</p>
<h2>Third Party Privacy Policies</h2>
<h2>Third Party Privacy Policies</h2>
<p>
OYS BAB Test's Privacy Policy does not apply to other advertisers or
websites. Thus, we are advising you to consult the respective Privacy
Policies of these third-party ad servers for more detailed information. It
may include their practices and instructions about how to opt-out of
certain options.
</p>
<p>
Undock's Privacy Policy does not apply to other advertisers or
websites. Thus, we are advising you to consult the respective Privacy
Policies of these third-party ad servers for more detailed
information. It may include their practices and instructions about how
to opt-out of certain options.
</p>
<p>
You can choose to disable cookies through your individual browser options.
To know more detailed information about cookie management with specific
web browsers, it can be found at the browsers' respective websites.
</p>
<p>
You can choose to disable cookies through your individual browser
options. To know more detailed information about cookie management
with specific web browsers, it can be found at the browsers'
respective websites.
</p>
<h2>Children's Information</h2>
<h2>Children's Information</h2>
<p>
Another part of our priority is adding protection for children while using
the internet. We encourage parents and guardians to observe, participate
in, and/or monitor and guide their online activity.
</p>
<p>
Another part of our priority is adding protection for children while
using the internet. We encourage parents and guardians to observe,
participate in, and/or monitor and guide their online activity.
</p>
<p>
OYS BAB Test does not knowingly collect any Personal Identifiable
Information from children under the age of 13. If you think that your
child provided this kind of information on our website, we strongly
encourage you to contact us immediately and we will do our best efforts to
promptly remove such information from our records.
</p>
<p>
Undock does not knowingly collect any Personal Identifiable
Information from children under the age of 13. If you think that your
child provided this kind of information on our website, we strongly
encourage you to contact us immediately and we will do our best
efforts to promptly remove such information from our records.
</p>
<h2>Online Privacy Policy Only</h2>
<h2>Online Privacy Policy Only</h2>
<p>
Our Privacy Policy applies only to our online activities and is valid for
visitors to our website with regards to the information that they shared
and/or collect in OYS BAB Test. This policy is not applicable to any
information collected offline or via channels other than this website.
</p>
<p>
Our Privacy Policy applies only to our online activities and is valid
for visitors to our website with regards to the information that they
shared and/or collect in Undock. This policy is not applicable to any
information collected offline or via channels other than this website.
</p>
<h2>Consent</h2>
<h2>Consent</h2>
<p>
By using our website, you hereby consent to our Privacy Policy and agree
to its terms.
</p>
</q-page>
<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>
<script setup lang="ts"></script>

View File

@@ -1,39 +1,74 @@
<template>
<toolbar-component pageTitle="Member Profile" />
<q-page padding>
<q-list bordered>
<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>
Ricky Gervais
<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="numbers" />
<q-avatar icon="email" />
</q-item-section>
<q-item-section>
12345
<q-item-label caption>Member ID</q-item-label>
<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>
<q-chip square icon="verified" color="primary" text-color="white"
>J/27</q-chip
>
<q-chip square icon="verified" color="green" text-color="white"
>Capri25</q-chip
>
<q-chip square icon="verified" color="grey-8" text-color="white"
>Night</q-chip
>
<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>
@@ -42,4 +77,22 @@
<script setup lang="ts">
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
import { useAuthStore } from 'src/stores/auth';
import { ref } from 'vue';
const authStore = useAuthStore();
const newName = ref();
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>

139
src/pages/ResetPassword.vue Normal file
View File

@@ -0,0 +1,139 @@
<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="~assets/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
<div class="col text-h6">Reset Password</div>
</div>
</q-card-section>
<q-form v-if="!isPasswordResetLink()">
<q-card-section class="q-ma-sm">
<q-input
v-model="email"
label="E-Mail"
type="email"
color="darkblue"
@keydown.enter.prevent="resetPw"
filled></q-input>
<div class="text-caption q-py-md">
Enter your e-mail address. If we have an account with that
address on file, you will be e-mailed a link to reset your
password.
</div>
<q-card-actions>
<q-btn
type="button"
@click="resetPw"
label="Send Reset Link"
color="primary"></q-btn>
</q-card-actions>
</q-card-section>
</q-form>
<div v-else-if="validResetLink()">
<q-form
@submit="submitNewPw"
@keydown.enter.prevent="resetPw">
<NewPasswordComponent v-model="newPassword" />
<q-card-actions>
<q-btn
type="submit"
label="Reset Password"
color="primary"></q-btn>
</q-card-actions>
</q-form>
</div>
<q-card
v-else
class="text-center">
<span class="text-h5">Invalid reset link.</span>
</q-card>
</q-card>
</q-page>
</q-page-container>
</q-layout>
</template>
<style>
.bg-image {
background-image: url('/src/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
/* background-image: linear-gradient(
135deg,
#ed232a 0%,
#ffffff 75%,
#14539a 100%
); */
}
</style>
<script setup lang="ts">
import { ref } from 'vue';
import { account, resetPassword } from 'boot/appwrite';
import { useRouter } from 'vue-router';
import { Dialog } from 'quasar';
import NewPasswordComponent from 'components/NewPasswordComponent.vue';
const email = ref('');
const router = useRouter();
const newPassword = ref();
function validResetLink(): boolean {
const query = router.currentRoute.value.query;
const expire = query.expire ? new Date(query.expire + 'Z') : null;
return Boolean(
query && expire && query.secret && query.userId && new Date() < expire
);
}
function isPasswordResetLink() {
const query = router.currentRoute.value.query;
return query && query.secret && query.userId && query.expire;
}
function submitNewPw() {
const query = router.currentRoute.value.query;
if (newPassword.value) {
account
.updateRecovery(
query.userId as string,
query.secret as string,
newPassword.value
)
.then(() => {
Dialog.create({ message: 'Password Changed!' }).onOk(() =>
router.replace('/login')
);
})
.catch((e) =>
Dialog.create({
message: 'Password change failed! Error: ' + e.message,
})
);
} else {
Dialog.create({
message: 'Invalid password. Try again',
});
}
}
function resetPw() {
resetPassword(email.value)
.then(() => router.replace('/login'))
.finally(() =>
Dialog.create({
message:
'If your address is in our system, you should receive an e-mail shortly.',
})
);
}
</script>

86
src/pages/SignupPage.vue Normal file
View File

@@ -0,0 +1,86 @@
<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="~assets/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
<div class="col text-h6">Sign Up</div>
</div>
</q-card-section>
<q-form>
<q-card-section class="q-gutter-md">
<q-input
v-model="email"
label="E-Mail"
type="email"
color="darkblue"
:rules="['email']"
filled></q-input>
<NewPasswordComponent v-model="password" />
<q-card-actions>
<q-space />
<q-btn
type="button"
@click="doRegister"
label="Sign Up"
color="primary"></q-btn>
</q-card-actions>
</q-card-section>
</q-form>
</q-card>
</q-page>
</q-page-container>
</q-layout>
</template>
<style>
.bg-image {
background-image: url('/src/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
/* background-image: linear-gradient(
135deg,
#ed232a 0%,
#ffffff 75%,
#14539a 100%
); */
}
</style>
<script setup lang="ts">
import { ref } from 'vue';
import { useAuthStore } from 'src/stores/auth';
import NewPasswordComponent from 'src/components/NewPasswordComponent.vue';
import { Dialog } from 'quasar';
import { useRouter } from 'vue-router';
const email = ref('');
const password = ref('');
const router = useRouter();
console.log('version:' + process.env.VUE_APP_VERSION);
const doRegister = async () => {
if (email.value && password.value) {
try {
await useAuthStore().register(email.value, password.value);
Dialog.create({
message: 'Account Created! Now log-in with your e-mail / password.',
}).onOk(() => router.replace('/login'));
} catch (e) {
console.log(e);
Dialog.create({
message: 'An error occurred. Please ask for support in Discord',
});
}
}
};
</script>

View File

@@ -1,26 +0,0 @@
<template>
<toolbar-component pageTitle="Tasks" />
<q-page padding>
<q-card bordered separator class="mobile-card">
<q-card-section clickable v-ripple>
<div class="text-h6">Launch Prep</div>
<div class="text-subtitle2">Prepare for Launch</div>
<q-chip size="md" color="green" text-color="white" icon="alarm">
APR 1,2024
</q-chip>
<q-chip size="md" icon="build"> 24 tasks </q-chip>
</q-card-section>
</q-card>
<q-card bordered separator class="mobile-card">
<q-card-section clickable v-ripple>
<div class="text-h6">General Maintenance</div>
<div class="text-subtitle2">Day to day maintenance and upkeep</div>
<q-chip size="md" icon="build"> 4 tasks </q-chip>
</q-card-section>
</q-card>
</q-page>
</template>
<script setup lang="ts">
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
</script>

View File

@@ -1,119 +1,128 @@
<template>
<q-page padding>
<h1>Website Terms and Conditions of Use</h1>
<q-layout>
<q-page-container>
<q-page padding>
<h1>Website Terms and Conditions of Use</h1>
<h2>1. Terms</h2>
<h2>1. Terms</h2>
<p>
By accessing this Website, accessible from https://bab.toal.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>
<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>
<h2>2. Use License</h2>
<p>
Permission is granted to temporarily download one copy of the materials on
bab.toal.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>
<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 bab.toal.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>
<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>
<p>
This will let bab.toal.ca to terminate upon violations of any of these
restrictions. Upon termination, your viewing right will also be terminated
and you should destroy any downloaded materials in your possession whether
it is printed or electronic format. These Terms of Service has been
created with the help of the
<a href="https://www.termsofservicegenerator.net"
>Terms Of Service Generator</a
>.
</p>
<p>
This will let undock.ca to terminate upon violations of any of these
restrictions. Upon termination, your viewing right will also be
terminated and you should destroy any downloaded materials in your
possession whether it is printed or electronic format. These Terms of
Service has been created with the help of the
<a href="https://www.termsofservicegenerator.net">
Terms Of Service Generator
</a>
.
</p>
<h2>3. Disclaimer</h2>
<h2>3. Disclaimer</h2>
<p>
All the materials on bab.toal.ca's Website are provided "as is".
bab.toal.ca makes no warranties, may it be expressed or implied, therefore
negates all other warranties. Furthermore, bab.toal.ca does not make any
representations concerning the accuracy or reliability of the use of the
materials on its Website or otherwise relating to such materials or any
sites linked to this Website.
</p>
<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. Furthermore, undock.ca does
not make any representations concerning the accuracy or reliability of
the use of the materials on its Website or otherwise relating to such
materials or any sites linked to this Website.
</p>
<h2>4. Limitations</h2>
<h2>4. Limitations</h2>
<p>
bab.toal.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
bab.toal.ca's Website, even if bab.toal.ca or an authorize representative
of this Website has been notified, orally or written, of the possibility
of such damage. Some jurisdiction does not allow limitations on implied
warranties or limitations of liability for incidental damages, these
limitations may not apply to you.
</p>
<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, even if bab.toal.ca or an authorize
representative of this Website has been notified, orally or written,
of the possibility of such damage. Some jurisdiction does not allow
limitations on implied warranties or limitations of liability for
incidental damages, these limitations may not apply to you.
</p>
<h2>5. Revisions and Errata</h2>
<h2>5. Revisions and Errata</h2>
<p>
The materials appearing on bab.toal.ca's Website may include technical,
typographical, or photographic errors. bab.toal.ca will not promise that
any of the materials in this Website are accurate, complete, or current.
bab.toal.ca may change the materials contained on its Website at any time
without notice. bab.toal.ca does not make any commitment to update the
materials.
</p>
<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. undock.ca may change the materials contained on its Website
at any time without notice. undock.ca does not make any commitment to
update the materials.
</p>
<h2>6. Links</h2>
<h2>6. Links</h2>
<p>
bab.toal.ca has not reviewed all of the sites linked to its Website and is
not responsible for the contents of any such linked site. The presence of
any link does not imply endorsement by bab.toal.ca of the site. The use of
any linked website is at the user's own risk.
</p>
<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. The
presence of any link does not imply endorsement by undock.ca of the
site. The use of any linked website is at the user's own risk.
</p>
<h2>7. Site Terms of Use Modifications</h2>
<h2>7. Site Terms of Use Modifications</h2>
<p>
bab.toal.ca may revise these Terms of Use for its Website at any time
without prior notice. By using this Website, you are agreeing to be bound
by the current version of these Terms and Conditions of Use.
</p>
<p>
undock.ca may revise these Terms of Use for its Website at any time
without prior notice. By using this Website, you are agreeing to be
bound by the current version of these Terms and Conditions of Use.
</p>
<h2>8. Your Privacy</h2>
<h2>8. Your Privacy</h2>
<p>Please read our Privacy Policy.</p>
<p>
Please read our
<a href="/privacy-policy">Privacy Policy.</a>
</p>
<h2>9. Governing Law</h2>
<h2>9. Governing Law</h2>
<p>
Any claim related to bab.toal.ca's Website shall be governed by the laws
of ca without regards to its conflict of law provisions.
</p>
</q-page>
<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>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,8 @@
<template>
<q-page padding>
<!-- content -->
</q-page>
</template>
<script setup lang="ts">
</script>

View File

@@ -1,163 +1,27 @@
<template>
<q-page padding>
<q-list>
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md">
<q-input
bottom-slots
v-model="bookingForm.name"
label="Creating reservation for"
readonly
>
<template v-slot:prepend>
<q-icon name="person" />
</template>
</q-input>
<q-expansion-item
expand-separator
v-model="resourceView"
icon="calendar_month"
label="Boat and Time"
default-opened
:caption="bookingSummary"
>
<q-separator />
<resource-schedule-viewer-component
@on-click-time="onClickTime"
@on-update-duration="
(value) => {
bookingForm.duration = value;
}
"
/>
<q-banner
rounded
class="bg-warning text-grey-10"
v-if="bookingForm.boat?.defects"
>
<template v-slot:avatar>
<q-icon name="warning" color="grey-10" />
</template>
{{ bookingForm.boat.name }} currently has the following notices:
<ol>
<li
v-for="defect in bookingForm.boat.defects"
:key="defect.description"
>
{{ defect.description }}
</li>
</ol>
</q-banner>
<q-card-section>
<q-btn
color="primary"
class="full-width"
icon="keyboard_arrow_down"
icon-right="keyboard_arrow_down"
label="Next: Crew & Passengers"
@click="resourceView = false"
/></q-card-section>
</q-expansion-item>
<q-expansion-item
expand-separator
icon="people"
label="Crew and Passengers"
default-opened
>
<q-separator />
</q-expansion-item>
<q-item-section>
<q-btn label="Submit" type="submit" color="primary" />
</q-item-section> </q-form
></q-list>
</q-page>
<BoatReservationComponent v-model="newReservation" />
</template>
<script setup lang="ts">
import { reactive, ref, computed, watch } from 'vue';
import { useAuthStore } from 'src/stores/auth';
import { Boat, useBoatStore } from 'src/stores/boat';
import { Dialog, date } from 'quasar';
import ResourceScheduleViewerComponent from 'src/components/ResourceScheduleViewerComponent.vue';
import { makeDateTime } from '@quasar/quasar-ui-qcalendar';
import { useScheduleStore, Reservation } from 'src/stores/schedule';
import BoatReservationComponent from 'src/components/BoatReservationComponent.vue';
import { useIntervalStore } from 'src/stores/interval';
import { Interval, Reservation } from 'src/stores/schedule.types';
import { ref } from 'vue';
import { useRoute } from 'vue-router';
const auth = useAuthStore();
const dateFormat = 'ddd MMM D, YYYY h:mm A';
const resourceView = ref(true);
const scheduleStore = useScheduleStore();
const bookingForm = reactive({
bookingId: scheduleStore.getNewId(),
name: auth.currentUser?.name,
boat: <Boat | undefined>undefined,
startDate: date.formatDate(new Date(), dateFormat),
endDate: computed(() =>
date.formatDate(
date.addToDate(bookingForm.startDate, {
hours: bookingForm.duration,
}),
dateFormat
)
),
duration: 1,
});
const $route = useRoute();
const newReservation = ref<Reservation>();
watch(bookingForm, (b, a) => {
const newRes = <Reservation>{
id: b.bookingId,
user: b.name,
resource: b.boat,
start: date.extractDate(b.startDate, dateFormat),
end: date.extractDate(b.endDate, dateFormat),
reservationDate: new Date(),
status: 'tentative',
};
//TODO: Turn this into a validator.
scheduleStore.isOverlapped(newRes)
? Dialog.create({ message: 'This booking overlaps another!' })
: scheduleStore.addOrCreateReservation(newRes);
});
const onReset = () => {
// TODO
};
const onSubmit = () => {
// TODO
};
const onClickTime = (data) => {
bookingForm.boat = data.scope.resource;
bookingForm.startDate = date.formatDate(
date.addToDate(makeDateTime(data.scope.timestamp), { hours: 5 }), // A terrible hack to convert back to EST. TODO: FIX!!!!
dateFormat
);
console.log(bookingForm.startDate);
};
const bookingDuration = computed(() => {
const diff = date.getDateDiff(
bookingForm.endDate,
bookingForm.startDate,
'minutes'
);
return diff <= 0
? 'Invalid'
: (diff > 60 ? Math.trunc(diff / 60) + ' hours' : '') +
(diff % 60 > 0 ? ' ' + (diff % 60) + ' minutes' : '');
});
const bookingSummary = computed(() => {
return bookingForm.boat && bookingForm.startDate && bookingForm.endDate
? `${bookingForm.boat.name} @ ${bookingForm.startDate} for ${bookingDuration.value}`
: '';
});
const limitDate = (startDate: string) => {
return date.isBetweenDates(
startDate,
new Date(),
date.addToDate(new Date(), { days: 21 }),
{ inclusiveFrom: true, inclusiveTo: true, onlyDate: true }
);
};
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>

View File

@@ -1,12 +1,163 @@
<template>
<q-page padding>
<!-- content -->
<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>
<script setup lang="ts">
import { useScheduleStore } from 'src/stores/schedule';
import { useReservationStore } from 'src/stores/reservation';
import { ref } from 'vue';
import { useAuthStore } from 'src/stores/auth';
const scheduleStore = useScheduleStore();
scheduleStore.loadSampleData();
const reservationStore = useReservationStore();
import { getDate } from '@quasar/quasar-ui-qcalendar';
import { QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat';
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
import { useQuasar } from 'quasar';
import { formatTime } from 'src/utils/schedule';
import { useIntervalStore } from 'src/stores/interval';
import { Interval, Reservation } from 'src/stores/schedule.types';
import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
const boatStore = useBoatStore();
const calendar = ref();
const $q = useQuasar();
const $router = useRouter();
const { getAvailableIntervals } = useIntervalStore();
const { selectedDate } = storeToRefs(useIntervalStore());
const currentUser = useAuthStore().currentUser;
// interface DayScope {
// timestamp: Timestamp;
// columnIndex: number;
// resource: object;
// resourceIndex: number;
// indentLevel: number;
// activeDate: boolean;
// droppable: boolean;
// }
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));
};
// Method declarations
// function slotStyle(
// event: Reservation,
// timeStartPos: (time: TimestampOrNull) => string,
// timeDurationHeight: (minutes: number) => string
// ) {
// const s = {
// top: '',
// height: '',
// 'align-items': 'flex-start',
// };
// if (timeStartPos && timeDurationHeight) {
// s.top = timeStartPos(parsed(event.start)) + 'px';
// s.height =
// timeDurationHeight(date.getDateDiff(event.end, event.start, 'minutes')) +
// 'px';
// }
// return s;
// }
const createReservationFromInterval = (interval: Interval | Reservation) => {
if (interval.user) {
if (interval.user === currentUser?.$id) {
$router.push({ name: 'edit-reservation', params: { id: interval.$id } });
} else {
return false;
}
} else {
$router.push({
name: 'reserve-boat',
query: { interval: interval.$id },
});
}
};
function handleSwipe({ ...event }) {
event.direction === 'right' ? calendar.value?.prev() : 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>
<style lang="sass">
.q-calendar-scheduler__resource
background-color: $primary
color: white
font-weight: bold
</style>

View File

@@ -0,0 +1,86 @@
<template>
<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>
</template>
<script setup lang="ts">
import { useReservationStore } from 'src/stores/reservation';
import ReservationCardComponent from 'src/components/scheduling/ReservationCardComponent.vue';
import { onMounted, ref } from 'vue';
const reservationStore = useReservationStore();
onMounted(() => useReservationStore().fetchUserReservations());
const tab = ref('upcoming');
// const showMarker = (
// index: number,
// items: Reservation[] | undefined
// ): boolean => {
// if (!items) return false;
// const currentItemDate = new Date(items[index].start);
// const nextItemDate = index > 0 ? new Date(items[index - 1].start) : null;
// // Show marker if current item is past and the next item is future or vice versa
// return (
// isPast(currentItemDate) && (nextItemDate === null || !isPast(nextItemDate))
// );
// };
</script>

View File

@@ -0,0 +1,324 @@
<template>
<div class="fit row wrap justify-start items-start content-start">
<div class="q-pa-md">
<div
class="scheduler"
style="max-width: 1200px">
<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-popup-edit
:model-value="block"
v-slot="scope"
buttons
@save="saveInterval"
>
TODO: Why isn't this saving?
<q-input
:model-value="date.formatDate(scope.value.start, 'HH:mm')"
dense
autofocus
type="time"
label="start"
@keyup.enter="scope.set"
@update:model-value="
(t) => {
block.start = new Date(
scope.value.start.split('T')[0] + 'T' + t
).toISOString();
}
"
/>
TODO: Clean this up
<q-input
:model-value="date.formatDate(scope.value.end, 'HH:mm')"
dense
type="time"
label="end"
@keyup.enter="scope.set"
@update:model-value="
(t) =>
(block.end = new Date(
scope.value.end.split('T')[0] + 'T' + t
).toISOString())
"
/>
</q-popup-edit>-->
</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>
</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>
</template>
<script setup lang="ts">
import {
QCalendarScheduler,
Timestamp,
today,
} from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat';
import { useIntervalStore } from 'src/stores/interval';
import { computed, onMounted, ref } from 'vue';
import type {
Interval,
IntervalTemplate,
TimeTuple,
} from 'src/stores/schedule.types';
import { date } from 'quasar';
import IntervalTemplateComponent from 'src/components/scheduling/IntervalTemplateComponent.vue';
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
import { storeToRefs } from 'pinia';
import { buildInterval, intervalsOverlapped } from 'src/utils/schedule';
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
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']],
});
/* TODOS:
* Need more validation:
- Interval start < end
- Intervals don't overlap
* Need to handle case of overnight blocks.
*/
onMounted(async () => {
await fetchBoats();
await intervalTemplateStore.fetchIntervalTemplates();
});
const filteredIntervals = (date: Timestamp, boat: Boat) => {
return intervalStore.getIntervals(date, boat);
};
const sortedIntervals = (date: Timestamp, boat: Boat) => {
return computed(() =>
filteredIntervals(date, 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, date: string) {
const intervals = intervalsFromTemplate(boat, templateId, date);
intervals.forEach((interval) => intervalStore.createInterval(interval));
}
function getIntervals(date: Timestamp, boat: Boat) {
return intervalStore.getIntervals(date, boat);
}
function intervalsFromTemplate(
boat: Boat,
templateId: string,
date: string
): Interval[] {
const template = intervalTemplateStore
.getIntervalTemplates()
.value.find((t) => t.$id === templateId);
return template
? template.timeTuples.map((timeTuple: TimeTuple) =>
buildInterval(boat, timeTuple, date)
)
: [];
}
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(
//TODO: Move all overlap checking to the store. This is too messy right now.
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 date = 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, date)
)
)
)
.flat(1);
if (overlapped.value.length === 0) {
boatsToApply.map((b) => createIntervals(b, templateId, date));
} 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>

View File

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

View File

@@ -1,27 +1,25 @@
<template>
<q-page padding>
<q-item v-for="link in navlinks" :key="link.label">
<q-item
v-for="link in navlinks"
:key="link.name">
<q-btn
:icon="link.icon"
color="primary"
:color="link.color ? link.color : 'primary'"
size="1.25em"
:to="link.to"
:label="link.label"
:label="link.name"
rounded
class="full-width"
align="left"
/>
align="left" />
</q-item>
</q-page>
</template>
<script setup lang="ts">
const navlinks = [
{
icon: 'more_time',
to: '/schedule/book',
label: 'Create a Reservation',
},
{ icon: 'calendar_month', to: '/schedule/view', label: 'View Schedule' },
];
import { enabledLinks } from 'src/router/navlinks';
const navlinks = enabledLinks.find(
(link) => link.name === 'Schedule'
)?.sublinks;
</script>

View File

@@ -0,0 +1,17 @@
<template>
<ToolbarComponent pageTitle="Tasks" />
<q-page padding>
<div
class="q-pa-md"
style="max-width: 400px">
<TaskEditComponent :taskId="taskId" />
</div>
</q-page>
</template>
<script setup lang="ts">
const taskId = useRoute().params.id as string;
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
import TaskEditComponent from 'src/components/task/TaskEditComponent.vue';
import { useRoute } from 'vue-router';
</script>

View File

@@ -0,0 +1,16 @@
<template>
<toolbar-component pageTitle="Tasks" />
<q-page padding>
<TaskTableComponent :tasks="taskStore.tasks" />
</q-page>
</template>
<script setup lang="ts">
import { useTaskStore } from 'stores/task';
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
import TaskTableComponent from 'src/components/task/TaskTableComponent.vue';
const taskStore = useTaskStore();
taskStore.fetchTasks(); // Fetch on mount
</script>

View File

@@ -1,163 +1,173 @@
<template>
<q-page padding>
<h1>Privacy Policy for bab.toal.ca</h1>
<q-layout>
<q-page-container>
<q-page padding>
<h1>Privacy Policy for Undock</h1>
<p>
At OYS BAB Test, accessible from https://bab.toal.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 OYS BAB
Test and how we use it.
</p>
<p>
At Undock, accessible from https://Undock, 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 OYS BAB Test
and how we use it.
</p>
<p>
If you have additional questions or require more information about our
Privacy Policy, do not hesitate to contact us. Our Privacy Policy was
generated with the help of
<a href="https://www.gdprprivacypolicy.net/"
>GDPR Privacy Policy Generator</a
>
</p>
<p>
If you have additional questions or require more information about our
Privacy Policy, do not hesitate to contact us. Our Privacy Policy was
generated with the help of
<a href="https://www.gdprprivacypolicy.net/">
GDPR Privacy Policy Generator
</a>
</p>
<h2>General Data Protection Regulation (GDPR)</h2>
<p>We are a Data Controller of your information.</p>
<h2>General Data Protection Regulation (GDPR)</h2>
<p>We are a Data Controller of your information.</p>
<p>
bab.toal.ca legal basis for collecting and using the personal information
described in this Privacy Policy depends on the Personal Information we
collect and the specific context in which we collect the information:
</p>
<ul>
<li>bab.toal.ca needs to perform a contract with you</li>
<li>You have given bab.toal.ca permission to do so</li>
<li>
Processing your personal information is in bab.toal.ca legitimate
interests
</li>
<li>bab.toal.ca needs to comply with the law</li>
</ul>
<p>
Undock legal basis for collecting and using the personal information
described in this Privacy Policy depends on the Personal Information
we collect and the specific context in which we collect the
information:
</p>
<ul>
<li>Undock needs to perform a contract with you</li>
<li>You have given Undock permission to do so</li>
<li>
Processing your personal information is in Undock legitimate
interests
</li>
<li>Undock needs to comply with the law</li>
</ul>
<p>
bab.toal.ca will retain your personal information only for as long as is
necessary for the purposes set out in this Privacy Policy. We will retain
and use your information to the extent necessary to comply with our legal
obligations, resolve disputes, and enforce our policies.
</p>
<p>
Undock will retain your personal information only for as long as is
necessary for the purposes set out in this Privacy Policy. We will
retain and use your information to the extent necessary to comply with
our legal obligations, resolve disputes, and enforce our policies.
</p>
<p>
If you are a resident of the European Economic Area (EEA), you have
certain data protection rights. If you wish to be informed what Personal
Information we hold about you and if you want it to be removed from our
systems, please contact us.
</p>
<p>
If you are a resident of the European Economic Area (EEA), you have
certain data protection rights. If you wish to be informed what
Personal Information we hold about you and if you want it to be
removed from our systems, please contact us.
</p>
<p>
In certain circumstances, you have the following data protection rights:
</p>
<ul>
<li>
The right to access, update or to delete the information we have on you.
</li>
<li>The right of rectification.</li>
<li>The right to object.</li>
<li>The right of restriction.</li>
<li>The right to data portability</li>
<li>The right to withdraw consent</li>
</ul>
<p>
In certain circumstances, you have the following data protection
rights:
</p>
<ul>
<li>
The right to access, update or to delete the information we have on
you.
</li>
<li>The right of rectification.</li>
<li>The right to object.</li>
<li>The right of restriction.</li>
<li>The right to data portability</li>
<li>The right to withdraw consent</li>
</ul>
<h2>Log Files</h2>
<h2>Log Files</h2>
<p>
OYS BAB Test follows a standard procedure of using log files. These files
log visitors when they visit websites. All hosting companies do this and a
part of hosting services' analytics. 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. These are not linked to any information
that is personally identifiable. The purpose of the information is for
analyzing trends, administering the site, tracking users' movement on the
website, and gathering demographic information.
</p>
<p>
Undock follows a standard procedure of using log files. These files
log visitors when they visit websites. All hosting companies do this
and a part of hosting services' analytics. 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. These are not linked to any
information that is personally identifiable. The purpose of the
information is for analyzing trends, administering the site, tracking
users' movement on the website, and gathering demographic information.
</p>
<h2>Cookies and Web Beacons</h2>
<h2>Cookies and Web Beacons</h2>
<p>
Like any other website, OYS BAB Test uses "cookies". These cookies are
used to store information including visitors' preferences, and the pages
on the website that the visitor accessed or visited. The information is
used to optimize the users' experience by customizing our web page content
based on visitors' browser type and/or other information.
</p>
<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. The information is
used to optimize the users' experience by customizing our web page
content based on visitors' browser type and/or other information.
</p>
<h2>Privacy Policies</h2>
<h2>Privacy Policies</h2>
<P
>You may consult this list to find the Privacy Policy for each of the
advertising partners of OYS BAB Test.</P
>
<p>
You may consult this list to find the Privacy Policy for each of the
advertising partners of Undock.
</p>
<p>
Third-party ad servers or ad networks uses technologies like cookies,
JavaScript, or Web Beacons that are used in their respective
advertisements and links that appear on OYS BAB Test, which are sent
directly to users' browser. They automatically receive your IP address
when this occurs. These technologies are used to measure the effectiveness
of their advertising campaigns and/or to personalize the advertising
content that you see on websites that you visit.
</p>
<p>
Third-party ad servers or ad networks uses technologies like cookies,
JavaScript, or Web Beacons that are used in their respective
advertisements and links that appear on Undock, which are sent
directly to users' browser. They automatically receive your IP address
when this occurs. These technologies are used to measure the
effectiveness of their advertising campaigns and/or to personalize the
advertising content that you see on websites that you visit.
</p>
<p>
Note that OYS BAB Test has no access to or control over these cookies that
are used by third-party advertisers.
</p>
<p>
Note that Undock has no access to or control over these cookies that
are used by third-party advertisers.
</p>
<h2>Third Party Privacy Policies</h2>
<h2>Third Party Privacy Policies</h2>
<p>
OYS BAB Test's Privacy Policy does not apply to other advertisers or
websites. Thus, we are advising you to consult the respective Privacy
Policies of these third-party ad servers for more detailed information. It
may include their practices and instructions about how to opt-out of
certain options.
</p>
<p>
Undock's Privacy Policy does not apply to other advertisers or
websites. Thus, we are advising you to consult the respective Privacy
Policies of these third-party ad servers for more detailed
information. It may include their practices and instructions about how
to opt-out of certain options.
</p>
<p>
You can choose to disable cookies through your individual browser options.
To know more detailed information about cookie management with specific
web browsers, it can be found at the browsers' respective websites.
</p>
<p>
You can choose to disable cookies through your individual browser
options. To know more detailed information about cookie management
with specific web browsers, it can be found at the browsers'
respective websites.
</p>
<h2>Children's Information</h2>
<h2>Children's Information</h2>
<p>
Another part of our priority is adding protection for children while using
the internet. We encourage parents and guardians to observe, participate
in, and/or monitor and guide their online activity.
</p>
<p>
Another part of our priority is adding protection for children while
using the internet. We encourage parents and guardians to observe,
participate in, and/or monitor and guide their online activity.
</p>
<p>
OYS BAB Test does not knowingly collect any Personal Identifiable
Information from children under the age of 13. If you think that your
child provided this kind of information on our website, we strongly
encourage you to contact us immediately and we will do our best efforts to
promptly remove such information from our records.
</p>
<p>
Undock does not knowingly collect any Personal Identifiable
Information from children under the age of 13. If you think that your
child provided this kind of information on our website, we strongly
encourage you to contact us immediately and we will do our best
efforts to promptly remove such information from our records.
</p>
<h2>Online Privacy Policy Only</h2>
<h2>Online Privacy Policy Only</h2>
<p>
Our Privacy Policy applies only to our online activities and is valid for
visitors to our website with regards to the information that they shared
and/or collect in OYS BAB Test. This policy is not applicable to any
information collected offline or via channels other than this website.
</p>
<p>
Our Privacy Policy applies only to our online activities and is valid
for visitors to our website with regards to the information that they
shared and/or collect in Undock. This policy is not applicable to any
information collected offline or via channels other than this website.
</p>
<h2>Consent</h2>
<h2>Consent</h2>
<p>
By using our website, you hereby consent to our Privacy Policy and agree
to its terms.
</p>
</q-page>
<p>
By using our website, you hereby consent to our Privacy Policy and
agree to its terms.
</p>
</q-page>
</q-page-container>
</q-layout>
</template>
<script setup lang="ts"></script>
<script
setup
lang="ts"></script>

View File

@@ -9,6 +9,10 @@ import {
import routes from './routes';
import { useAuthStore } from 'src/stores/auth';
const publicRoutes = routes
.filter((route) => route.meta?.publicRoute)
.map((r) => r.path);
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
@@ -35,17 +39,37 @@ export default route(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE),
});
Router.beforeEach((to) => {
const auth = useAuthStore();
Router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
const currentUser = authStore.currentUser;
const authRequired = !publicRoutes.includes(to.path);
const requiredRoles = to.meta?.requiredRoles as string[];
if (!auth.ready) {
return false;
if (authRequired && !currentUser) {
return next('/login');
}
if (auth.currentUser) {
return to.meta.accountRoute ? { name: 'index' } : true;
} else {
return to.name == 'login' ? true : { name: 'login' };
if (to.name === 'login' && currentUser) {
return next('/');
}
if (requiredRoles) {
if (!currentUser) {
return next('/login');
}
try {
const hasRole = authStore.hasRequiredRole(requiredRoles);
if (!hasRole) {
return next(from);
}
} catch (error) {
console.error('Failed to fetch user teams:', error);
return next('/error'); // Redirect to an error page or handle it as needed
}
}
next();
});
return Router;

View File

@@ -1,50 +1,121 @@
export const links = [
import { useAuthStore } from 'src/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: 'Manage',
to: '/schedule/manage',
icon: 'edit_calendar',
front_links: false,
enabled: true,
color: 'accent',
requiredRoles: ['Schedule Admins'],
},
],
},
{
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: 'Tasks',
to: '/task',
icon: 'build',
front_links: true,
enabled: false,
},
];
const authStore = useAuthStore();
function hasRole(roles: string[] | undefined) {
if (roles === undefined) return true;
const hasRole = authStore.hasRequiredRole(roles);
return hasRole;
}
export const enabledLinks = links
.filter((link) => link.enabled)
.map((link) => {
if (link.sublinks) {
link.sublinks = link.sublinks.filter(
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
);
}
return link;
});

View File

@@ -1,24 +1,9 @@
import ScheduleIndexPage from 'pages/schedule/ScheduleIndexPage.vue';
import ChecklistPageVue from 'pages/ChecklistPage.vue';
import LoginPageVue from 'pages/LoginPage.vue';
import ReferencePageVue from 'src/pages/reference/ReferencePage.vue';
import ReferenceIndexPageVue from 'src/pages/reference/ReferenceIndexPage.vue';
import ReferenceItemPageVue from 'src/pages/reference/ReferenceItemPage.vue';
import MainLayoutVue from 'src/layouts/MainLayout.vue';
import BoatPageVue from 'src/pages/BoatPage.vue';
import CertificationPageVue from 'src/pages/CertificationPage.vue';
import IndexPageVue from 'src/pages/IndexPage.vue';
import ProfilePageVue from 'src/pages/ProfilePage.vue';
import TaskPageVue from 'src/pages/TaskPage.vue';
import { RouteRecordRaw } from 'vue-router';
import SchedulePageView from 'pages/schedule/SchedulePageView.vue';
import BoatReservationPageVue from 'src/pages/schedule/BoatReservationPage.vue';
import BoatScheduleViewVue from 'src/pages/schedule/BoatScheduleView.vue';
const routes: RouteRecordRaw[] = [
{
path: '/',
component: MainLayoutVue,
component: () => import('src/layouts/MainLayout.vue'),
// If we get so big we need lazy loading, we can use imports again
// component: () => import('layouts/MainLayout.vue'),
children: [
@@ -26,69 +11,101 @@ const routes: RouteRecordRaw[] = [
path: '',
// If we get so big we need lazy loading, we can use imports again
// component: () => import('pages/IndexPage.vue'),
component: IndexPageVue,
component: () => import('src/pages/IndexPage.vue'),
name: 'index',
},
{
path: '/boat',
component: BoatPageVue,
component: () => import('src/pages/BoatPage.vue'),
name: 'boat',
},
{
path: '/schedule',
component: SchedulePageView,
component: () => import('pages/schedule/SchedulePageView.vue'),
name: 'schedule',
children: [
{
path: '',
component: ScheduleIndexPage,
component: () => import('pages/schedule/ScheduleIndexPage.vue'),
name: 'schedule-index',
},
{
path: 'book',
component: BoatReservationPageVue,
component: () =>
import('src/pages/schedule/BoatReservationPage.vue'),
name: 'reserve-boat',
},
{
path: 'view',
component: BoatScheduleViewVue,
component: () => import('src/pages/schedule/BoatScheduleView.vue'),
name: 'boat-schedule',
},
{
path: 'list',
component: () =>
import('src/pages/schedule/ListReservationsPage.vue'),
name: 'list-reservations',
},
{
path: 'edit/:id',
component: () =>
import('src/pages/schedule/ModifyBoatReservation.vue'),
name: 'edit-reservation',
},
{
path: 'manage',
component: () => import('src/pages/schedule/ManageCalendar.vue'),
name: 'manage-schedule',
meta: { requiredRoles: ['Schedule Admins'] },
},
],
},
{
path: '/certification',
component: CertificationPageVue,
component: () => import('src/pages/CertificationPage.vue'),
name: 'certification',
},
{
path: '/task',
component: TaskPageVue,
name: 'task',
children: [
{
path: '',
component: () => import('src/pages/task/TaskPage.vue'),
name: 'task-index',
},
{
path: '/:id/edit',
component: () => import('pages/task/TaskEditPage.vue'),
name: 'edit-task',
},
],
},
{
path: '/checklist',
component: ChecklistPageVue,
component: () => import('pages/ChecklistPage.vue'),
name: 'checklist',
},
{
path: '/profile',
component: ProfilePageVue,
component: () => import('src/pages/ProfilePage.vue'),
name: 'profile',
},
{
path: '/reference',
component: ReferencePageVue,
component: () => import('src/pages/reference/ReferencePage.vue'),
name: 'reference',
children: [
{
path: '',
component: ReferenceIndexPageVue,
component: () =>
import('src/pages/reference/ReferenceIndexPage.vue'),
name: 'reference-index',
},
{
path: '/reference/:id/view',
component: ReferenceItemPageVue,
component: () =>
import('src/pages/reference/ReferenceItemPage.vue'),
},
],
},
@@ -97,6 +114,7 @@ const routes: RouteRecordRaw[] = [
{
path: '/admin',
component: () => import('layouts/AdminLayout.vue'),
meta: { requiredRoles: ['admin'] },
children: [
{
path: '/user',
@@ -112,7 +130,23 @@ const routes: RouteRecordRaw[] = [
},
{
path: '/login',
component: LoginPageVue,
component: () => import('pages/LoginPage.vue'),
name: 'login',
meta: {
publicRoute: true,
},
},
{
path: '/pwreset',
component: () => import('pages/ResetPassword.vue'),
name: 'pwreset',
meta: {
publicRoute: true,
},
},
{
path: '/login',
component: () => import('pages/LoginPage.vue'),
name: 'login',
meta: {
publicRoute: true,
@@ -134,14 +168,14 @@ const routes: RouteRecordRaw[] = [
publicRoute: true,
},
},
// {
// path: '/register',
// component: () => import('pages/RegisterPage.vue'),
// name: 'register'
// meta: {
// accountRoute: true,
// }
// },
{
path: '/signup',
component: () => import('pages/SignupPage.vue'),
name: 'signup',
meta: {
publicRoute: true,
},
},
// Always leave this as last one,
// but you can also remove it
{

View File

@@ -1,41 +1,125 @@
import { defineStore } from 'pinia';
import { ID, account } from 'boot/appwrite';
import type { Models } from 'appwrite';
import { ref } from 'vue';
import { ID, account, functions, teams } from 'boot/appwrite';
import { ExecutionMethod, OAuthProvider, 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 ready = ref(false);
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;
}
ready.value = true;
}
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 register(email: string, password: string) {
await account.create(ID.unique(), email, password);
return await login(email, password);
}
async function login(email: string, password: string) {
await account.createEmailSession(email, password);
currentUser.value = await account.get();
await account.createEmailPasswordSession(email, password);
await init();
}
async function createTokenSession(email: string) {
return await account.createEmailToken(ID.unique(), email);
}
async function googleLogin() {
account.createOAuth2Session(
'google',
'https://bab.toal.ca/',
'https://bab.toal.ca/#/login'
await account.createOAuth2Session(
OAuthProvider.Google,
'https://oys.undock.ca',
'https://oys.undock.ca/login'
);
currentUser.value = await account.get();
await init();
}
async function discordLogin() {
await account.createOAuth2Session(
OAuthProvider.Discord,
'https://oys.undock.ca',
'https://oys.undock.ca/login'
);
await init();
}
async function tokenLogin(userId: string, token: string) {
await account.createSession(userId, token);
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];
}
function logout() {
return account.deleteSession('current').then((currentUser.value = null));
}
return { currentUser, register, login, googleLogin, logout, init, ready };
async function updateName(name: string) {
await account.updateName(name);
currentUser.value = await account.get();
}
return {
currentUser,
getUserNameById,
hasRequiredRole,
register,
updateName,
login,
googleLogin,
discordLogin,
createTokenSession,
tokenLogin,
logout,
init,
};
});

View File

@@ -1,21 +1,22 @@
import { Models } from 'appwrite';
import { defineStore } from 'pinia';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ref } from 'vue';
// const boatSource = null;
export interface Boat {
id: number;
export interface Boat extends Models.Document {
$id: string;
name: string;
displayName?: string;
class?: string;
year?: number;
imgsrc?: string;
iconsrc?: string;
booking?: {
available: boolean;
requiredCerts: string[];
maxDuration: number;
maxPassengers: number;
};
defects?: {
imgSrc?: string;
iconSrc?: string;
bookingAvailable: boolean;
requiredCerts: string[];
maxPassengers: number;
defects: {
type: string;
severity: string;
description: string;
@@ -23,58 +24,25 @@ export interface Boat {
}[];
}
const getSampleData = () => [
{
id: 1,
name: 'ProjectX',
class: 'J/27',
year: 1981,
imgsrc: '/tmpimg/j27.png',
iconsrc: '/tmpimg/projectx_avatar256.png',
defects: [
{
type: 'engine',
severity: 'moderate',
description: 'Fuel line leaks at engine fitting.',
detail: `The gasket in the end of the fuel hose is damaged, and does not properly seal.
This will cause fuel to leak, and will allow air into the fuel chamber, causing a lean mixture,
and rough engine performance.`,
},
{
type: 'rigging',
severity: 'moderate',
description: 'Tiller extension is broken.',
detail:
'The tiller extension swivel is broken, and will not attach to the tiller.',
},
],
},
{
id: 2,
name: 'Take5',
class: 'J/27',
year: 1985,
imgsrc: '/tmpimg/j27.png',
iconsrc: '/tmpimg/take5_avatar32.png',
},
{
id: 3,
name: 'WeeBeestie',
class: 'Capri 25',
year: 1989,
imgsrc: '/tmpimg/capri25.png',
},
];
export const useBoatStore = defineStore('boat', () => {
const boats = ref<Boat[]>([]);
export const useBoatStore = defineStore('boat', {
state: () => ({
boats: getSampleData(),
}),
async function fetchBoats() {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.boat
);
boats.value = response.documents as Boat[];
} catch (error) {
console.error('Failed to fetch boats', error);
}
}
getters: {},
const getBoatById = (id: string | null | undefined): Boat | null => {
if (!id) return null;
return boats.value?.find((b) => b.$id === id) || null;
};
actions: {
// update () {
// }
},
return { boats, fetchBoats, getBoatById };
});

161
src/stores/interval.ts Normal file
View File

@@ -0,0 +1,161 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { Boat } from './boat';
import { Timestamp, today } from '@quasar/quasar-ui-qcalendar';
import { Interval } from './schedule.types';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ID, Query } from 'appwrite';
import { useReservationStore } from './reservation';
import { LoadingTypes } from 'src/utils/misc';
import { useRealtimeStore } from './realtime';
export const useIntervalStore = defineStore('interval', () => {
const intervals = ref(new Map<string, Interval>()); // Intervals by DocID
const dateStatus = ref(new Map<string, LoadingTypes>()); // State of load by date
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 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), // We are asuming that we won't have more than 50 intervals per day.
]
);
response.documents.forEach((d) =>
intervals.value.set(d.$id, d 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 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 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,97 @@
import { Ref, ref } from 'vue';
import { IntervalTemplate } from './schedule.types';
import { defineStore } from 'pinia';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ID, Models } from 'appwrite';
import { arrayToTimeTuples } from 'src/utils/schedule';
export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
const intervalTemplates = ref<IntervalTemplate[]>([]);
const getIntervalTemplates = (): Ref<IntervalTemplate[]> => {
// Should subscribe to get new intervaltemplates when they are created
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: Models.Document): IntervalTemplate => {
return {
...d,
timeTuples: arrayToTimeTuples(d.timeTuple),
} 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 IntervalTemplate);
} catch (e) {
console.error('Error updating 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.timeTuple),
} as IntervalTemplate)
);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);
}
};
return {
getIntervalTemplates,
fetchIntervalTemplates,
createIntervalTemplate,
deleteIntervalTemplate,
updateIntervalTemplate,
};
});

21
src/stores/realtime.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineStore } from 'pinia';
import { client } from 'src/boot/appwrite';
import { Interval } from './schedule.types';
import { ref } from 'vue';
import { RealtimeResponseEvent } from 'appwrite';
export const useRealtimeStore = defineStore('realtime', () => {
const subscriptions = ref<Map<string, () => void>>(new Map());
const register = (
channel: string,
fn: (response: RealtimeResponseEvent<Interval>) => void
) => {
if (subscriptions.value.has(channel)) return; // Already subscribed. But maybe different callback fn?
subscriptions.value.set(channel, client.subscribe(channel, fn));
};
return {
register,
};
});

285
src/stores/reservation.ts Normal file
View File

@@ -0,0 +1,285 @@
import { defineStore } from 'pinia';
import type { Reservation } from './schedule.types';
import { ComputedRef, computed, reactive } from 'vue';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ID, Query } from 'appwrite';
import { date, useQuasar } from 'quasar';
import { Timestamp, parseDate, today } from '@quasar/quasar-ui-qcalendar';
import { LoadingTypes } from 'src/utils/misc';
import { useAuthStore } from './auth';
import { isPast } from 'src/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 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);
}
}
}
);
// Fetch reservations for a specific date range
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 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 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 Reservation);
userReservations.set(response.$id, response as Reservation);
console.info('Reservation booked: ', response);
return response 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',
});
}
};
// Set the loading state for dates
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;
};
// Get reservations by date and optionally filter by boat
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;
});
});
};
// Get conflicting reservations for a resource within a time range
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
);
};
// Check if a resource has time overlap
const isResourceTimeOverlapped = (
resource: string,
start: Date,
end: Date
): boolean => {
return getConflictingReservations(resource, start, end).length > 0;
};
// Check if a reservation overlaps with existing reservations
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 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,
};
});

View File

@@ -0,0 +1,65 @@
export const getSampleData = () => [
{
$id: '1',
name: 'ProjectX',
displayName: 'PX',
class: 'J/27',
year: 1981,
imgSrc: '/tmpimg/projectX.jpg',
iconSrc: '/tmpimg/projectx_avatar256.png',
bookingAvailable: true,
maxPassengers: 8,
requiredCerts: [],
defects: [
{
type: 'engine',
severity: 'moderate',
description: 'Fuel line leaks at engine fitting.',
detail: `The gasket in the end of the fuel hose is damaged, and does not properly seal.
This will cause fuel to leak, and will allow air into the fuel chamber, causing a lean mixture,
and rough engine performance.`,
},
{
type: 'rigging',
severity: 'moderate',
description: 'Tiller extension is broken.',
detail:
'The tiller extension swivel is broken, and will not attach to the tiller.',
},
],
},
{
$id: '2',
name: 'Take5',
displayName: 'T5',
class: 'J/27',
year: 1985,
imgSrc: '/tmpimg/j27.png',
iconsrc: '/tmpimg/take5_avatar32.png',
bookingAvailable: true,
maxPassengers: 8,
requiredCerts: [],
},
{
$id: '3',
name: 'WeeBeestie',
displayName: 'WB',
class: 'Capri 25',
year: 1989,
imgSrc: '/tmpimg/capri25.png',
bookingAvailable: true,
maxPassengers: 6,
requiredCerts: [],
},
{
$id: '4',
name: 'Just My Imagination',
displayName: 'JMI',
class: 'Sirius 28',
year: 1989,
imgSrc: '/tmpimg/JMI.jpg',
bookingAvailable: true,
maxPassengers: 8,
requiredCerts: [],
},
];

View File

@@ -0,0 +1,151 @@
import { DateOptions, date } from 'quasar';
import { Boat, useBoatStore } from '../boat';
import {
parseTimestamp,
today,
Timestamp,
addToDate,
} from '@quasar/quasar-ui-qcalendar';
import type {
StatusTypes,
Reservation,
IntervalTemplate,
Interval,
TimeTuple,
} from '../schedule.types';
export const templateA: IntervalTemplate = {
id: '1',
name: 'WeekdayBlocks',
timeTuples: [
['08:00', '12:00'],
['12:00', '16:00'],
['17:00', '21:00'],
],
};
export const templateB: IntervalTemplate = {
id: '2',
name: 'WeekendBlocks',
timeTuples: [
['07:00', '10:00'],
['10:00', '13:00'],
['13:00', '16:00'],
['16:00', '19:00'],
],
};
export function getSampleIntervals(): Interval[] {
// Hard-code 30 days worth of blocks, for now. Make them random templates
const boats = useBoatStore().boats;
const result: Interval[] = [];
const tsToday: Timestamp = parseTimestamp(today()) as Timestamp;
for (let i = 0; i <= 30; i++) {
const template = templateB;
result.push(
...boats
.map((b): Interval[] => {
return template.blocks.map((t: TimeTuple): Interval => {
return {
$id: 'id' + Math.random().toString(32).slice(2),
resource: b.$id,
start: addToDate(tsToday, { day: i }).date + ' ' + t[0],
end: addToDate(tsToday, { day: i }).date + ' ' + t[1],
};
});
})
.flat(2)
);
}
return result;
}
export function getSampleReservations(): Reservation[] {
const sampleData = [
{
id: '1',
user: 'John Smith',
start: '7:00',
end: '10:00',
boat: '66359729003825946ae1',
status: 'confirmed',
reason: 'Open Sail',
},
{
id: '2',
user: 'Bob Barker',
start: '16:00',
end: '19:00',
boat: '66359729003825946ae1',
status: 'confirmed',
reason: 'Open Sail',
},
{
id: '3',
user: 'Peter Parker',
start: '7:00',
end: '13:00',
boat: '663597030029b71c7a9b',
status: 'tentative',
reason: 'Open Sail',
},
{
id: '4',
user: 'Vince McMahon',
start: '10:00',
end: '13:00',
boat: '663597030029b71c7a9b',
status: 'pending',
reason: 'Open Sail',
},
{
id: '5',
user: 'Heather Graham',
start: '13:00',
end: '19:00',
boat: '663596b9000235ffea55',
status: 'confirmed',
reason: 'Private Sail',
},
{
id: '6',
user: 'Lawrence Fishburne',
start: '13:00',
end: '16:00',
boat: '663596b9000235ffea55',
reason: 'Open Sail',
},
];
const boatStore = useBoatStore();
const now = new Date();
const splitTime = (x: string): string[] => {
return x.split(':');
};
const makeOpts = (x: string[]): DateOptions => {
return {
hour: parseInt(x[0]),
minute: parseInt(x[1]),
seconds: 0,
milliseconds: 0,
};
};
return sampleData.map((entry): Reservation => {
const boat = <Boat>boatStore.boats.find((b) => b.$id == entry.boat);
return {
id: entry.id,
user: entry.user,
start: date
.adjustDate(now, makeOpts(splitTime(entry.start)))
.toISOString(),
end: date.adjustDate(now, makeOpts(splitTime(entry.end))).toISOString(),
resource: boat.$id,
reservationDate: now,
reason: entry.reason,
status: entry.status as StatusTypes,
comment: '',
};
});
}

View File

@@ -1,140 +0,0 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { Boat, useBoatStore } from './boat';
import { date } from 'quasar';
import { DateOptions } from 'quasar';
export interface Reservation {
id: number;
user: string;
start: Date;
end: Date;
resource: Boat;
reservationDate: Date;
status?: string;
}
function getSampleData(): Reservation[] {
const sampleData = [
{
id: 1,
user: 'John Smith',
start: '12:00',
end: '14:00',
boat: 1,
status: 'confirmed',
},
{
id: 2,
user: 'Bob Barker',
start: '18:00',
end: '20:00',
boat: 1,
status: 'confirmed',
},
{
id: 3,
user: 'Peter Parker',
start: '8:00',
end: '10:00',
boat: 2,
status: 'tentative',
},
{
id: 4,
user: 'Vince McMahon',
start: '13:00',
end: '17:00',
boat: 2,
status: 'pending',
},
{
id: 5,
user: 'Heather Graham',
start: '06:00',
end: '09:00',
boat: 3,
status: 'confirmed',
},
{
id: 6,
user: 'Lawrence Fishburne',
start: '18:00',
end: '20:00',
boat: 3,
},
];
const boatStore = useBoatStore();
const now = new Date();
const splitTime = (x: string): string[] => {
return x.split(':');
};
const makeOpts = (x: string[]): DateOptions => {
return { hour: parseInt(x[0]), minute: parseInt(x[1]) };
};
return sampleData.map((entry): Reservation => {
const boat = <Boat>boatStore.boats.find((b) => b.id == entry.boat);
return {
id: entry.id,
user: entry.user,
start: date.adjustDate(now, makeOpts(splitTime(entry.start))),
end: date.adjustDate(now, makeOpts(splitTime(entry.end))),
resource: boat,
reservationDate: now,
status: entry.status,
};
});
}
export const useScheduleStore = defineStore('schedule', () => {
const reservations = ref<Reservation[]>(getSampleData());
const getBoatReservations = (
boat: number | string,
curDate: Date
): Reservation[] => {
return reservations.value.filter((x) => {
return (
(x.start.getDate() == curDate.getDate() ||
x.end.getDate() == curDate.getDate()) &&
x.resource != undefined &&
(typeof boat == 'number'
? x.resource.id == boat
: x.resource.name == boat)
);
});
};
const isOverlapped = (res: Reservation) => {
const lapped = reservations.value.filter(
(entry: Reservation) =>
entry.id != res.id &&
entry.resource == res.resource &&
((entry.start <= res.start && entry.end > res.start) ||
(entry.end >= res.end && entry.start <= res.end))
);
return lapped.length > 0;
};
const getNewId = () => {
// Trivial placeholder
return Math.max(...reservations.value.map((item) => item.id)) + 1;
};
const addOrCreateReservation = (reservation: Reservation) => {
const index = reservations.value.findIndex(
(res) => res.id == reservation.id
);
index != -1
? (reservations.value[index] = reservation)
: reservations.value.push(reservation);
};
return {
reservations,
getBoatReservations,
getNewId,
addOrCreateReservation,
isOverlapped,
};
});

View File

@@ -0,0 +1,30 @@
import { 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
/* TODO: Figure out how best to separate out where qcalendar bits should be.
e.g.: Should there be any qcalendar stuff in this store? Or should we have just JS Date
objects in here? */
export type TimeTuple = [start: string, end: string];
export type Interval = Partial<Models.Document> & {
resource: string;
start: string;
end: string;
};
export type IntervalTemplate = Partial<Models.Document> & {
name: string;
timeTuples: TimeTuple[];
};

157
src/stores/task.ts Normal file
View File

@@ -0,0 +1,157 @@
import { defineStore } from 'pinia';
import { AppwriteIds, databases, ID } from 'src/boot/appwrite';
import type { Models } from 'appwrite';
export const TASKSTATUS = ['ready', 'complete', 'waiting', 'archived'];
export interface Task extends Partial<Models.Document> {
title: string;
description: string;
/* Array of Appwrite Document IDs */
required_skills: string[];
/* Array of Appwrite Document IDs */
tags: string[];
due_date: string;
duration: number;
/* Array of Appwrite Document IDs */
volunteers: string[];
volunteers_required: number;
status: string;
/* Array of Appwrite Document IDs */
depends_on: string[];
/* Appwrite ID of a Boat resource */
boat?: string[];
}
export interface TaskTag extends Models.Document {
name: string;
description: string;
colour: string;
}
export interface SkillTag extends Models.Document {
name: string;
description: string;
tagColour: string;
}
export const useTaskStore = defineStore('tasks', {
state: () => ({
tasks: [] as Task[],
taskTags: [] as TaskTag[],
skillTags: [] as SkillTag[],
}),
actions: {
async fetchTasks() {
try {
await this.fetchTaskTags();
await this.fetchSkillTags();
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.task
);
this.tasks = response.documents as Task[];
} catch (error) {
console.error('Failed to fetch tasks', error);
}
},
async fetchTaskTags() {
// This is fine for a small number of tags, but more than a few hundred tags, we'd need to optimize
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.taskTags
);
this.taskTags = response.documents as TaskTag[];
} catch (error) {
console.error('Failed to fetch task tags', error);
}
},
async fetchSkillTags() {
// This is fine for a small number of tags, but more than a few hundred tags, we'd need to optimize
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.skillTags
);
this.skillTags = response.documents as SkillTag[];
} catch (error) {
console.error('Failed to fetch skill tags', error);
}
},
async deleteTask(task: Task | string) {
const docId = typeof task === 'string' ? task : task.$id;
if (docId === undefined) {
console.error('No document ID provided to deleteTask!');
return;
}
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.task,
docId
);
this.tasks = this.tasks.filter((task) => docId !== task.$id);
} catch (error) {
// Need some better error handling, here.
console.error('Failed to delete task:', error);
}
},
async addTask(task: Task) {
const newTask = <Models.Document>{ ...task };
try {
const response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.task,
ID.unique(),
newTask
);
this.tasks.push(response as Task);
} catch (error) {
console.error('Failed to add task:', error);
}
},
async updateTask(task: Task) {
const newTask = <Partial<Models.Document>>{
...task,
id: undefined,
$databaseId: undefined,
$collectionId: undefined,
};
if (!task.$id) {
console.error('No Task ID!');
return;
}
try {
const response = await databases.updateDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.task,
task.$id,
newTask
);
this.tasks.push(response as Task);
} catch (error) {
console.error('Failed to update task:', error);
}
},
// TODO: Enhance this store to include offline caching, and subscription notification when items change on the server.
},
// Add more actions as needed (e.g., updateTask, deleteTask)
getters: {
getTaskById: (state) => (id: string) => {
return state.tasks.find((task) => task.$id === id) || null;
},
getTaskTagById: (state) => (id: string) => {
return state.taskTags.find((tag) => tag.$id === id) || null;
},
getSkillById: (state) => (id: string) => {
return state.skillTags.find((tag) => tag.$id === id) || null;
},
filterTasksByTitle: (state) => (searchQuery: string) => {
const result = state.tasks.filter((task) =>
task.title.toLowerCase().includes(searchQuery.toLowerCase())
);
return result;
},
},
});

9
src/utils/misc.ts Normal file
View File

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

90
src/utils/schedule.ts Normal file
View File

@@ -0,0 +1,90 @@
import { date } from 'quasar';
import { Boat } from 'src/stores/boat';
import {
Interval,
IntervalTemplate,
TimeTuple,
} from 'src/stores/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[]
): 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 {
/* When the time zone offset is absent, date-only forms are interpreted
as a UTC time and date-time forms are interpreted as local time. */
const result = {
resource: resource.$id,
start: new Date(blockDate + 'T' + time[0]).toISOString(),
end: new Date(blockDate + 'T' + time[1]).toISOString(),
};
return result;
}
export const isPast = (itemDate: Date | string): boolean => {
if (!(itemDate instanceof Date)) {
itemDate = new Date(itemDate);
}
const currentDate = new Date();
return itemDate < currentDate;
};
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');
}

6
tsconfig.vue-tsc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"skipLibCheck": true
}
}

6092
yarn.lock

File diff suppressed because it is too large Load Diff