Compare commits
181 Commits
39a6ab5fcc
...
v0.7.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
eaae9b7487
|
|||
|
f012025917
|
|||
|
ac65cd683a
|
|||
|
e689e3efd8
|
|||
|
94d3a2716e
|
|||
|
18d9f998f5
|
|||
|
bb3042014e
|
|||
|
6e1f58cd8e
|
|||
|
cc6903a799
|
|||
|
6c4d047bf0
|
|||
|
2874ea3be1
|
|||
|
26bc33a095
|
|||
|
67c7a3c050
|
|||
|
5d08b1c927
|
|||
|
148b8ff49d
|
|||
|
c4113f63a4
|
|||
|
6274e4936d
|
|||
|
e1259688a4
|
|||
|
e2a4dd851d
|
|||
|
2a61cc105f
|
|||
|
d6f58ddabd
|
|||
|
a1d9e147f9
|
|||
|
92bfc7bafa
|
|||
|
6f61edd659
|
|||
|
ea4e848e57
|
|||
|
c08fa6c2d8
|
|||
|
01aae9e8ff
|
|||
|
70c6837858
|
|||
|
6167a713dd
|
|||
|
ab6b909fba
|
|||
|
9fdab2acc9
|
|||
|
68c242ae81
|
|||
|
cb3c1ab05f
|
|||
|
02dae967a2
|
|||
|
77ae081031
|
|||
|
aed60cc0d5
|
|||
|
278c7309b7
|
|||
|
a11b2a0568
|
|||
|
ff8e54449a
|
|||
|
64a59e856f
|
|||
|
5e8c5a1631
|
|||
|
e97949cab3
|
|||
|
b7a3608e67
|
|||
|
bbb544c029
|
|||
|
da42f6ed22
|
|||
|
8016e20451
|
|||
|
64ee8f4fea
|
|||
|
17e8d7dc37
|
|||
|
a409b0a5c7
|
|||
|
6ec4a1e025
|
|||
|
d063b0cf0d
|
|||
|
643d74e29d
|
|||
|
1526a10630
|
|||
|
fc035106d0
|
|||
|
8ae855838b
|
|||
|
9bd10b56d9
|
|||
|
1a78f82c5e
|
|||
|
475ba45248
|
|||
|
2a949d771a
|
|||
|
7fc640d679
|
|||
|
91b54cf791
|
|||
|
27b15a37f7
|
|||
|
947b463fe2
|
|||
|
c3098b073f
|
|||
|
b2420b270c
|
|||
|
9104ccab0f
|
|||
|
387af2e6ce
|
|||
|
6654132120
|
|||
|
59d2729719
|
|||
|
9f398e5509
|
|||
|
2fb236cf97
|
|||
|
7bc0573455
|
|||
|
68a2b8ffff
|
|||
|
ce696a5a04
|
|||
|
b0d6ec877b
|
|||
|
c03ad48615
|
|||
|
55bc1acbb3
|
|||
|
cd692a6f3b
|
|||
|
737de91bbc
|
|||
|
a6e357f973
|
|||
|
76b0498a18
|
|||
|
d6339815aa
|
|||
|
97481a5d2e
|
|||
|
369bbc4960
|
|||
|
c3ee739366
|
|||
|
adc34a116b
|
|||
|
b506ab7ca9
|
|||
|
dd631b71bb
|
|||
|
b0921ccf32
|
|||
|
78211a33ae
|
|||
|
4a273ccb2f
|
|||
|
3a67f2fbb1
|
|||
|
77619b0741
|
|||
|
ea785887a1
|
|||
|
b860e1d977
|
|||
|
274d0193f7
|
|||
|
033993b1b8
|
|||
|
2872fb867e
|
|||
|
8e73650462
|
|||
|
634cff507c
|
|||
|
fa4d83e42d
|
|||
|
c92f737612
|
|||
|
5792e80112
|
|||
|
db0755a368
|
|||
|
2b61d57a8a
|
|||
|
29f9aeaba4
|
|||
|
28600578f1
|
|||
|
b66afb5692
|
|||
|
2f68877ce6
|
|||
|
0de9991a49
|
|||
|
4faff7cc8c
|
|||
|
c297f1f287
|
|||
|
43e68c8ae7
|
|||
|
e1a784ef45
|
|||
|
d9cfa4ab56
|
|||
|
cb2131ae7e
|
|||
|
de04b53914
|
|||
|
1a18881980
|
|||
|
84867875c5
|
|||
|
ea0bc82c49
|
|||
|
15ef8435f6
|
|||
|
4c2cae7149
|
|||
|
ffaf31bbeb
|
|||
|
6ab1aa26b1
|
|||
|
5d9dbb0653
|
|||
|
299ede4aa9
|
|||
|
b91ba39d06
|
|||
|
8464701082
|
|||
|
b3ce8e59cb
|
|||
|
55071318ca
|
|||
|
b66b63101f
|
|||
|
9db1b4d97c
|
|||
|
71a8c2e8d2
|
|||
|
88738715b6
|
|||
|
53c650d4b0
|
|||
|
deb6a0b8ed
|
|||
|
923d09d713
|
|||
|
d752898865
|
|||
|
435438aaa8
|
|||
|
084aadccef
|
|||
|
468569fa27
|
|||
|
0986d04ea6
|
|||
|
6ff1a69e2b
|
|||
|
052cae2c2e
|
|||
|
29170f9e13
|
|||
|
25ed6df62a
|
|||
|
2f86700fb7
|
|||
|
e7a79736b7
|
|||
|
2d585d499e
|
|||
|
284d5ffcb4
|
|||
|
27a476ae00
|
|||
|
ee7f79550c
|
|||
|
2ef801905b
|
|||
|
752421c9fc
|
|||
|
ce169f6a61
|
|||
|
622b9fc82d
|
|||
|
275f23c421
|
|||
|
88ed4caf5b
|
|||
|
346e395e15
|
|||
|
f30848803b
|
|||
|
96dab93483
|
|||
|
a6abee1ddf
|
|||
|
b20f2bffd6
|
|||
|
f6689cbc5c
|
|||
|
8383605115
|
|||
|
f69614d5c7
|
|||
|
f7902011cc
|
|||
|
e86876ba69
|
|||
|
cd6f2e3ba2
|
|||
|
66e2169f45
|
|||
|
489cc2646b
|
|||
|
295f1f7449
|
|||
|
33a1bc24f6
|
|||
|
d18780bb21
|
|||
|
ef569ac3b1
|
|||
|
9390b7035c
|
|||
|
ac1730401a
|
|||
|
bc41b1a7a1
|
|||
|
ea566d4a42
|
|||
|
573e327a0f
|
|||
|
831e81e892
|
8
.claude/commands/handoff.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Write a session handoff file for the current session.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Read `templates/claude-templates.md` and find the Session Handoff template (Template 4). Use the Light Handoff if this is a small project (under 5 sessions), Full Handoff otherwise.
|
||||||
|
2. Fill in every field based on what was accomplished in this session. Be specific — include exact file paths for every output, exact numbers discovered, and conditional logic established.
|
||||||
|
3. Write the handoff to `./docs/summaries/handoff-[today's date]-[topic].md`.
|
||||||
|
4. If a previous handoff file exists in `./docs/summaries/`, move it to `./docs/archive/handoffs/`.
|
||||||
|
5. Tell me the file path of the new handoff and summarize what it contains.
|
||||||
13
.claude/commands/process-doc.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
Process an input document into a structured source summary.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Read `templates/claude-templates.md` and find the Source Document Summary template (Template 1). Use the Light Source Summary if this is a small project (under 5 sessions), Full Source Summary otherwise.
|
||||||
|
2. Read the document at: $ARGUMENTS
|
||||||
|
3. Extract all information into the template format. Pay special attention to:
|
||||||
|
- EXACT numbers — do not round or paraphrase
|
||||||
|
- Requirements in IF/THEN/BUT/EXCEPT format
|
||||||
|
- Decisions with rationale and rejected alternatives
|
||||||
|
- Open questions marked as OPEN, ASSUMED, or MISSING
|
||||||
|
4. Write the summary to `./docs/summaries/source-[filename].md`.
|
||||||
|
5. Move the original document to `./docs/archive/`.
|
||||||
|
6. Tell me: what was extracted, what's unclear, and what needs follow-up.
|
||||||
13
.claude/commands/status.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
Report on the current project state.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Read `./docs/summaries/00-project-brief.md` for project context.
|
||||||
|
2. Find and read the latest `handoff-*.md` file in `./docs/summaries/` for current state.
|
||||||
|
3. List all files in `./docs/summaries/` to understand what's been processed.
|
||||||
|
4. Report:
|
||||||
|
- **Project:** name and type from the project brief
|
||||||
|
- **Current phase:** based on the project phase tracker
|
||||||
|
- **Last session:** what was accomplished (from the latest handoff)
|
||||||
|
- **Next steps:** what the next session should do (from the latest handoff)
|
||||||
|
- **Open questions:** anything unresolved
|
||||||
|
- **Summary file count:** how many files in docs/summaries/ (warn if approaching 15)
|
||||||
@@ -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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/dist
|
|
||||||
/src-capacitor
|
|
||||||
/src-cordova
|
|
||||||
/.quasar
|
|
||||||
/node_modules
|
|
||||||
.eslintrc.js
|
|
||||||
/src-ssr
|
|
||||||
/quasar.config.*.temporary.compiled*
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
// https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy
|
|
||||||
// This option interrupts the configuration hierarchy at this file
|
|
||||||
// Remove this if you have an higher level ESLint config file (it usually happens into a monorepos)
|
|
||||||
root: true,
|
|
||||||
|
|
||||||
// https://eslint.vuejs.org/user-guide/#how-to-use-a-custom-parser
|
|
||||||
// Must use parserOptions instead of "parser" to allow vue-eslint-parser to keep working
|
|
||||||
// `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted
|
|
||||||
parserOptions: {
|
|
||||||
parser: require.resolve('@typescript-eslint/parser'),
|
|
||||||
extraFileExtensions: [ '.vue' ]
|
|
||||||
},
|
|
||||||
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
es2021: true,
|
|
||||||
node: true,
|
|
||||||
'vue/setup-compiler-macros': true
|
|
||||||
},
|
|
||||||
|
|
||||||
// Rules order is important, please avoid shuffling them
|
|
||||||
extends: [
|
|
||||||
// Base ESLint recommended rules
|
|
||||||
// 'eslint:recommended',
|
|
||||||
|
|
||||||
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#usage
|
|
||||||
// ESLint typescript rules
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
|
|
||||||
// Uncomment any of the lines below to choose desired strictness,
|
|
||||||
// but leave only one uncommented!
|
|
||||||
// See https://eslint.vuejs.org/rules/#available-rules
|
|
||||||
'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention)
|
|
||||||
// 'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
|
|
||||||
// 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
|
|
||||||
|
|
||||||
// https://github.com/prettier/eslint-config-prettier#installation
|
|
||||||
// usage with Prettier, provided by 'eslint-config-prettier'.
|
|
||||||
'prettier'
|
|
||||||
],
|
|
||||||
|
|
||||||
plugins: [
|
|
||||||
// required to apply rules which need type information
|
|
||||||
'@typescript-eslint',
|
|
||||||
|
|
||||||
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files
|
|
||||||
// required to lint *.vue files
|
|
||||||
'vue'
|
|
||||||
|
|
||||||
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
|
|
||||||
// Prettier has not been included as plugin to avoid performance impact
|
|
||||||
// add it as an extension for your IDE
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
globals: {
|
|
||||||
ga: 'readonly', // Google Analytics
|
|
||||||
cordova: 'readonly',
|
|
||||||
__statics: 'readonly',
|
|
||||||
__QUASAR_SSR__: 'readonly',
|
|
||||||
__QUASAR_SSR_SERVER__: 'readonly',
|
|
||||||
__QUASAR_SSR_CLIENT__: 'readonly',
|
|
||||||
__QUASAR_SSR_PWA__: 'readonly',
|
|
||||||
process: 'readonly',
|
|
||||||
Capacitor: 'readonly',
|
|
||||||
chrome: 'readonly'
|
|
||||||
},
|
|
||||||
|
|
||||||
// add your custom rules here
|
|
||||||
rules: {
|
|
||||||
|
|
||||||
'prefer-promise-reject-errors': 'off',
|
|
||||||
|
|
||||||
quotes: ['warn', 'single', { avoidEscape: true }],
|
|
||||||
|
|
||||||
// this rule, if on, would require explicit return type on the `render` function
|
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
||||||
|
|
||||||
// in plain CommonJS modules, you can't use `import foo = require('foo')` to pass this rule, so it has to be disabled
|
|
||||||
'@typescript-eslint/no-var-requires': 'off',
|
|
||||||
|
|
||||||
// The core 'no-unused-vars' rules (in the eslint:recommended ruleset)
|
|
||||||
// does not work with type definitions
|
|
||||||
'no-unused-vars': 'off',
|
|
||||||
|
|
||||||
// allow debugger during development only
|
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,67 @@
|
|||||||
name: Build BAB Application Deployment Artifact
|
name: Build BAB Application Deployment Artifact
|
||||||
run-name: ${{ gitea.actor }} is building an artifact 🚀
|
run-name: ${{ gitea.actor }} is building a BAB App artifact 🚀
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- alpha
|
||||||
|
- devel
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-18.04
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Enable Corepack and Yarn
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare yarn@stable --activate
|
||||||
|
|
||||||
|
- name: Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-modules-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: yarn install --immutable
|
||||||
- name: Install yarn
|
|
||||||
run: npm install --global yarn
|
- name: Create env file
|
||||||
- name: Install Quasar CLI
|
run: echo "${{ vars.ENV_FILE }}" > .env
|
||||||
run: npm install -g @quasar/cli
|
|
||||||
- name: Build Project
|
- name: Show env file
|
||||||
run: quasar build -m pwa
|
run: cat .env
|
||||||
- name: Get Version Number
|
|
||||||
id: get_version
|
- name: Build and Release
|
||||||
run: echo "::set-output name=VERSION::$(node -p "require('./package.json').version")"
|
id: build
|
||||||
- name: Create Tarball of Build
|
run: yarn semantic-release
|
||||||
run: tar -czvf build-${{ steps.get_version.outputs.VERSION }}.tar.gz dist/pwa
|
env:
|
||||||
- name: Upload Tarball
|
GITEA_TOKEN: ${{ secrets.GT_TOKEN }}
|
||||||
uses: actions/upload-artifact@v3
|
GITEA_URL: ${{ vars.GT_URL }}
|
||||||
|
|
||||||
|
- name: Trigger Ansible Deploy Playbook
|
||||||
|
if: steps.build.outputs.VERSION != ''
|
||||||
|
uses: https://github.com/distributhor/workflow-webhook@v3
|
||||||
with:
|
with:
|
||||||
name: build-artifact-${{ steps.get_version.outputs.VERSION }}
|
webhook_url: ${{ vars.WEBHOOK_URL }}
|
||||||
path: build-${{ steps.get_version.outputs.VERSION }}.tar.gz
|
webhook_auth_type: bearer
|
||||||
|
webhook_auth: ${{ secrets.WEBHOOK_SECRET }}
|
||||||
|
verbose: true
|
||||||
|
data: >
|
||||||
|
{
|
||||||
|
"artifact_url":
|
||||||
|
"${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/v${{ steps.build.outputs.VERSION }}/release-${{ steps.build.outputs.VERSION }}.tar.gz"
|
||||||
|
}
|
||||||
|
|||||||
59
.gitignore
vendored
@@ -1,37 +1,40 @@
|
|||||||
.DS_Store
|
# Nuxt dev/build outputs
|
||||||
.thumbs.db
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
# Quasar core related directories
|
# Logs
|
||||||
.quasar
|
logs
|
||||||
/dist
|
*.log
|
||||||
/quasar.config.*.temporary.compiled*
|
|
||||||
|
|
||||||
# Cordova related directories and files
|
# Misc
|
||||||
/src-cordova/node_modules
|
.DS_Store
|
||||||
/src-cordova/platforms
|
.thumbs.db
|
||||||
/src-cordova/plugins
|
.fleet
|
||||||
/src-cordova/www
|
|
||||||
|
|
||||||
# Capacitor related directories and files
|
|
||||||
/src-capacitor/www
|
|
||||||
/src-capacitor/node_modules
|
|
||||||
|
|
||||||
# BEX related directories and files
|
|
||||||
/src-bex/www
|
|
||||||
/src-bex/js/core
|
|
||||||
|
|
||||||
# Log files
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.idea
|
.idea
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
|
|
||||||
# local .env files
|
# Local env files
|
||||||
.env.local*
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Yarn 4
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# Release artifacts
|
||||||
|
release-*.gz
|
||||||
|
CHANGELOG.md
|
||||||
|
VERSION
|
||||||
|
|||||||
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
|||||||
|
yarn typecheck
|
||||||
3
.npmrc
@@ -1,3 +0,0 @@
|
|||||||
# pnpm-related options
|
|
||||||
shamefully-hoist=true
|
|
||||||
strict-peer-dependencies=false
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"semi": true
|
|
||||||
}
|
|
||||||
27
.releaserc.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"branches": [
|
||||||
|
"main",
|
||||||
|
"next",
|
||||||
|
{ "name": "beta", "prerelease": true },
|
||||||
|
{ "name": "devel", "prerelease": true },
|
||||||
|
{ "name": "alpha", "prerelease": true }
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
"@semantic-release/changelog",
|
||||||
|
[
|
||||||
|
"@semantic-release/exec",
|
||||||
|
{
|
||||||
|
"prepareCmd": "node generate-version.cjs '${nextRelease.version}' && yarn install --immutable && yarn generate",
|
||||||
|
"publishCmd": "tar -czvf release-${nextRelease.version}.tar.gz -C .output/public . && echo \"VERSION=${nextRelease.version}\" >> \"$GITHUB_OUTPUT\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@saithodev/semantic-release-gitea",
|
||||||
|
{
|
||||||
|
"assets": ["release-${nextRelease.version}.tar.gz"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
940
.yarn/releases/yarn-4.13.0.cjs
vendored
Executable file
3
.yarnrc.yml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||||
51
CLAUDE.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Session Start
|
||||||
|
|
||||||
|
Read the latest handoff in docs/summaries/ if one exists. Load only the files that handoff references — not all summaries. If no handoff exists, ask: what is the project, what type of work, what is the target deliverable.
|
||||||
|
|
||||||
|
Before starting work, state: what you understand the project state to be, what you plan to do this session, and any open questions.
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
You work with Patrick, a Solutions Architect, on the OYS Borrow a Boat (bab-app) project — a Quasar/Vue 3 app for managing a Borrow a Boat program for a Yacht Club. Backend is Appwrite.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
- **App**: OYS Borrow a Boat (oys_bab)
|
||||||
|
- **Stack**: Quasar (Vue 3), TypeScript, Appwrite (BaaS)
|
||||||
|
- **Purpose**: Manage a Borrow a Boat program for a Yacht Club
|
||||||
|
- **Docs**: docs/planning/ contains personas, user/role/permission model, and time-based logic
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. Do not mix unrelated project contexts in one session.
|
||||||
|
2. Write state to disk, not conversation. After completing meaningful work, write a summary to docs/summaries/ using templates from templates/claude-templates.md. Include: decisions with rationale, exact numbers, file paths, open items.
|
||||||
|
3. Before compaction or session end, write to disk: every number, every decision with rationale, every open question, every file path, exact next action.
|
||||||
|
4. When switching work types (research → writing → review), write a handoff to docs/summaries/handoff-[date]-[topic].md and suggest a new session.
|
||||||
|
5. Do not silently resolve open questions. Mark them OPEN or ASSUMED.
|
||||||
|
6. Do not bulk-read documents. Process one at a time: read, summarize to disk, release from context before reading next. For the detailed protocol, read docs/context/processing-protocol.md.
|
||||||
|
7. Sub-agent returns must be structured, not free-form prose. Use output contracts from templates/claude-templates.md.
|
||||||
|
|
||||||
|
## Where Things Live
|
||||||
|
|
||||||
|
- templates/claude-templates.md — summary, handoff, decision, analysis, task, output contract templates (read on demand)
|
||||||
|
- docs/summaries/ — active session state (latest handoff + project brief + decision records + source summaries)
|
||||||
|
- docs/context/ — reusable domain knowledge, loaded only when relevant to the current task
|
||||||
|
- processing-protocol.md — full document processing steps
|
||||||
|
- archive-rules.md — summary lifecycle and file archival rules
|
||||||
|
- subagent-rules.md — rules for structured sub-agent outputs
|
||||||
|
- docs/planning/ — original planning documents (personas, roles/permissions, time logic)
|
||||||
|
- docs/archive/ — processed raw files. Do not read unless explicitly told.
|
||||||
|
- output/deliverables/ — final outputs
|
||||||
|
- src/ — Quasar/Vue app source
|
||||||
|
- src-pwa/ — PWA config
|
||||||
|
- appwrite.json — Appwrite project config
|
||||||
|
|
||||||
|
## Error Recovery
|
||||||
|
|
||||||
|
If context degrades or auto-compact fires unexpectedly: write current state to docs/summaries/recovery-[date].md, tell the user what may have been lost, suggest a fresh session.
|
||||||
|
|
||||||
|
## Before Delivering Output
|
||||||
|
|
||||||
|
Verify: exact numbers preserved, open questions marked OPEN, output matches what was requested (not assumed), claims backed by specific data, output consistent with stored decisions in docs/context/, summary written to disk for this session's work.
|
||||||
@@ -41,3 +41,7 @@ quasar build
|
|||||||
### Customize the configuration
|
### Customize the configuration
|
||||||
|
|
||||||
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
|
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
|
||||||
|
|
||||||
|
### TODO
|
||||||
|
|
||||||
|
https://github.com/semantic-release/semantic-release
|
||||||
|
|||||||
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
BIN
app/assets/OYS-Burgee_square.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
BIN
app/assets/oysqn_logo_only_bordered.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
227
app/components/BoatReservationComponent.vue
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useAuthStore } from '~/stores/auth';
|
||||||
|
import { useBoatStore } from '~/stores/boat';
|
||||||
|
import type { Boat } from '~/utils/boat.types';
|
||||||
|
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||||
|
import BoatScheduleTableComponent from '~/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
||||||
|
import { formatDate } from '~/utils/schedule';
|
||||||
|
import { useReservationStore } from '~/stores/reservation';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
|
||||||
|
interface BookingForm {
|
||||||
|
$id?: string;
|
||||||
|
user?: string;
|
||||||
|
interval?: Interval | null;
|
||||||
|
reason?: string;
|
||||||
|
members?: string[];
|
||||||
|
guests?: string[];
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason_options = ['Open Sail', 'Private Sail', 'Racing', 'Other'];
|
||||||
|
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const newForm = {
|
||||||
|
user: auth.currentUser?.$id,
|
||||||
|
interval: {} as Interval,
|
||||||
|
reason: 'Open Sail',
|
||||||
|
members: [],
|
||||||
|
guests: [],
|
||||||
|
comment: '',
|
||||||
|
};
|
||||||
|
const reservation = defineModel<Reservation>();
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
const boatSelect = ref(false);
|
||||||
|
const bookingForm = ref<BookingForm>({ ...newForm });
|
||||||
|
const $q = useQuasar();
|
||||||
|
const $router = useRouter();
|
||||||
|
|
||||||
|
watch(reservation, (newReservation) => {
|
||||||
|
if (!newReservation) {
|
||||||
|
bookingForm.value = newForm;
|
||||||
|
} else {
|
||||||
|
bookingForm.value = {
|
||||||
|
...newReservation,
|
||||||
|
user: auth.currentUser?.$id,
|
||||||
|
interval: {
|
||||||
|
start: newReservation.start,
|
||||||
|
end: newReservation.end,
|
||||||
|
resource: newReservation.resource,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateInterval = (interval: Interval | null | undefined) => {
|
||||||
|
bookingForm.value.interval = interval;
|
||||||
|
boatSelect.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookingDuration = computed((): { hours: number; minutes: number } => {
|
||||||
|
if (bookingForm.value.interval?.start && bookingForm.value.interval?.end) {
|
||||||
|
const start = new Date(bookingForm.value.interval.start).getTime();
|
||||||
|
const end = new Date(bookingForm.value.interval.end).getTime();
|
||||||
|
const delta = Math.abs(end - start) / 1000;
|
||||||
|
const hours = Math.floor(delta / 3600) % 24;
|
||||||
|
const minutes = Math.floor(delta - hours * 3600) % 60;
|
||||||
|
return { hours, minutes };
|
||||||
|
}
|
||||||
|
return { hours: 0, minutes: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingName = computed(() => auth.getUserNameById(bookingForm.value?.user));
|
||||||
|
|
||||||
|
const boat = computed((): Boat | null => {
|
||||||
|
const boatId = bookingForm.value.interval?.resource;
|
||||||
|
return boatStore.getBoatById(boatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
reservationStore.deleteReservation(reservation.value?.$id);
|
||||||
|
$router.go(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
bookingForm.value.interval = null;
|
||||||
|
bookingForm.value = reservation.value
|
||||||
|
? {
|
||||||
|
...reservation.value,
|
||||||
|
interval: {
|
||||||
|
start: reservation.value.start,
|
||||||
|
end: reservation.value.end,
|
||||||
|
resource: reservation.value.resource,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { ...newForm };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
const booking = bookingForm.value;
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
booking.interval &&
|
||||||
|
booking.interval.resource &&
|
||||||
|
booking.interval.start &&
|
||||||
|
booking.interval.end &&
|
||||||
|
auth.currentUser
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const newReservation = <Reservation>{
|
||||||
|
resource: booking.interval.resource,
|
||||||
|
start: booking.interval.start,
|
||||||
|
end: booking.interval.end,
|
||||||
|
user: auth.currentUser.$id,
|
||||||
|
status: 'confirmed',
|
||||||
|
reason: booking.reason,
|
||||||
|
comment: booking.comment,
|
||||||
|
$id: reservation.value?.$id,
|
||||||
|
};
|
||||||
|
const status = $q.notify({
|
||||||
|
color: 'secondary',
|
||||||
|
textColor: 'white',
|
||||||
|
message: 'Submitting Reservation',
|
||||||
|
spinner: true,
|
||||||
|
closeBtn: 'Dismiss',
|
||||||
|
position: 'top',
|
||||||
|
timeout: 0,
|
||||||
|
group: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const r = await reservationStore.createOrUpdateReservation(newReservation);
|
||||||
|
status({
|
||||||
|
color: 'positive',
|
||||||
|
icon: 'cloud_done',
|
||||||
|
message: `Booking ${newReservation.$id ? 'updated' : 'created'}: ${
|
||||||
|
boatStore.getBoatById(r.resource)?.name
|
||||||
|
} at ${formatDate(r.start)}`,
|
||||||
|
spinner: false,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
status({ color: 'negative', icon: 'error', spinner: false, message: 'Failed to book!' + e });
|
||||||
|
}
|
||||||
|
$router.go(-1);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="q-pa-xs row q-gutter-xs">
|
||||||
|
<q-card flat class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h5 q-mt-none q-mb-xs">
|
||||||
|
{{ reservation ? 'Modify Booking' : 'New Booking' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-list class="q-px-xs">
|
||||||
|
<q-item class="q-pa-none" clickable @click="boatSelect = true">
|
||||||
|
<q-card v-if="boat" class="col-12">
|
||||||
|
<q-card-section>
|
||||||
|
<q-img :src="boat.imgSrc" :fit="'scale-down'">
|
||||||
|
<div class="row absolute-top">
|
||||||
|
<div class="col text-h7 text-left">{{ boat.name }}</div>
|
||||||
|
<div class="col text-right text-caption">{{ boat.class }}</div>
|
||||||
|
</div>
|
||||||
|
</q-img>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
<q-card-section horizontal>
|
||||||
|
<q-card-section class="col-9">
|
||||||
|
<q-list dense class="row">
|
||||||
|
<q-item class="q-ma-none col-12">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-badge color="primary" label="Start" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section class="text-body2">
|
||||||
|
{{ formatDate(bookingForm.interval?.start) }}
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item class="q-ma-none col-12">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-badge color="primary" label="End" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section class="text-body2" style="min-width: 150px">
|
||||||
|
{{ formatDate(bookingForm.interval?.end) }}
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator vertical />
|
||||||
|
<q-card-section class="col-3 flex flex-center bg-grey-4">
|
||||||
|
{{ bookingDuration.hours }} hours
|
||||||
|
<div v-if="bookingDuration.minutes">
|
||||||
|
<q-separator />
|
||||||
|
{{ bookingDuration.minutes }} mins
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<div v-else class="col-12">
|
||||||
|
<q-field filled>Tap to Select a Boat / Time</q-field>
|
||||||
|
</div>
|
||||||
|
</q-item>
|
||||||
|
<q-item class="q-px-none">
|
||||||
|
<q-item-section>
|
||||||
|
<q-select filled v-model="bookingForm.reason" :options="reason_options" label="Reason for sail" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item class="q-px-none">
|
||||||
|
<q-item-section>
|
||||||
|
<q-input v-model="bookingForm.comment" clearable autogrow filled label="Additional Comments (optional)" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn label="Delete" color="negative" size="lg" v-if="reservation?.$id" @click="onDelete" />
|
||||||
|
<q-btn label="Reset" @click="onReset" size="lg" color="secondary" />
|
||||||
|
<q-btn label="Submit" @click="onSubmit" size="lg" color="primary" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
<q-dialog v-model="boatSelect" full-width>
|
||||||
|
<BoatScheduleTableComponent :model-value="bookingForm.interval" @update:model-value="updateInterval" />
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
app/components/BottomNavComponent.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<q-tabs class="mobile-only">
|
||||||
|
<q-route-tab name="Boats" icon="sailing" to="/boat" />
|
||||||
|
<q-route-tab name="Schedule" icon="calendar_month" to="/schedule" />
|
||||||
|
</q-tabs>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts"></script>
|
||||||
35
app/components/CertificationComponent.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const certifications = [
|
||||||
|
{ title: 'J/27 Skipper', badgeText: 'J/27', description: 'Certified to be a skipper on a J/27 class boat.' },
|
||||||
|
{ title: 'Capri 25 Skipper', badgeText: 'Capri25', description: 'Certified to be a skipper on a Capri 25 class boat.' },
|
||||||
|
{ title: 'Night', badgeText: 'Night', description: 'Certified to operate boats at night' },
|
||||||
|
{ title: 'Navigation', badgeText: 'Nav', description: 'Advanced Navigation' },
|
||||||
|
{ title: 'Crew', badgeText: 'crew', description: 'Crew certification.' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>Certification</div>
|
||||||
|
<q-item
|
||||||
|
v-for="cert in certifications"
|
||||||
|
:key="cert.title"
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
class="rounded-borders"
|
||||||
|
:class="$q.dark.isActive ? 'bg-grey-9 text-white' : 'bg-grey-2'">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar rounded>
|
||||||
|
<q-icon :name="`check`" />
|
||||||
|
</q-avatar>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ cert.title }}</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<q-badge color="green-4" text-color="black">{{ cert.badgeText }}</q-badge>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<span>{{ cert.description }}</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
78
app/components/LeftDrawer.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Dialog } from 'quasar';
|
||||||
|
import { useNavLinks } from '~/utils/navlinks';
|
||||||
|
import { useAuthStore } from '~/stores/auth';
|
||||||
|
import { APP_VERSION } from '~/utils/version';
|
||||||
|
|
||||||
|
defineProps(['drawer']);
|
||||||
|
defineEmits(['drawer-toggle']);
|
||||||
|
|
||||||
|
const { enabledLinks } = useNavLinks();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
function showAbout() {
|
||||||
|
Dialog.create({
|
||||||
|
title: 'OYS Borrow a Boat',
|
||||||
|
message: `Version ${APP_VERSION}<br>Manage a Borrow a Boat program for a Yacht Club.<br><br>© Oakville Yacht Squadron`,
|
||||||
|
html: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await authStore.logout();
|
||||||
|
await navigateTo('/login');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-drawer
|
||||||
|
:model-value="drawer"
|
||||||
|
show-if-above
|
||||||
|
:width="200"
|
||||||
|
:breakpoint="1024"
|
||||||
|
@update:model-value="$emit('drawer-toggle')">
|
||||||
|
<q-scroll-area class="fit">
|
||||||
|
<q-list padding class="menu-list">
|
||||||
|
<template v-for="link in enabledLinks" :key="link.name">
|
||||||
|
<q-item clickable v-ripple :to="link.to">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="link.icon" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<span :class="link.color ? `text-${link.color}` : ''">
|
||||||
|
{{ link.name }}
|
||||||
|
</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-list v-if="link.sublinks">
|
||||||
|
<div v-for="sublink in link.sublinks" :key="sublink.name">
|
||||||
|
<q-item clickable v-ripple :to="sublink.to" class="q-ml-md">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="sublink.icon" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<span :class="sublink.color ? `text-${sublink.color}` : ''">
|
||||||
|
{{ sublink.name }}
|
||||||
|
</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
</q-list>
|
||||||
|
</template>
|
||||||
|
<q-item clickable v-ripple @click="showAbout()">
|
||||||
|
<q-item-section avatar><q-icon name="info" /></q-item-section>
|
||||||
|
<q-item-section>About</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable v-ripple @click="logout()">
|
||||||
|
<q-item-section avatar><q-icon name="logout" /></q-item-section>
|
||||||
|
<q-item-section>Logout</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-scroll-area>
|
||||||
|
</q-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
.menu-list .q-item
|
||||||
|
border-radius: 0 32px 32px 0
|
||||||
|
</style>
|
||||||
@@ -1,50 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ReferenceEntry } from '~/stores/reference';
|
||||||
|
|
||||||
|
defineProps({ entries: Array<ReferenceEntry> });
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-card
|
<q-card flat bordered class="my-card" v-for="entry in entries" :key="entry.title">
|
||||||
flat
|
|
||||||
bordered
|
|
||||||
class="my-card"
|
|
||||||
v-for="entry in entries"
|
|
||||||
:key="entry.title"
|
|
||||||
>
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row items-center no-wrap">
|
<div class="row items-center no-wrap">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="text-h6">{{ entry.title }}</div>
|
<div class="text-h6">{{ entry.title }}</div>
|
||||||
<div class="text-subtitle2">{{ entry.subtitle }}</div>
|
<div class="text-subtitle2">{{ entry.subtitle }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<q-btn color="grey-7" round flat icon="more_vert">
|
<q-btn color="grey-7" round flat icon="more_vert">
|
||||||
<q-menu cover auto-close>
|
<q-menu cover auto-close>
|
||||||
<q-list>
|
<q-list>
|
||||||
<q-item clickable>
|
<q-item clickable><q-item-section>Remove Card</q-item-section></q-item>
|
||||||
<q-item-section>Remove Card</q-item-section>
|
<q-item clickable><q-item-section>Send Feedback</q-item-section></q-item>
|
||||||
</q-item>
|
<q-item clickable><q-item-section>Share</q-item-section></q-item>
|
||||||
<q-item clickable>
|
|
||||||
<q-item-section>Send Feedback</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item clickable>
|
|
||||||
<q-item-section>Share</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-menu>
|
</q-menu>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-separator />
|
<q-separator />
|
||||||
|
|
||||||
<q-card-actions>
|
<q-card-actions>
|
||||||
<q-btn flat :to="'reference/' + entry.id + '/view'">Read</q-btn>
|
<q-btn flat :to="'reference/' + entry.id + '/view'">Read</q-btn>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ReferenceEntry } from 'src/stores/reference';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
entries: Array<ReferenceEntry>,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
168
app/components/ResourceScheduleViewerComponent.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<!-- Abandoned: superseded by block-based booking. Retained for future reference. -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { TimestampOrNull, Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import {
|
||||||
|
QCalendarResource,
|
||||||
|
QCalendarMonth,
|
||||||
|
today,
|
||||||
|
parseTimestamp,
|
||||||
|
addToDate,
|
||||||
|
parsed,
|
||||||
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { useBoatStore } from '~/stores/boat';
|
||||||
|
import type { Boat } from '~/utils/boat.types';
|
||||||
|
import { useReservationStore } from '~/stores/reservation';
|
||||||
|
import { date } from 'quasar';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { StatusTypes } from '~/utils/schedule.types';
|
||||||
|
import { useIntervalStore } from '~/stores/interval';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
interface EventData {
|
||||||
|
event: object;
|
||||||
|
scope: { timestamp: object; columnindex: number; activeDate: boolean; droppable: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
|
||||||
|
|
||||||
|
interface ResourceIntervalScope {
|
||||||
|
resource: Boat;
|
||||||
|
intervals: [];
|
||||||
|
timeStartPosX(start: TimestampOrNull): number;
|
||||||
|
timeDurationWidth(duration: number): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLookup = {
|
||||||
|
confirmed: ['#14539a', 'white'],
|
||||||
|
pending: ['#f2c037', 'white'],
|
||||||
|
tentative: ['white', 'grey'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendar = ref();
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||||
|
const duration = ref(1);
|
||||||
|
|
||||||
|
const formattedMonth = computed(() => {
|
||||||
|
const d = new Date(selectedDate.value);
|
||||||
|
return monthFormatter()?.format(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabledBefore = computed(() => {
|
||||||
|
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||||
|
return addToDate(todayTs, { day: -1 }).date;
|
||||||
|
});
|
||||||
|
|
||||||
|
function monthFormatter() {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', { month: 'long', timeZone: 'UTC' });
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEvents(scope: ResourceIntervalScope) {
|
||||||
|
const resourceEvents = reservationStore.getReservationsByDate(selectedDate.value, scope.resource.$id);
|
||||||
|
return resourceEvents.value.map((event) => ({
|
||||||
|
left: scope.timeStartPosX(parsed(event.start)),
|
||||||
|
width: scope.timeDurationWidth(date.getDateDiff(event.end, event.start, 'minutes')),
|
||||||
|
title: event.user,
|
||||||
|
status: event.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyle(event: { left: number; width: number; title: string; status: StatusTypes }) {
|
||||||
|
return {
|
||||||
|
position: 'absolute',
|
||||||
|
background: event.status ? statusLookup[event.status][0] : 'white',
|
||||||
|
color: event.status ? statusLookup[event.status][1] : '#14539a',
|
||||||
|
left: `${event.left}px`,
|
||||||
|
width: `${event.width}px`,
|
||||||
|
height: '32px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['onClickTime', 'onUpdateDuration']);
|
||||||
|
|
||||||
|
function onPrev() { calendar.value.prev(); }
|
||||||
|
function onNext() { calendar.value.next(); }
|
||||||
|
function onClickDate(data: EventData) { return data; }
|
||||||
|
function onClickTime(data: EventData) { emit('onClickTime', data); }
|
||||||
|
function onUpdateDuration(value: EventData) { emit('onUpdateDuration', value); }
|
||||||
|
const onClickInterval = () => {};
|
||||||
|
const onClickHeadResources = () => {};
|
||||||
|
const onClickResource = () => {};
|
||||||
|
const onResourceExpanded = () => {};
|
||||||
|
const onMoved = () => {};
|
||||||
|
const onChange = () => {};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-caption text-justify">
|
||||||
|
Use the calendar to pick a date. Tap a box in the grid for the boat and start time.
|
||||||
|
</div>
|
||||||
|
<div style="width: 100%; display: flex; justify-content: center">
|
||||||
|
<div style="width: 50%; max-width: 350px; display: flex; justify-content: space-between">
|
||||||
|
<span class="q-button" style="cursor: pointer; user-select: none" @click="onPrev"><</span>
|
||||||
|
{{ formattedMonth }}
|
||||||
|
<span class="q-button" style="cursor: pointer; user-select: none" @click="onNext">></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: center; align-items: center; flex-wrap: nowrap">
|
||||||
|
<div style="display: flex; width: 100%">
|
||||||
|
<q-calendar-month
|
||||||
|
ref="calendar"
|
||||||
|
v-model="selectedDate"
|
||||||
|
:disabled-before="disabledBefore"
|
||||||
|
animated
|
||||||
|
bordered
|
||||||
|
mini-mode
|
||||||
|
date-type="rounded"
|
||||||
|
@change="onChange"
|
||||||
|
@moved="onMoved"
|
||||||
|
@click-date="onClickDate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-calendar-resource
|
||||||
|
v-model="selectedDate"
|
||||||
|
:model-resources="boatStore.boats"
|
||||||
|
resource-key="id"
|
||||||
|
resource-label="displayName"
|
||||||
|
resource-width="32"
|
||||||
|
:interval-start="6"
|
||||||
|
:interval-count="18"
|
||||||
|
:interval-minutes="60"
|
||||||
|
cell-width="48"
|
||||||
|
style="--calendar-resources-width: 48px"
|
||||||
|
resource-min-height="40"
|
||||||
|
animated
|
||||||
|
bordered
|
||||||
|
@change="onChange"
|
||||||
|
@moved="onMoved"
|
||||||
|
@resource-expanded="onResourceExpanded"
|
||||||
|
@click-date="onClickDate"
|
||||||
|
@click-time="onClickTime"
|
||||||
|
@click-resource="onClickResource"
|
||||||
|
@click-head-resources="onClickHeadResources"
|
||||||
|
@click-interval="onClickInterval">
|
||||||
|
<template #resource-intervals="{ scope }">
|
||||||
|
<template v-for="(event, index) in getEvents(scope)" :key="index">
|
||||||
|
<q-badge outline :label="event.title" :style="getStyle(event)" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template #resource-label="{ scope: { resource } }">
|
||||||
|
<div class="col-12 .col-md-auto">
|
||||||
|
{{ resource.displayName }}
|
||||||
|
<q-icon v-if="resource.defects" name="warning" color="warning" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-calendar-resource>
|
||||||
|
<q-card-section>
|
||||||
|
<q-select filled v-model="duration" :options="durations" dense @update:model-value="onUpdateDuration" label="Duration (hours)" stack-label>
|
||||||
|
<template v-slot:append><q-icon name="timelapse" /></template>
|
||||||
|
</q-select>
|
||||||
|
</q-card-section>
|
||||||
|
</template>
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
<div>My component</div>
|
<div>My component</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts"></script>
|
||||||
</script>
|
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useBoatStore } from '~/stores/boat';
|
||||||
|
import type { Boat } from '~/utils/boat.types';
|
||||||
|
|
||||||
|
const boats = useBoatStore().boats;
|
||||||
|
const boat = <Boat | undefined>undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-select
|
<q-select v-model="boat" :options="boats" option-value="id" option-label="name" label="Boat">
|
||||||
v-model="boat"
|
|
||||||
:options="boats"
|
|
||||||
option-value="id"
|
|
||||||
option-label="name"
|
|
||||||
label="Boat"
|
|
||||||
>
|
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-img v-if="boat?.iconsrc" :src="boat?.iconsrc" />
|
<q-img v-if="boat?.iconSrc" :src="boat?.iconSrc" />
|
||||||
<q-icon v-else name="sailing" />
|
<q-icon v-else name="sailing" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-slot:option="scope">
|
<template v-slot:option="scope">
|
||||||
<q-item v-bind="scope.itemProps">
|
<q-item v-bind="scope.itemProps">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
@@ -24,17 +25,11 @@
|
|||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section avatar v-if="scope.opt.defects">
|
<q-item-section avatar v-if="scope.opt.defects">
|
||||||
<q-icon name="warning" color="warning" />
|
<q-icon name="warning" color="warning" />
|
||||||
<q-tooltip class="bg-amber text-black shadow-7"
|
<q-tooltip class="bg-amber text-black shadow-7">
|
||||||
>This boat has notices. Select it to see details.
|
This boat has notices. Select it to see details.
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</template>
|
</template>
|
||||||
</q-select>
|
</q-select>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
|
||||||
const boats = useBoatStore().boats;
|
|
||||||
const boat = <Boat | undefined>undefined;
|
|
||||||
</script>
|
|
||||||
25
app/components/boat/BoatPreviewComponent.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Boat } from '~/utils/boat.types';
|
||||||
|
|
||||||
|
defineProps({ boats: Array<Boat> });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="boats" class="row">
|
||||||
|
<q-card
|
||||||
|
v-for="boat in boats"
|
||||||
|
:key="boat.$id"
|
||||||
|
class="q-ma-sm col-xs-12 col-sm-6 col-xl-3">
|
||||||
|
<q-card-section>
|
||||||
|
<q-img :src="boat.imgSrc" :fit="'scale-down'">
|
||||||
|
<div class="row absolute-top">
|
||||||
|
<div class="col text-h6 text-left">{{ boat.name }}</div>
|
||||||
|
<div class="col text-right">{{ boat.class }}</div>
|
||||||
|
</div>
|
||||||
|
</q-img>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
|
||||||
|
</template>
|
||||||
1
app/components/scheduling/BoatSelection.vue
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<template><div /></template>
|
||||||
101
app/components/scheduling/IntervalTemplateComponent.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||||
|
import type { IntervalTemplate } from '~/utils/schedule.types';
|
||||||
|
import { copyIntervalTemplate, timeTuplesOverlapped } from '~/utils/schedule';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const alert = ref(false);
|
||||||
|
const overlapped = ref();
|
||||||
|
const intervalTemplateStore = useIntervalTemplateStore();
|
||||||
|
const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>();
|
||||||
|
const edit = ref(props.edit);
|
||||||
|
const expanded = ref(props.edit);
|
||||||
|
const template = ref(copyIntervalTemplate(props.modelValue));
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'saved'): void }>();
|
||||||
|
|
||||||
|
const revert = () => {
|
||||||
|
template.value = copyIntervalTemplate(props.modelValue);
|
||||||
|
edit.value = false;
|
||||||
|
emit('cancel');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEdit = () => {
|
||||||
|
edit.value = !edit.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTemplate = (event: Event, tmpl: IntervalTemplate | undefined) => {
|
||||||
|
if (tmpl?.$id) intervalTemplateStore.deleteIntervalTemplate(tmpl.$id);
|
||||||
|
};
|
||||||
|
|
||||||
|
function onDragStart(e: DragEvent, tmpl: IntervalTemplate) {
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('ID', tmpl.$id || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveTemplate = (evt: Event, tmpl: IntervalTemplate | undefined) => {
|
||||||
|
if (!tmpl) return false;
|
||||||
|
overlapped.value = timeTuplesOverlapped(tmpl.timeTuples);
|
||||||
|
if (overlapped.value.length > 0) {
|
||||||
|
alert.value = true;
|
||||||
|
} else {
|
||||||
|
edit.value = false;
|
||||||
|
if (tmpl.$id && tmpl.$id !== 'unsaved') {
|
||||||
|
intervalTemplateStore.updateIntervalTemplate(tmpl, tmpl.$id);
|
||||||
|
} else {
|
||||||
|
intervalTemplateStore.createIntervalTemplate(tmpl);
|
||||||
|
emit('saved');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-expansion-item expand-icon-toggle draggable="true" @dragstart="onDragStart($event, template)" v-model="expanded">
|
||||||
|
<template v-slot:header>
|
||||||
|
<q-item-section>
|
||||||
|
<q-input label="Template name" :borderless="!edit" dense v-model="template.name" v-if="edit" />
|
||||||
|
<q-item-label v-if="!edit" class="cursor-pointer">{{ template.name }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</template>
|
||||||
|
<q-card flat>
|
||||||
|
<q-card-section horizontal>
|
||||||
|
<q-card-section class="q-pt-xs">
|
||||||
|
<q-list dense>
|
||||||
|
<q-item v-for="(item, index) in template.timeTuples" :key="item[0]">
|
||||||
|
<q-input class="q-mx-sm" dense v-model="item[0]" type="time" label="Start" :borderless="!edit" :readonly="!edit" />
|
||||||
|
<q-input class="q-mx-sm" dense v-model="item[1]" type="time" label="End" :borderless="!edit" :readonly="!edit">
|
||||||
|
<template v-slot:after>
|
||||||
|
<q-btn v-if="edit" round dense flat icon="delete" @click="template.timeTuples.splice(index, 1)" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<q-btn v-if="edit" dense color="primary" size="sm" label="Add interval" @click="template.timeTuples.push(['00:00', '00:00'])" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions vertical>
|
||||||
|
<q-btn v-if="!edit" color="primary" icon="edit" label="Edit" @click="toggleEdit" />
|
||||||
|
<q-btn v-if="edit" color="primary" icon="save" label="Save" @click="saveTemplate($event, template)" />
|
||||||
|
<q-btn v-if="edit" color="secondary" icon="cancel" label="Cancel" @click="revert" />
|
||||||
|
<q-btn color="negative" icon="delete" label="Delete" v-if="template.$id !== ''" @click="deleteTemplate($event, template)" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-dialog v-model="alert">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section><div class="text-h6">Overlapped blocks!</div></q-card-section>
|
||||||
|
<q-card-section class="q-pt-none">
|
||||||
|
<q-chip square icon="schedule" v-for="item in overlapped" :key="item.start">
|
||||||
|
{{ item.start }}-{{ item.end }}
|
||||||
|
</q-chip>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="OK" color="primary" v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
13
app/components/scheduling/NavigationBar.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div class="row justify-center">
|
||||||
|
<div class="q-pa-md q-gutter-sm row">
|
||||||
|
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">Today</q-btn>
|
||||||
|
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">< Prev</q-btn>
|
||||||
|
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">Next ></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineEmits(['today', 'prev', 'next']);
|
||||||
|
</script>
|
||||||
69
app/components/scheduling/ReservationCardComponent.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useBoatStore } from '~/stores/boat';
|
||||||
|
import { useReservationStore } from '~/stores/reservation';
|
||||||
|
import type { Reservation } from '~/utils/schedule.types';
|
||||||
|
import { formatDate, isPast } from '~/utils/schedule';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const cancelDialog = ref(false);
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
|
||||||
|
const reservation = defineModel<Reservation>({ required: true });
|
||||||
|
|
||||||
|
const cancelReservation = () => {
|
||||||
|
cancelDialog.value = true;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-card
|
||||||
|
bordered
|
||||||
|
:class="isPast(reservation.end) ? 'text-blue-grey-6' : ''"
|
||||||
|
class="q-ma-md">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<div class="col">
|
||||||
|
<div class="text-h6">
|
||||||
|
{{ boatStore.getBoatById(reservation.resource)?.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-subtitle2">
|
||||||
|
<p>
|
||||||
|
Start: {{ formatDate(reservation.start) }}<br />
|
||||||
|
End: {{ formatDate(reservation.end) }}<br />
|
||||||
|
Type: {{ reservation.reason }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
<q-card-actions v-if="!isPast(reservation.end)">
|
||||||
|
<q-btn flat size="lg" :to="`/schedule/edit/${reservation.$id}`">Modify</q-btn>
|
||||||
|
<q-btn flat size="lg" @click="cancelReservation()">Delete</q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
<q-dialog v-model="cancelDialog">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section class="row items-center">
|
||||||
|
<q-avatar icon="warning" color="negative" text-color="white" />
|
||||||
|
<span class="q-ml-md">Warning!</span>
|
||||||
|
<p class="q-pt-md">
|
||||||
|
This will delete your reservation for
|
||||||
|
{{ boatStore.getBoatById(reservation?.resource)?.name }} on
|
||||||
|
{{ formatDate(reservation?.start) }}
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat size="lg" label="Cancel" color="primary" v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
size="lg"
|
||||||
|
label="Delete"
|
||||||
|
color="negative"
|
||||||
|
@click="reservationStore.deleteReservation(reservation)"
|
||||||
|
v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
236
app/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import {
|
||||||
|
QCalendarDay,
|
||||||
|
diffTimestamp,
|
||||||
|
today,
|
||||||
|
parseTimestamp,
|
||||||
|
parseDate,
|
||||||
|
addToDate,
|
||||||
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useBoatStore } from '~/stores/boat';
|
||||||
|
import { useAuthStore } from '~/stores/auth';
|
||||||
|
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useReservationStore } from '~/stores/reservation';
|
||||||
|
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||||
|
import { useIntervalStore } from '~/stores/interval';
|
||||||
|
|
||||||
|
const intervalTemplateStore = useIntervalTemplateStore();
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
const { boats } = storeToRefs(useBoatStore());
|
||||||
|
const selectedBlock = defineModel<Interval | null>();
|
||||||
|
const selectedDate = ref(today());
|
||||||
|
const { getAvailableIntervals } = useIntervalStore();
|
||||||
|
const calendar = ref<typeof QCalendarDay | null>(null);
|
||||||
|
const now = ref(new Date());
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await useBoatStore().fetchBoats();
|
||||||
|
await intervalTemplateStore.fetchIntervalTemplates();
|
||||||
|
intervalId = setInterval(() => { now.value = new Date(); }, 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => clearInterval(intervalId));
|
||||||
|
|
||||||
|
function handleSwipe({ direction }: { direction: string }) {
|
||||||
|
if (direction === 'right') {
|
||||||
|
calendar.value?.prev();
|
||||||
|
} else {
|
||||||
|
calendar.value?.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reservationStyles(
|
||||||
|
reservation: Reservation,
|
||||||
|
timeStartPos: (t: string) => string,
|
||||||
|
timeDurationHeight: (d: number) => string
|
||||||
|
) {
|
||||||
|
return genericBlockStyle(
|
||||||
|
parseDate(new Date(reservation.start)) as Timestamp,
|
||||||
|
parseDate(new Date(reservation.end)) as Timestamp,
|
||||||
|
timeStartPos,
|
||||||
|
timeDurationHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserName(userid: string) {
|
||||||
|
return useAuthStore().getUserNameById(userid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockStyles(
|
||||||
|
block: Interval,
|
||||||
|
timeStartPos: (t: string) => string,
|
||||||
|
timeDurationHeight: (d: number) => string
|
||||||
|
) {
|
||||||
|
return genericBlockStyle(
|
||||||
|
parseDate(new Date(block.start)) as Timestamp,
|
||||||
|
parseDate(new Date(block.end)) as Timestamp,
|
||||||
|
timeStartPos,
|
||||||
|
timeDurationHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoatDisplayName(scope: DayBodyScope) {
|
||||||
|
return boats.value[scope.columnIndex]?.displayName ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeNow(time: Date) {
|
||||||
|
return time < now.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericBlockStyle(
|
||||||
|
start: Timestamp,
|
||||||
|
end: Timestamp,
|
||||||
|
timeStartPos: (t: string) => string,
|
||||||
|
timeDurationHeight: (d: number) => string
|
||||||
|
) {
|
||||||
|
const s = { top: '', height: '', opacity: '' };
|
||||||
|
if (timeStartPos && timeDurationHeight) {
|
||||||
|
s.top = timeStartPos(start.time) + 'px';
|
||||||
|
s.height =
|
||||||
|
parseInt(timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)) - 1 + 'px';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayBodyScope {
|
||||||
|
columnIndex: number;
|
||||||
|
timeDurationHeight: string;
|
||||||
|
timeStartPos: (time: string, clamp: boolean) => string;
|
||||||
|
timestamp: Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
|
||||||
|
if (scope.timestamp.disabled || new Date(block.end) < new Date()) return false;
|
||||||
|
selectedBlock.value = block;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boatReservations = computed((): Record<string, Reservation[]> => {
|
||||||
|
return reservationStore
|
||||||
|
.getReservationsByDate(selectedDate.value)
|
||||||
|
.value.reduce((result, reservation) => {
|
||||||
|
if (!result[reservation.resource]) result[reservation.resource] = [];
|
||||||
|
result[reservation.resource]!.push(reservation);
|
||||||
|
return result;
|
||||||
|
}, <Record<string, Reservation[]>>{});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getBoatReservations(scope: DayBodyScope): Reservation[] {
|
||||||
|
const boat = boats.value[scope.columnIndex];
|
||||||
|
return boat ? boatReservations.value[boat.$id] ?? [] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledBefore = computed(() => {
|
||||||
|
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||||
|
return addToDate(todayTs, { day: -1 }).date;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<q-card>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-toolbar-title>Select a Boat and Time</q-toolbar-title>
|
||||||
|
<q-btn icon="close" flat round dense v-close-popup />
|
||||||
|
</q-toolbar>
|
||||||
|
<q-separator />
|
||||||
|
<CalendarHeaderComponent v-model="selectedDate" />
|
||||||
|
<div class="boat-schedule-table-component">
|
||||||
|
<QCalendarDay
|
||||||
|
ref="calendar"
|
||||||
|
class="q-pa-xs"
|
||||||
|
flat
|
||||||
|
animated
|
||||||
|
dense
|
||||||
|
:disabled-before="disabledBefore"
|
||||||
|
interval-height="24"
|
||||||
|
interval-count="18"
|
||||||
|
interval-start="06:00"
|
||||||
|
:short-interval-label="true"
|
||||||
|
v-model="selectedDate"
|
||||||
|
:column-count="boats.length"
|
||||||
|
v-touch-swipe.left.right="handleSwipe">
|
||||||
|
<template #head-day="{ scope }">
|
||||||
|
<div style="text-align: center; font-weight: 800">
|
||||||
|
{{ getBoatDisplayName(scope) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #day-body="{ scope }">
|
||||||
|
<div
|
||||||
|
v-for="block in getAvailableIntervals(scope.timestamp, boats[scope.columnIndex]).value"
|
||||||
|
:key="block.$id">
|
||||||
|
<div
|
||||||
|
class="timeblock"
|
||||||
|
:disabled="beforeNow(new Date(block.end))"
|
||||||
|
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
|
||||||
|
:style="blockStyles(block, scope.timeStartPos, scope.timeDurationHeight)"
|
||||||
|
:id="block.$id"
|
||||||
|
@click="selectBlock($event, scope, block)">
|
||||||
|
{{ boats[scope.columnIndex]?.name }}<br />
|
||||||
|
{{ selectedBlock?.$id === block.$id ? 'Selected' : 'Available' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="reservation in getBoatReservations(scope)" :key="reservation.$id">
|
||||||
|
<div
|
||||||
|
class="reservation column"
|
||||||
|
:style="reservationStyles(reservation, scope.timeStartPos, scope.timeDurationHeight)">
|
||||||
|
{{ getUserName(reservation.user) || 'loading...' }}<br />
|
||||||
|
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</QCalendarDay>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
.boat-schedule-table-component
|
||||||
|
display: flex
|
||||||
|
max-height: 60vh
|
||||||
|
max-width: 98vw
|
||||||
|
.reservation
|
||||||
|
display: flex
|
||||||
|
position: absolute
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
text-align: center
|
||||||
|
width: 100%
|
||||||
|
opacity: 1
|
||||||
|
margin: 0px
|
||||||
|
text-overflow: ellipsis
|
||||||
|
font-size: 0.8em
|
||||||
|
cursor: pointer
|
||||||
|
background: $accent
|
||||||
|
color: white
|
||||||
|
border: 1px solid black
|
||||||
|
.timeblock
|
||||||
|
display: flex
|
||||||
|
position: absolute
|
||||||
|
justify-content: center
|
||||||
|
text-align: center
|
||||||
|
align-items: center
|
||||||
|
width: 100%
|
||||||
|
opacity: 0.5
|
||||||
|
margin: 0px
|
||||||
|
text-overflow: ellipsis
|
||||||
|
font-size: 0.8em
|
||||||
|
cursor: pointer
|
||||||
|
background: $primary
|
||||||
|
color: white
|
||||||
|
border: 1px solid black
|
||||||
|
.selected
|
||||||
|
opacity: 1 !important
|
||||||
|
.q-calendar-day__interval--text
|
||||||
|
font-size: 0.8em
|
||||||
|
.q-calendar-day__day.q-current-day
|
||||||
|
padding: 1px
|
||||||
|
.q-calendar-day__head--days__column
|
||||||
|
background: $primary
|
||||||
|
color: white
|
||||||
|
</style>
|
||||||
192
app/components/scheduling/boat/CalendarHeaderComponent.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import {
|
||||||
|
addToDate,
|
||||||
|
createDayList,
|
||||||
|
createNativeLocaleFormatter,
|
||||||
|
getEndOfWeek,
|
||||||
|
getStartOfWeek,
|
||||||
|
parseTimestamp,
|
||||||
|
today,
|
||||||
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { ref, reactive, computed } from 'vue';
|
||||||
|
|
||||||
|
const selectedDate = defineModel<string>();
|
||||||
|
|
||||||
|
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]);
|
||||||
|
const locale = ref('en-CA');
|
||||||
|
const monthFormatter = monthFormatterFunc();
|
||||||
|
const dayFormatter = dayFormatterFunc();
|
||||||
|
const weekdayFormatter = weekdayFormatterFunc();
|
||||||
|
|
||||||
|
const today2 = computed(() => parseTimestamp(today()));
|
||||||
|
|
||||||
|
const parsedStart = computed(() =>
|
||||||
|
getStartOfWeek(
|
||||||
|
parseTimestamp(selectedDate.value || today()) as Timestamp,
|
||||||
|
weekdays,
|
||||||
|
today2.value as Timestamp
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedEnd = computed(() =>
|
||||||
|
getEndOfWeek(
|
||||||
|
parseTimestamp(selectedDate.value || today()) as Timestamp,
|
||||||
|
weekdays,
|
||||||
|
today2.value as Timestamp
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const days = computed(() => {
|
||||||
|
if (parsedStart.value && parsedEnd.value) {
|
||||||
|
return createDayList(parsedStart.value, parsedEnd.value, today2.value as Timestamp, weekdays);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayStyle = computed(() => ({ width: 100 / weekdays.length + '%' }));
|
||||||
|
|
||||||
|
function onPrev() {
|
||||||
|
selectedDate.value = addToDate(parsedStart.value, { day: -7 }).date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNext() {
|
||||||
|
selectedDate.value = addToDate(parsedStart.value, { day: 7 }).date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayClass(day: Timestamp) {
|
||||||
|
return { 'date-button': true, 'selected-date-button': selectedDate.value === day.date };
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthFormatterFunc() {
|
||||||
|
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'long' };
|
||||||
|
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'short' };
|
||||||
|
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayFormatterFunc() {
|
||||||
|
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'long' };
|
||||||
|
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'short' };
|
||||||
|
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayFormatterFunc() {
|
||||||
|
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: '2-digit' };
|
||||||
|
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: 'numeric' };
|
||||||
|
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="title-bar" style="display: flex">
|
||||||
|
<button tabindex="0" class="date-button direction-button direction-button__left" @click="onPrev">
|
||||||
|
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||||
|
</button>
|
||||||
|
<div class="dates-holder">
|
||||||
|
<div :key="parsedStart?.date" class="internal-dates-holder">
|
||||||
|
<div v-for="day in days" :key="day.date" :style="dayStyle">
|
||||||
|
<button tabindex="0" style="width: 100%" :class="dayClass(day)" @click="selectedDate = day.date">
|
||||||
|
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||||
|
<div style="width: 100%; font-size: 0.9em">{{ monthFormatter(day, true) }}</div>
|
||||||
|
<div style="width: 100%; font-size: 1.2em; font-weight: 700">{{ dayFormatter(day, false) }}</div>
|
||||||
|
<div style="width: 100%; font-size: 1em">{{ weekdayFormatter(day, true) }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button tabindex="0" class="date-button direction-button direction-button__right" @click="onNext">
|
||||||
|
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
.title-bar
|
||||||
|
position: relative
|
||||||
|
width: 100%
|
||||||
|
height: 70px
|
||||||
|
background: white
|
||||||
|
display: flex
|
||||||
|
flex-direction: row
|
||||||
|
flex: 1 0 100%
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
overflow: hidden
|
||||||
|
border-radius: 3px
|
||||||
|
user-select: none
|
||||||
|
margin: 2px 0px 2px
|
||||||
|
|
||||||
|
.dates-holder
|
||||||
|
position: relative
|
||||||
|
width: 100%
|
||||||
|
align-items: center
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
color: #fff
|
||||||
|
overflow: hidden
|
||||||
|
user-select: none
|
||||||
|
|
||||||
|
.internal-dates-holder
|
||||||
|
position: relative
|
||||||
|
width: 100%
|
||||||
|
display: inline-flex
|
||||||
|
flex: 1 1 100%
|
||||||
|
flex-direction: row
|
||||||
|
justify-content: space-between
|
||||||
|
overflow: hidden
|
||||||
|
user-select: none
|
||||||
|
|
||||||
|
.direction-button
|
||||||
|
background: white
|
||||||
|
color: $primary
|
||||||
|
width: 40px
|
||||||
|
max-width: 50px !important
|
||||||
|
|
||||||
|
.direction-button__left
|
||||||
|
&:before
|
||||||
|
content: '<'
|
||||||
|
display: inline-flex
|
||||||
|
flex-direction: column
|
||||||
|
justify-content: center
|
||||||
|
height: 100%
|
||||||
|
font-weight: 900
|
||||||
|
font-size: 3em
|
||||||
|
|
||||||
|
.direction-button__right
|
||||||
|
&:before
|
||||||
|
content: '>'
|
||||||
|
display: inline-flex
|
||||||
|
flex-direction: column
|
||||||
|
justify-content: center
|
||||||
|
height: 100%
|
||||||
|
font-weight: 900
|
||||||
|
font-size: 3em
|
||||||
|
|
||||||
|
.date-button
|
||||||
|
color: $primary
|
||||||
|
background: white
|
||||||
|
z-index: 2
|
||||||
|
height: 100%
|
||||||
|
outline: 0
|
||||||
|
cursor: pointer
|
||||||
|
border-radius: 3px
|
||||||
|
display: inline-flex
|
||||||
|
flex: 1 0 auto
|
||||||
|
flex-direction: column
|
||||||
|
align-items: stretch
|
||||||
|
position: relative
|
||||||
|
border: 0
|
||||||
|
vertical-align: middle
|
||||||
|
padding: 0
|
||||||
|
font-size: 14px
|
||||||
|
line-height: 1.715em
|
||||||
|
text-decoration: none
|
||||||
|
font-weight: 500
|
||||||
|
text-transform: uppercase
|
||||||
|
text-align: center
|
||||||
|
user-select: none
|
||||||
|
|
||||||
|
.selected-date-button
|
||||||
|
color: white !important
|
||||||
|
background: $primary !important
|
||||||
|
</style>
|
||||||
28
app/layouts/admin.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const leftDrawer = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-layout view="hHh Lpr fFf">
|
||||||
|
<q-header elevated>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-btn flat round dense icon="menu" @click="leftDrawer = !leftDrawer" />
|
||||||
|
<q-toolbar-title>Admin</q-toolbar-title>
|
||||||
|
</q-toolbar>
|
||||||
|
<q-tabs>
|
||||||
|
<q-route-tab icon="person" to="/admin/user" replace label="Users" />
|
||||||
|
<q-route-tab icon="directions_boat" to="/admin/boat" replace label="Boats" />
|
||||||
|
</q-tabs>
|
||||||
|
</q-header>
|
||||||
|
|
||||||
|
<q-drawer v-model="leftDrawer" side="left" bordered content-class="bg-grey-2">
|
||||||
|
<q-scroll-area class="fit q-pa-sm" />
|
||||||
|
</q-drawer>
|
||||||
|
|
||||||
|
<q-page-container>
|
||||||
|
<slot />
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
45
app/layouts/default.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import BottomNavComponent from '~/components/BottomNavComponent.vue';
|
||||||
|
import LeftDrawer from '~/components/LeftDrawer.vue';
|
||||||
|
import { APP_VERSION } from '~/utils/version';
|
||||||
|
|
||||||
|
const q = useQuasar();
|
||||||
|
const route = useRoute();
|
||||||
|
const leftDrawerOpen = ref(false);
|
||||||
|
|
||||||
|
function toggleLeftDrawer() {
|
||||||
|
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
q.addressbarColor?.set('#14539a');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-layout view="hHh Lpr fFf">
|
||||||
|
<q-header elevated>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
icon="menu"
|
||||||
|
aria-label="Menu"
|
||||||
|
@click="toggleLeftDrawer" />
|
||||||
|
<q-toolbar-title>{{ route?.meta?.title as string }}</q-toolbar-title>
|
||||||
|
<q-space />
|
||||||
|
<div>v{{ APP_VERSION }}</div>
|
||||||
|
</q-toolbar>
|
||||||
|
</q-header>
|
||||||
|
<LeftDrawer
|
||||||
|
:drawer="leftDrawerOpen"
|
||||||
|
@drawer-toggle="toggleLeftDrawer" />
|
||||||
|
<q-page-container>
|
||||||
|
<slot />
|
||||||
|
</q-page-container>
|
||||||
|
<q-footer>
|
||||||
|
<BottomNavComponent />
|
||||||
|
</q-footer>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
27
app/middleware/auth.global.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useAuthStore } from '~/stores/auth';
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// Public routes (set via definePageMeta({ public: true }) in each page)
|
||||||
|
if (to.meta.public === true) {
|
||||||
|
// Redirect already-authenticated users away from /login
|
||||||
|
if (to.path === '/login' && authStore.currentUser) {
|
||||||
|
return navigateTo('/');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other routes require auth
|
||||||
|
if (!authStore.currentUser) {
|
||||||
|
return navigateTo('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role-based access: pages set requiredRoles via definePageMeta
|
||||||
|
const requiredRoles = to.meta.requiredRoles as string[] | undefined;
|
||||||
|
if (requiredRoles && requiredRoles.length > 0) {
|
||||||
|
if (!authStore.hasRequiredRole(requiredRoles)) {
|
||||||
|
return abortNavigation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
20
app/pages/[...slug].vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
definePageMeta({ public: true, layout: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 30vh">404</div>
|
||||||
|
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
|
||||||
|
<q-btn
|
||||||
|
class="q-mt-xl"
|
||||||
|
color="white"
|
||||||
|
text-color="blue"
|
||||||
|
unelevated
|
||||||
|
to="/"
|
||||||
|
label="Go Home"
|
||||||
|
no-caps />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: 'admin', requiredRoles: ['admin'] });
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<!-- content -->
|
<!-- content -->
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
</script>
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: 'admin', requiredRoles: ['admin'] });
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<!-- content -->
|
<!-- content -->
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
</script>
|
|
||||||
34
app/pages/auth/callback.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '~/stores/auth';
|
||||||
|
|
||||||
|
definePageMeta({ public: true, layout: false });
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const userId = route.query.userId as string | undefined;
|
||||||
|
const secret = route.query.secret as string | undefined;
|
||||||
|
|
||||||
|
if (!userId || !secret) {
|
||||||
|
error.value = 'Invalid magic link — missing parameters.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.magicURLLogin(userId, secret);
|
||||||
|
await navigateTo('/');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
error.value = 'Login failed. The link may have expired.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-page class="flex flex-center">
|
||||||
|
<div v-if="error" class="text-negative text-body1">{{ error }}</div>
|
||||||
|
<q-spinner v-else color="primary" size="50px" />
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
18
app/pages/boat.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import BoatPreviewComponent from '~/components/boat/BoatPreviewComponent.vue';
|
||||||
|
import { useBoatStore } from '~/stores/boat';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
definePageMeta({ title: 'Boats' });
|
||||||
|
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const { boats } = storeToRefs(boatStore);
|
||||||
|
|
||||||
|
onMounted(() => boatStore.fetchBoats());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-page>
|
||||||
|
<boat-preview-component :boats="boats" />
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
11
app/pages/certification.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import CertificationComponent from '~/components/CertificationComponent.vue';
|
||||||
|
|
||||||
|
definePageMeta({ title: 'Certifications' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<CertificationComponent />
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ title: 'Checklists' });
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<toolbar-component pageTitle="Checklists" />
|
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<q-card bordered separator style="max-width: 400px">
|
<q-card bordered separator style="max-width: 400px">
|
||||||
<q-card-section clickable v-ripple>
|
<q-card-section clickable v-ripple>
|
||||||
@@ -19,7 +22,3 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
|
||||||
</script>
|
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useNavLinks } from '~/utils/navlinks';
|
||||||
|
|
||||||
|
definePageMeta({ title: 'OYS Borrow a Boat' });
|
||||||
|
|
||||||
|
const { enabledLinks } = useNavLinks();
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ToolbarComponent />
|
|
||||||
<q-page class="row justify-center">
|
<q-page class="row justify-center">
|
||||||
<q-img alt="OYS Logo" src="~assets/oysqn_logo.png" fit="scale-down" />
|
<q-img alt="OYS Logo" src="/oysqn_logo.png" fit="scale-down" />
|
||||||
<q-list class="full-width mobile-only">
|
<q-list class="full-width mobile-only">
|
||||||
<q-item
|
<q-item
|
||||||
v-for="link in links.filter((x) => x.front_links)"
|
v-for="link in enabledLinks.filter((x) => x.front_links)"
|
||||||
:key="link.name"
|
:key="link.name">
|
||||||
>
|
|
||||||
<q-btn
|
<q-btn
|
||||||
:icon="link.icon"
|
:icon="link.icon"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -15,14 +21,8 @@
|
|||||||
:label="link.name"
|
:label="link.name"
|
||||||
rounded
|
rounded
|
||||||
class="full-width"
|
class="full-width"
|
||||||
:align="'left'"
|
:align="'left'" />
|
||||||
/>
|
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { links } from 'src/router/navlinks.js';
|
|
||||||
import ToolbarComponent from 'components/ToolbarComponent.vue';
|
|
||||||
</script>
|
|
||||||
127
app/pages/login.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { Dialog, Notify } from 'quasar';
|
||||||
|
import { useAuthStore } from '~/stores/auth';
|
||||||
|
import { AppwriteException } from 'appwrite';
|
||||||
|
|
||||||
|
definePageMeta({ public: true, layout: false });
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const token = ref('');
|
||||||
|
const userId = ref<string | undefined>();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const sendMagicLink = async () => {
|
||||||
|
if (!email.value) {
|
||||||
|
Dialog.create({ message: 'Please enter your e-mail address.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await authStore.createMagicURLSession(email.value);
|
||||||
|
Dialog.create({ message: 'Check your e-mail for a magic login link.' });
|
||||||
|
} catch {
|
||||||
|
Dialog.create({ message: 'An error occurred. Please ask for help in Discord.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doTokenLogin = async () => {
|
||||||
|
if (!userId.value) {
|
||||||
|
try {
|
||||||
|
const sessionToken = await authStore.createTokenSession(email.value);
|
||||||
|
userId.value = sessionToken.userId;
|
||||||
|
Dialog.create({ message: 'Check your e-mail for your login code.' });
|
||||||
|
} catch {
|
||||||
|
Dialog.create({ message: 'An error occurred. Please ask for help in Discord.' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const notification = Notify.create({
|
||||||
|
type: 'primary',
|
||||||
|
position: 'top',
|
||||||
|
spinner: true,
|
||||||
|
message: 'Logging you in...',
|
||||||
|
timeout: 8000,
|
||||||
|
group: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await authStore.tokenLogin(userId.value, token.value);
|
||||||
|
notification({ type: 'positive', message: 'Logged in!', timeout: 2000, spinner: false, icon: 'check_circle' });
|
||||||
|
await navigateTo('/');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof AppwriteException) {
|
||||||
|
if (error.type === 'user_session_already_exists') {
|
||||||
|
notification({ type: 'positive', message: 'Already logged in!', timeout: 2000, spinner: false, icon: 'check_circle' });
|
||||||
|
await navigateTo('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Dialog.create({ title: 'Login Error!', message: error.message, persistent: true });
|
||||||
|
}
|
||||||
|
notification({ type: 'negative', message: 'Login failed.', timeout: 2000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-layout>
|
||||||
|
<q-page-container>
|
||||||
|
<q-page class="flex bg-image flex-center">
|
||||||
|
<q-card
|
||||||
|
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||||
|
<q-card-section>
|
||||||
|
<q-img fit="scale-down" src="/oysqn_logo.png" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-center q-pt-sm">
|
||||||
|
<div class="col text-h6">Log in</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-form @keydown.enter.prevent="doTokenLogin">
|
||||||
|
<q-card-section class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
v-model="email"
|
||||||
|
label="E-Mail"
|
||||||
|
type="email"
|
||||||
|
color="darkblue"
|
||||||
|
filled />
|
||||||
|
<q-input
|
||||||
|
v-if="userId"
|
||||||
|
v-model="token"
|
||||||
|
label="6-digit code"
|
||||||
|
type="number"
|
||||||
|
color="darkblue"
|
||||||
|
filled />
|
||||||
|
</q-card-section>
|
||||||
|
</q-form>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<div class="row justify-center q-ma-sm">
|
||||||
|
<q-btn
|
||||||
|
v-if="!userId"
|
||||||
|
type="button"
|
||||||
|
@click="sendMagicLink"
|
||||||
|
color="secondary"
|
||||||
|
label="Send Magic Link"
|
||||||
|
style="width: 300px" />
|
||||||
|
</div>
|
||||||
|
<div class="row justify-center q-ma-sm">
|
||||||
|
<q-btn
|
||||||
|
type="button"
|
||||||
|
@click="doTokenLogin"
|
||||||
|
color="primary"
|
||||||
|
:label="userId ? 'Login' : 'Send Code'"
|
||||||
|
style="width: 300px" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-image {
|
||||||
|
background-image: url('~/assets/oys_lighthouse.jpg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position-x: center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
43
app/pages/privacy-policy.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ public: true, layout: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-layout>
|
||||||
|
<q-page-container>
|
||||||
|
<q-page padding>
|
||||||
|
<h1>Privacy Policy for Undock.ca</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
At Undock, accessible from https://undock.ca, one of our main priorities is the
|
||||||
|
privacy of our visitors. This Privacy Policy document contains types of information
|
||||||
|
that is collected and recorded by Undock and how we use it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>General Data Protection Regulation (GDPR)</h2>
|
||||||
|
<p>We are a Data Controller of your information.</p>
|
||||||
|
|
||||||
|
<h2>Log Files</h2>
|
||||||
|
<p>
|
||||||
|
Undock follows a standard procedure of using log files. These files log visitors
|
||||||
|
when they visit websites. The information collected by log files include internet
|
||||||
|
protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and
|
||||||
|
time stamp, referring/exit pages, and possibly the number of clicks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Cookies and Web Beacons</h2>
|
||||||
|
<p>
|
||||||
|
Like any other website, Undock uses "cookies". These cookies are used to store
|
||||||
|
information including visitors' preferences, and the pages on the website that
|
||||||
|
the visitor accessed or visited.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Consent</h2>
|
||||||
|
<p>
|
||||||
|
By using our website, you hereby consent to our Privacy Policy and agree to its
|
||||||
|
<a href="/terms-of-service">terms</a>.
|
||||||
|
</p>
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
76
app/pages/profile.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '~/stores/auth';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
definePageMeta({ title: 'Member Profile' });
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const newName = ref<string | undefined>();
|
||||||
|
|
||||||
|
const editName = async () => {
|
||||||
|
if (newName.value) {
|
||||||
|
try {
|
||||||
|
await authStore.updateName(newName.value);
|
||||||
|
newName.value = undefined;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newName.value = authStore.currentUser?.name || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-page padding class="row">
|
||||||
|
<q-list class="col-sm-4 col-12">
|
||||||
|
<q-separator />
|
||||||
|
<q-item>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar icon="person" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption>Name</q-item-label>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="newName"
|
||||||
|
@keydown.enter.prevent="editName"
|
||||||
|
v-if="newName !== undefined" />
|
||||||
|
<div v-else>{{ authStore.currentUser?.name }}</div>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-btn
|
||||||
|
square
|
||||||
|
@click="editName"
|
||||||
|
:icon="newName !== undefined ? 'check' : 'edit'" />
|
||||||
|
<q-btn
|
||||||
|
v-if="newName !== undefined"
|
||||||
|
square
|
||||||
|
color="negative"
|
||||||
|
@click="newName = undefined"
|
||||||
|
icon="cancel" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar icon="email" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption>E-mail</q-item-label>
|
||||||
|
{{ authStore.currentUser?.email }}
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-separator />
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>Certifications</q-item-label>
|
||||||
|
<div>
|
||||||
|
<q-chip square icon="verified" color="green" text-color="white">J/27</q-chip>
|
||||||
|
<q-chip square icon="verified" color="blue" text-color="white">Capri25</q-chip>
|
||||||
|
<q-chip square icon="verified" color="grey-9" text-color="white">Night</q-chip>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
38
app/pages/pwreset.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// NOTE: Password reset removed — auth is magic link + OTP only.
|
||||||
|
definePageMeta({ public: true, layout: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-layout>
|
||||||
|
<q-page-container>
|
||||||
|
<q-page class="flex bg-image flex-center">
|
||||||
|
<q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||||
|
<q-card-section>
|
||||||
|
<q-img fit="scale-down" src="/oysqn_logo.png" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-center q-pt-sm">
|
||||||
|
<div class="text-h6">Password Reset</div>
|
||||||
|
<div class="text-body2 q-mt-md">
|
||||||
|
This application uses magic link and email code login — no password is required.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="text-center">
|
||||||
|
<q-btn flat color="primary" label="Back to Login" to="/login" />
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-image {
|
||||||
|
background-image: url('~/assets/oys_lighthouse.jpg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position-x: center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
app/pages/reference.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ title: 'Reference' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtPage />
|
||||||
|
</template>
|
||||||
13
app/pages/reference/index.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ReferencePreviewComponent from '~/components/ReferencePreviewComponent.vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useReferenceStore } from '~/stores/reference';
|
||||||
|
|
||||||
|
const items = ref(useReferenceStore().allItems);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<ReferencePreviewComponent :entries="items" />
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
@@ -4,8 +4,7 @@
|
|||||||
<q-video
|
<q-video
|
||||||
title="Engine Starting"
|
title="Engine Starting"
|
||||||
:ratio="16 / 9"
|
:ratio="16 / 9"
|
||||||
src="https://www.youtube.com/embed/GMHMLDlkKcE"
|
src="https://www.youtube.com/embed/GMHMLDlkKcE" />
|
||||||
></q-video>
|
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
7
app/pages/schedule.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ title: 'Schedule' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtPage />
|
||||||
|
</template>
|
||||||
28
app/pages/schedule/book.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import BoatReservationComponent from '~/components/BoatReservationComponent.vue';
|
||||||
|
import { useIntervalStore } from '~/stores/interval';
|
||||||
|
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const newReservation = ref<Reservation>();
|
||||||
|
|
||||||
|
if (typeof route.query.interval === 'string') {
|
||||||
|
useIntervalStore()
|
||||||
|
.fetchInterval(route.query.interval)
|
||||||
|
.then(
|
||||||
|
(interval: Interval) =>
|
||||||
|
(newReservation.value = <Reservation>{
|
||||||
|
resource: interval.resource,
|
||||||
|
start: interval.start,
|
||||||
|
end: interval.end,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-page>
|
||||||
|
<BoatReservationComponent v-model="newReservation" />
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
20
app/pages/schedule/edit/[id].vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import BoatReservationComponent from '~/components/BoatReservationComponent.vue';
|
||||||
|
import { useReservationStore } from '~/stores/reservation';
|
||||||
|
import type { Reservation } from '~/utils/schedule.types';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const reservation = ref<Reservation>();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = route.params.id as string;
|
||||||
|
reservation.value = await useReservationStore().getReservationById(id);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-page>
|
||||||
|
<BoatReservationComponent v-model="reservation" />
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
22
app/pages/schedule/index.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useNavLinks } from '~/utils/navlinks';
|
||||||
|
|
||||||
|
const { enabledLinks } = useNavLinks();
|
||||||
|
const navlinks = enabledLinks.find((link) => link.name === 'Schedule')?.sublinks;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<q-item v-for="link in navlinks" :key="link.name">
|
||||||
|
<q-btn
|
||||||
|
:icon="link.icon"
|
||||||
|
:color="link.color ? link.color : 'primary'"
|
||||||
|
size="1.25em"
|
||||||
|
:to="link.to"
|
||||||
|
:label="link.name"
|
||||||
|
rounded
|
||||||
|
class="full-width"
|
||||||
|
align="left" />
|
||||||
|
</q-item>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
55
app/pages/schedule/list.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useReservationStore } from '~/stores/reservation';
|
||||||
|
import ReservationCardComponent from '~/components/scheduling/ReservationCardComponent.vue';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
onMounted(() => reservationStore.fetchUserReservations());
|
||||||
|
|
||||||
|
const tab = ref('upcoming');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-page>
|
||||||
|
<q-tabs v-model="tab" inline-label class="text-primary">
|
||||||
|
<q-tab name="upcoming" icon="schedule" label="Upcoming" />
|
||||||
|
<q-tab name="past" icon="history" label="Past" />
|
||||||
|
</q-tabs>
|
||||||
|
<q-separator />
|
||||||
|
<q-tab-panels v-model="tab" animated>
|
||||||
|
<q-tab-panel name="upcoming" class="q-pa-none">
|
||||||
|
<q-card clas="q-ma-md" v-if="!reservationStore.futureUserReservations.length">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">You don't have any upcoming bookings!</div>
|
||||||
|
<div class="text-h8">Why don't you go make one?</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="event"
|
||||||
|
:size="`1.25em`"
|
||||||
|
label="Book Now"
|
||||||
|
rounded
|
||||||
|
class="full-width"
|
||||||
|
:align="'left'"
|
||||||
|
to="/schedule/book" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-for="reservation in reservationStore.futureUserReservations"
|
||||||
|
:key="reservation.$id">
|
||||||
|
<ReservationCardComponent :modelValue="reservation" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
<q-tab-panel name="past" class="q-pa-none">
|
||||||
|
<div
|
||||||
|
v-for="reservation in reservationStore.pastUserReservations"
|
||||||
|
:key="reservation.$id">
|
||||||
|
<ReservationCardComponent :modelValue="reservation" />
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
238
app/pages/schedule/manage.vue
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import {
|
||||||
|
QCalendarScheduler,
|
||||||
|
today,
|
||||||
|
} from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import type { Boat } from '~/utils/boat.types';
|
||||||
|
import { useIntervalStore } from '~/stores/interval';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
|
||||||
|
import { date } from 'quasar';
|
||||||
|
import IntervalTemplateComponent from '~/components/scheduling/IntervalTemplateComponent.vue';
|
||||||
|
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { buildInterval, intervalsOverlapped } from '~/utils/schedule';
|
||||||
|
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||||
|
|
||||||
|
definePageMeta({ requiredRoles: ['Schedule Admins'] });
|
||||||
|
|
||||||
|
const selectedDate = ref(today());
|
||||||
|
const { fetchBoats } = useBoatStore();
|
||||||
|
const intervalStore = useIntervalStore();
|
||||||
|
const intervalTemplateStore = useIntervalTemplateStore();
|
||||||
|
const { boats } = storeToRefs(useBoatStore());
|
||||||
|
const intervalTemplates = intervalTemplateStore.getIntervalTemplates();
|
||||||
|
const calendar = ref();
|
||||||
|
const overlapped = ref();
|
||||||
|
const alert = ref(false);
|
||||||
|
const newTemplate = ref<IntervalTemplate>({
|
||||||
|
$id: '',
|
||||||
|
name: 'NewTemplate',
|
||||||
|
timeTuples: [['09:00', '12:00']],
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchBoats();
|
||||||
|
await intervalTemplateStore.fetchIntervalTemplates();
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredIntervals = (ts: Timestamp, boat: Boat) => {
|
||||||
|
return intervalStore.getIntervals(ts, boat);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedIntervals = (ts: Timestamp, boat: Boat) => {
|
||||||
|
return computed(() =>
|
||||||
|
filteredIntervals(ts, boat).value.sort(
|
||||||
|
(a, b) => Date.parse(a.start) - Date.parse(b.start)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetNewTemplate() {
|
||||||
|
newTemplate.value = {
|
||||||
|
$id: 'unsaved',
|
||||||
|
name: 'NewTemplate',
|
||||||
|
timeTuples: [['09:00', '12:00']],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function createTemplate() {
|
||||||
|
newTemplate.value.$id = 'unsaved';
|
||||||
|
}
|
||||||
|
function createIntervals(boat: Boat, templateId: string, dateStr: string) {
|
||||||
|
const intervals = intervalsFromTemplate(boat, templateId, dateStr);
|
||||||
|
intervals.forEach((interval) => intervalStore.createInterval(interval));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntervals(ts: Timestamp, boat: Boat) {
|
||||||
|
return intervalStore.getIntervals(ts, boat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function intervalsFromTemplate(
|
||||||
|
boat: Boat,
|
||||||
|
templateId: string,
|
||||||
|
dateStr: string
|
||||||
|
): Interval[] {
|
||||||
|
const template = intervalTemplateStore
|
||||||
|
.getIntervalTemplates()
|
||||||
|
.value.find((t) => t.$id === templateId);
|
||||||
|
return template
|
||||||
|
? template.timeTuples.map((timeTuple: TimeTuple) =>
|
||||||
|
buildInterval(boat, timeTuple, dateStr)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBlock(block: Interval) {
|
||||||
|
if (block.$id) {
|
||||||
|
intervalStore.deleteInterval(block.$id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter(e: DragEvent, type: string) {
|
||||||
|
if (type === 'day' || type === 'head-day') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.add('bg-secondary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, type: string) {
|
||||||
|
if (type === 'day' || type === 'head-day') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(e: DragEvent, type: string) {
|
||||||
|
if (type === 'day' || type === 'head-day') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.remove('bg-secondary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(
|
||||||
|
e: DragEvent,
|
||||||
|
type: string,
|
||||||
|
scope: { resource: Boat; timestamp: Timestamp }
|
||||||
|
) {
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.remove('bg-secondary');
|
||||||
|
|
||||||
|
if ((type === 'day' || type === 'head-day') && e.dataTransfer) {
|
||||||
|
const templateId = e.dataTransfer.getData('ID');
|
||||||
|
const dateStr = scope.timestamp.date;
|
||||||
|
const resource = scope.resource;
|
||||||
|
const existingIntervals = getIntervals(scope.timestamp, resource);
|
||||||
|
const boatsToApply = type === 'head-day' ? boats.value : [resource];
|
||||||
|
overlapped.value = boatsToApply
|
||||||
|
.map((boat) =>
|
||||||
|
intervalsOverlapped(
|
||||||
|
existingIntervals.value.concat(
|
||||||
|
intervalsFromTemplate(boat, templateId, dateStr)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flat(1);
|
||||||
|
if (overlapped.value.length === 0) {
|
||||||
|
boatsToApply.map((b) => createIntervals(b, templateId, dateStr));
|
||||||
|
} else {
|
||||||
|
alert.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.target instanceof HTMLDivElement)
|
||||||
|
e.target.classList.remove('bg-secondary');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToday() { calendar.value.moveToToday(); }
|
||||||
|
function onPrev() { calendar.value.prev(); }
|
||||||
|
function onNext() { calendar.value.next(); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fit row">
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<div class="scheduler col-12">
|
||||||
|
<NavigationBar @next="onNext" @today="onToday" @prev="onPrev" />
|
||||||
|
<q-calendar-scheduler
|
||||||
|
ref="calendar"
|
||||||
|
v-model="selectedDate"
|
||||||
|
v-model:model-resources="boats"
|
||||||
|
resource-key="$id"
|
||||||
|
resource-label="name"
|
||||||
|
view="week"
|
||||||
|
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||||
|
animated
|
||||||
|
bordered
|
||||||
|
:drag-enter-func="onDragEnter"
|
||||||
|
:drag-over-func="onDragOver"
|
||||||
|
:drag-leave-func="onDragLeave"
|
||||||
|
:drop-func="onDrop"
|
||||||
|
day-min-height="50px"
|
||||||
|
cell-width="150px">
|
||||||
|
<template #day="{ scope }">
|
||||||
|
<div
|
||||||
|
v-if="filteredIntervals(scope.timestamp, scope.resource).value.length"
|
||||||
|
style="display: flex; flex-wrap: wrap; justify-content: space-evenly; align-items: center; font-size: 12px;">
|
||||||
|
<template
|
||||||
|
v-for="block in sortedIntervals(scope.timestamp, scope.resource).value"
|
||||||
|
:key="block.$id">
|
||||||
|
<q-chip class="cursor-pointer">
|
||||||
|
{{ date.formatDate(block.start, 'HH:mm') }} -
|
||||||
|
{{ date.formatDate(block.end, 'HH:mm') }}
|
||||||
|
</q-chip>
|
||||||
|
<q-btn size="xs" icon="delete" round @click="deleteBlock(block)" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-calendar-scheduler>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="q-pa-md" style="width: 400px">
|
||||||
|
<q-list padding bordered class="rounded-borders">
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>Availability Templates</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
Drag and drop a template to a boat / date to create booking availability
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn label="Add Template" color="primary" @click="createTemplate" />
|
||||||
|
</q-card-actions>
|
||||||
|
<q-item v-if="newTemplate.$id === 'unsaved'">
|
||||||
|
<IntervalTemplateComponent
|
||||||
|
:model-value="newTemplate"
|
||||||
|
:edit="true"
|
||||||
|
@cancel="resetNewTemplate"
|
||||||
|
@saved="resetNewTemplate" />
|
||||||
|
</q-item>
|
||||||
|
<q-separator spaced />
|
||||||
|
<IntervalTemplateComponent
|
||||||
|
v-for="template in intervalTemplates"
|
||||||
|
:key="template.$id"
|
||||||
|
:model-value="template" />
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
<q-dialog v-model="alert">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Warning!</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pt-none">
|
||||||
|
Conflicting times! Please delete overlapped items!
|
||||||
|
<q-chip v-for="item in overlapped" :key="item.index">
|
||||||
|
{{ boats.find((b) => b.$id === item.boatId)?.name }}:
|
||||||
|
{{ date.formatDate(item.start, 'hh:mm') }} -
|
||||||
|
{{ date.formatDate(item.end, 'hh:mm') }}
|
||||||
|
</q-chip>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="OK" color="primary" v-close-popup />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
120
app/pages/schedule/view.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useReservationStore } from '~/stores/reservation';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useAuthStore } from '~/stores/auth';
|
||||||
|
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { getDate, QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import type { Boat } from '~/utils/boat.types';
|
||||||
|
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { formatTime } from '~/utils/schedule';
|
||||||
|
import { useIntervalStore } from '~/stores/interval';
|
||||||
|
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
const boatStore = useBoatStore();
|
||||||
|
const calendar = ref();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const { getAvailableIntervals } = useIntervalStore();
|
||||||
|
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||||
|
const currentUser = useAuthStore().currentUser;
|
||||||
|
|
||||||
|
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
|
||||||
|
return getAvailableIntervals(timestamp, boat)
|
||||||
|
.value.concat(boatReservationEvents(timestamp, boat))
|
||||||
|
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createReservationFromInterval = (interval: Interval | Reservation) => {
|
||||||
|
if (interval.user) {
|
||||||
|
if (interval.user === currentUser?.$id) {
|
||||||
|
navigateTo(`/schedule/edit/${interval.$id}`);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigateTo({ path: '/schedule/book', query: { interval: interval.$id } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleSwipe({ ...event }: { direction: string }) {
|
||||||
|
if (event.direction === 'right') {
|
||||||
|
calendar.value?.prev();
|
||||||
|
} else {
|
||||||
|
calendar.value?.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const boatReservationEvents = (
|
||||||
|
timestamp: Timestamp,
|
||||||
|
resource: Boat | undefined
|
||||||
|
): Reservation[] => {
|
||||||
|
if (!resource) return [] as Reservation[];
|
||||||
|
return reservationStore.getReservationsByDate(
|
||||||
|
getDate(timestamp),
|
||||||
|
(resource as Boat).$id
|
||||||
|
).value;
|
||||||
|
};
|
||||||
|
|
||||||
|
function onToday() { calendar.value.moveToToday(); }
|
||||||
|
function onPrev() { calendar.value.prev(); }
|
||||||
|
function onNext() { calendar.value.next(); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-page>
|
||||||
|
<div class="col">
|
||||||
|
<navigation-bar @today="onToday" @prev="onPrev" @next="onNext" />
|
||||||
|
</div>
|
||||||
|
<div class="col q-ma-sm">
|
||||||
|
<q-calendar-scheduler
|
||||||
|
ref="calendar"
|
||||||
|
v-model="selectedDate"
|
||||||
|
v-model:model-resources="boatStore.boats"
|
||||||
|
resource-key="$id"
|
||||||
|
resource-label="displayName"
|
||||||
|
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||||
|
:view="$q.screen.gt.md ? 'week' : 'day'"
|
||||||
|
v-touch-swipe.mouse.left.right="handleSwipe"
|
||||||
|
:max-days="$q.screen.lt.sm ? 3 : 7"
|
||||||
|
animated
|
||||||
|
bordered
|
||||||
|
style="--calendar-resources-width: 40px">
|
||||||
|
<template #day="{ scope }">
|
||||||
|
<div
|
||||||
|
v-for="interval in getSortedIntervals(scope.timestamp, scope.resource)"
|
||||||
|
:key="interval.$id"
|
||||||
|
class="q-pb-xs row"
|
||||||
|
@click="createReservationFromInterval(interval)">
|
||||||
|
<q-badge
|
||||||
|
multi-line
|
||||||
|
:class="!interval.user ? 'cursor-pointer' : null"
|
||||||
|
class="col-12 q-pa-sm"
|
||||||
|
:transparent="interval.user != undefined"
|
||||||
|
:color="interval.user ? 'secondary' : 'primary'"
|
||||||
|
:outline="!interval.user"
|
||||||
|
:id="interval.$id">
|
||||||
|
{{
|
||||||
|
interval.user
|
||||||
|
? useAuthStore().getUserNameById(interval.user)
|
||||||
|
: 'Available'
|
||||||
|
}}
|
||||||
|
<br />
|
||||||
|
{{ formatTime(interval.start) }} to
|
||||||
|
<br />
|
||||||
|
{{ formatTime(interval.end) }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-calendar-scheduler>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
.q-calendar-scheduler__resource
|
||||||
|
background-color: $primary
|
||||||
|
color: white
|
||||||
|
font-weight: bold
|
||||||
|
</style>
|
||||||
40
app/pages/signup.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// NOTE: Password-based registration removed (magic link + OTP only).
|
||||||
|
// This page is a stub — registration is handled by admin invitation.
|
||||||
|
definePageMeta({ public: true, layout: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-layout>
|
||||||
|
<q-page-container>
|
||||||
|
<q-page class="flex bg-image flex-center">
|
||||||
|
<q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||||
|
<q-card-section>
|
||||||
|
<q-img fit="scale-down" src="/oysqn_logo.png" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-center q-pt-sm">
|
||||||
|
<div class="text-h6">Sign Up</div>
|
||||||
|
<div class="text-body2 q-mt-md">
|
||||||
|
Account registration is managed by the club administrator.
|
||||||
|
Please contact your club admin to request access.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="text-center">
|
||||||
|
<q-btn flat color="primary" label="Back to Login" to="/login" />
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-image {
|
||||||
|
background-image: url('~/assets/oys_lighthouse.jpg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position-x: center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
77
app/pages/terms-of-service.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ public: true, layout: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-layout>
|
||||||
|
<q-page-container>
|
||||||
|
<q-page padding>
|
||||||
|
<h1>Website Terms and Conditions of Use</h1>
|
||||||
|
|
||||||
|
<h2>1. Terms</h2>
|
||||||
|
<p>
|
||||||
|
By accessing this Website, accessible from https://undock.ca, you are
|
||||||
|
agreeing to be bound by these Website Terms and Conditions of Use and
|
||||||
|
agree that you are responsible for the agreement with any applicable
|
||||||
|
local laws. If you disagree with any of these terms, you are
|
||||||
|
prohibited from accessing this site. The materials contained in this
|
||||||
|
Website are protected by copyright and trade mark law.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Use License</h2>
|
||||||
|
<p>
|
||||||
|
Permission is granted to temporarily download one copy of the
|
||||||
|
materials on undock.ca's Website for personal, non-commercial
|
||||||
|
transitory viewing only. This is the grant of a license, not a
|
||||||
|
transfer of title, and under this license you may not:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>modify or copy the materials;</li>
|
||||||
|
<li>use the materials for any commercial purpose or for any public display;</li>
|
||||||
|
<li>attempt to reverse engineer any software contained on undock.ca's Website;</li>
|
||||||
|
<li>remove any copyright or other proprietary notations from the materials; or</li>
|
||||||
|
<li>transferring the materials to another person or "mirror" the materials on any other server.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Disclaimer</h2>
|
||||||
|
<p>
|
||||||
|
All the materials on undock.ca's Website are provided "as is". undock.ca makes no
|
||||||
|
warranties, may it be expressed or implied, therefore negates all other warranties.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Limitations</h2>
|
||||||
|
<p>
|
||||||
|
undock.ca or its suppliers will not be hold accountable for any damages that will
|
||||||
|
arise with the use or inability to use the materials on undock.ca's Website.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Revisions and Errata</h2>
|
||||||
|
<p>
|
||||||
|
The materials appearing on undock.ca's Website may include technical, typographical,
|
||||||
|
or photographic errors. undock.ca will not promise that any of the materials in this
|
||||||
|
Website are accurate, complete, or current.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Links</h2>
|
||||||
|
<p>
|
||||||
|
undock.ca has not reviewed all of the sites linked to its Website and is not
|
||||||
|
responsible for the contents of any such linked site.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Site Terms of Use Modifications</h2>
|
||||||
|
<p>
|
||||||
|
undock.ca may revise these Terms of Use for its Website at any time without prior notice.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Your Privacy</h2>
|
||||||
|
<p>Please read our <a href="/privacy-policy">Privacy Policy.</a></p>
|
||||||
|
|
||||||
|
<h2>9. Governing Law</h2>
|
||||||
|
<p>
|
||||||
|
Any claim related to undock.ca's Website shall be governed by the laws of ca without
|
||||||
|
regards to its conflict of law provisions.
|
||||||
|
</p>
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
15
app/plugins/appwrite.client.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useAuthStore } from '~/stores/auth';
|
||||||
|
import { initAppwriteClient } from '~/utils/appwrite';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(async () => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const endpoint = config.public.appwriteEndpoint as string;
|
||||||
|
const projectId = config.public.appwriteProjectId as string;
|
||||||
|
if (!endpoint || !projectId) {
|
||||||
|
console.error('Appwrite config missing — check NUXT_PUBLIC_APPWRITE_ENDPOINT and NUXT_PUBLIC_APPWRITE_PROJECT_ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initAppwriteClient(endpoint, projectId);
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
await authStore.init();
|
||||||
|
});
|
||||||
111
app/stores/auth.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ID, account, functions, teams } from '~/utils/appwrite';
|
||||||
|
import { ExecutionMethod, type Models } from 'appwrite';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useBoatStore } from './boat';
|
||||||
|
import { useReservationStore } from './reservation';
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const currentUser = ref<Models.User<Models.Preferences> | null>(null);
|
||||||
|
const currentUserTeams = ref<Models.TeamList<Models.Preferences> | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const userNames = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
currentUser.value = await account.get();
|
||||||
|
currentUserTeams.value = await teams.list();
|
||||||
|
await useBoatStore().fetchBoats();
|
||||||
|
await useReservationStore().fetchUserReservations();
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
currentUserTeams.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserTeamNames = computed(() =>
|
||||||
|
currentUserTeams.value
|
||||||
|
? currentUserTeams.value.teams.map((team) => team.name)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasRequiredRole = (requiredRoles: string[]): boolean => {
|
||||||
|
return requiredRoles.some((role) =>
|
||||||
|
currentUserTeamNames.value.includes(role)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createTokenSession(email: string) {
|
||||||
|
return await account.createEmailToken(ID.unique(), email);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMagicURLSession(email: string) {
|
||||||
|
return await account.createMagicURLToken(
|
||||||
|
ID.unique(),
|
||||||
|
email,
|
||||||
|
window.location.origin + '/auth/callback'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenLogin(userId: string, token: string) {
|
||||||
|
await account.createSession(userId, token);
|
||||||
|
await init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function magicURLLogin(userId: string, secret: string) {
|
||||||
|
await account.updateMagicURLSession(userId, secret);
|
||||||
|
await init();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserNameById(id: string | undefined | null): string {
|
||||||
|
if (!id) return 'No User';
|
||||||
|
try {
|
||||||
|
if (!userNames.value[id]) {
|
||||||
|
userNames.value[id] = 'Loading...';
|
||||||
|
functions
|
||||||
|
.createExecution(
|
||||||
|
'userinfo',
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
'/userinfo/' + id,
|
||||||
|
ExecutionMethod.GET
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.responseBody) {
|
||||||
|
userNames.value[id] = JSON.parse(res.responseBody).name;
|
||||||
|
} else {
|
||||||
|
console.error(res, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get username. Error: ' + e);
|
||||||
|
}
|
||||||
|
return userNames.value[id] ?? 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
return account.deleteSession('current').then(() => {
|
||||||
|
currentUser.value = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateName(name: string) {
|
||||||
|
await account.updateName(name);
|
||||||
|
currentUser.value = await account.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentUser,
|
||||||
|
getUserNameById,
|
||||||
|
hasRequiredRole,
|
||||||
|
updateName,
|
||||||
|
createTokenSession,
|
||||||
|
createMagicURLSession,
|
||||||
|
tokenLogin,
|
||||||
|
magicURLLogin,
|
||||||
|
logout,
|
||||||
|
init,
|
||||||
|
};
|
||||||
|
});
|
||||||
29
app/stores/boat.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { Boat } from '~/utils/boat.types';
|
||||||
|
|
||||||
|
export { type Boat } from '~/utils/boat.types';
|
||||||
|
|
||||||
|
export const useBoatStore = defineStore('boat', () => {
|
||||||
|
const boats = ref<Boat[]>([]);
|
||||||
|
|
||||||
|
async function fetchBoats() {
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.boat
|
||||||
|
);
|
||||||
|
boats.value = response.documents as unknown as Boat[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch boats', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBoatById = (id: string | null | undefined): Boat | null => {
|
||||||
|
if (!id) return null;
|
||||||
|
return boats.value?.find((b) => b.$id === id) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { boats, fetchBoats, getBoatById };
|
||||||
|
});
|
||||||
163
app/stores/interval.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type { Boat } from '~/utils/boat.types';
|
||||||
|
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { today } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import type { Interval } from '~/utils/schedule.types';
|
||||||
|
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||||
|
import { ID, Query } from 'appwrite';
|
||||||
|
import { useReservationStore } from './reservation';
|
||||||
|
import type { LoadingTypes } from '~/utils/misc';
|
||||||
|
import { useRealtimeStore } from './realtime';
|
||||||
|
|
||||||
|
export const useIntervalStore = defineStore('interval', () => {
|
||||||
|
const intervals = ref(new Map<string, Interval>());
|
||||||
|
const dateStatus = ref(new Map<string, LoadingTypes>());
|
||||||
|
|
||||||
|
const selectedDate = ref<string>(today());
|
||||||
|
|
||||||
|
const reservationStore = useReservationStore();
|
||||||
|
const realtimeStore = useRealtimeStore();
|
||||||
|
|
||||||
|
realtimeStore.register(
|
||||||
|
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.interval}.documents`,
|
||||||
|
(response) => {
|
||||||
|
const payload = response.payload as unknown as Interval;
|
||||||
|
if (!payload.$id) return;
|
||||||
|
if (
|
||||||
|
response.events.includes('databases.*.collections.*.documents.*.delete')
|
||||||
|
) {
|
||||||
|
intervals.value.delete(payload.$id);
|
||||||
|
} else {
|
||||||
|
intervals.value.set(payload.$id, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const getIntervals = (date: Timestamp | string, boat?: Boat) => {
|
||||||
|
const searchDate = typeof date === 'string' ? date : date.date;
|
||||||
|
const dayStart = new Date(searchDate + 'T00:00');
|
||||||
|
const dayEnd = new Date(searchDate + 'T23:59');
|
||||||
|
if (dateStatus.value.get(searchDate) === undefined) {
|
||||||
|
dateStatus.value.set(searchDate, 'pending');
|
||||||
|
fetchIntervals(searchDate);
|
||||||
|
}
|
||||||
|
return computed(() => {
|
||||||
|
return Array.from(intervals.value.values()).filter((interval) => {
|
||||||
|
const intervalStart = new Date(interval.start);
|
||||||
|
const intervalEnd = new Date(interval.end);
|
||||||
|
|
||||||
|
const isWithinDay = intervalStart < dayEnd && intervalEnd > dayStart;
|
||||||
|
const matchesBoat = boat ? boat.$id === interval.resource : true;
|
||||||
|
return isWithinDay && matchesBoat;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailableIntervals = (date: Timestamp | string, boat?: Boat) => {
|
||||||
|
return computed(() =>
|
||||||
|
getIntervals(date, boat).value.filter((interval) => {
|
||||||
|
return !reservationStore.isResourceTimeOverlapped(
|
||||||
|
interval.resource,
|
||||||
|
new Date(interval.start),
|
||||||
|
new Date(interval.end)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchInterval(id: string): Promise<Interval> {
|
||||||
|
return (await databases.getDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
id
|
||||||
|
)) as Interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchIntervals(dateString: string) {
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
[
|
||||||
|
Query.greaterThanEqual(
|
||||||
|
'end',
|
||||||
|
new Date(dateString + 'T00:00').toISOString()
|
||||||
|
),
|
||||||
|
Query.lessThanEqual(
|
||||||
|
'start',
|
||||||
|
new Date(dateString + 'T23:59').toISOString()
|
||||||
|
),
|
||||||
|
Query.limit(50),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
response.documents.forEach((d) =>
|
||||||
|
intervals.value.set(d.$id, d as unknown as Interval)
|
||||||
|
);
|
||||||
|
dateStatus.value.set(dateString, 'loaded');
|
||||||
|
console.info(`Loaded ${response.documents.length} intervals from server`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch intervals', error);
|
||||||
|
dateStatus.value.set(dateString, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createInterval = async (interval: Interval) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
ID.unique(),
|
||||||
|
interval
|
||||||
|
);
|
||||||
|
intervals.value.set(response.$id, response as unknown as Interval);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error creating Interval: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateInterval = async (interval: Interval) => {
|
||||||
|
try {
|
||||||
|
if (interval.$id) {
|
||||||
|
const response = await databases.updateDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
interval.$id,
|
||||||
|
{ ...interval, $id: undefined }
|
||||||
|
);
|
||||||
|
intervals.value.set(response.$id, response as unknown as Interval);
|
||||||
|
console.info(`Saved Interval: ${interval.$id}`);
|
||||||
|
} else {
|
||||||
|
console.error('Update interval called without an ID');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating Interval: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteInterval = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.interval,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
intervals.value.delete(id);
|
||||||
|
console.info(`Deleted interval: ${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting Interval: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getIntervals,
|
||||||
|
getAvailableIntervals,
|
||||||
|
fetchIntervals,
|
||||||
|
fetchInterval,
|
||||||
|
createInterval,
|
||||||
|
updateInterval,
|
||||||
|
deleteInterval,
|
||||||
|
selectedDate,
|
||||||
|
intervals,
|
||||||
|
};
|
||||||
|
});
|
||||||
101
app/stores/intervalTemplate.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { IntervalTemplate } from '~/utils/schedule.types';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||||
|
import type { Models } from 'appwrite';
|
||||||
|
import { ID } from 'appwrite';
|
||||||
|
import { arrayToTimeTuples } from '~/utils/schedule';
|
||||||
|
|
||||||
|
export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
|
||||||
|
const intervalTemplates = ref<IntervalTemplate[]>([]);
|
||||||
|
|
||||||
|
const getIntervalTemplates = (): Ref<IntervalTemplate[]> => {
|
||||||
|
if (!intervalTemplates.value) fetchIntervalTemplates();
|
||||||
|
return intervalTemplates;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchIntervalTemplates() {
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate
|
||||||
|
);
|
||||||
|
intervalTemplates.value = response.documents.map((d): IntervalTemplate => {
|
||||||
|
const doc = d as unknown as { timeTuple: string[] } & Models.Document;
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
timeTuples: arrayToTimeTuples(doc.timeTuple),
|
||||||
|
} as unknown as IntervalTemplate;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch timeblock templates', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createIntervalTemplate = async (template: IntervalTemplate) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate,
|
||||||
|
ID.unique(),
|
||||||
|
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
|
||||||
|
);
|
||||||
|
intervalTemplates.value.push(response as unknown as IntervalTemplate);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error creating IntervalTemplate: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteIntervalTemplate = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
intervalTemplates.value = intervalTemplates.value.filter(
|
||||||
|
(template) => template.$id !== id
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting IntervalTemplate: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateIntervalTemplate = async (
|
||||||
|
template: IntervalTemplate,
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.updateDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.intervalTemplate,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
name: template.name,
|
||||||
|
timeTuple: template.timeTuples.flat(2),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
intervalTemplates.value = intervalTemplates.value.map((b) =>
|
||||||
|
b.$id !== id
|
||||||
|
? b
|
||||||
|
: ({
|
||||||
|
...response,
|
||||||
|
timeTuples: arrayToTimeTuples(
|
||||||
|
(response as unknown as { timeTuple: string[] }).timeTuple
|
||||||
|
),
|
||||||
|
} as unknown as IntervalTemplate)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating IntervalTemplate: ' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getIntervalTemplates,
|
||||||
|
fetchIntervalTemplates,
|
||||||
|
createIntervalTemplate,
|
||||||
|
deleteIntervalTemplate,
|
||||||
|
updateIntervalTemplate,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -18,16 +18,4 @@ export const useMemberProfileStore = defineStore('memberProfile', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
...getSampleData(),
|
...getSampleData(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// getters: {
|
|
||||||
// doubleCount (state) {
|
|
||||||
// return state.counter * 2;
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
|
|
||||||
// actions: {
|
|
||||||
// increment () {
|
|
||||||
// this.counter++;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
});
|
});
|
||||||
20
app/stores/realtime.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { client } from '~/utils/appwrite';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { RealtimeResponseEvent } from 'appwrite';
|
||||||
|
|
||||||
|
export const useRealtimeStore = defineStore('realtime', () => {
|
||||||
|
const subscriptions = ref<Map<string, () => void>>(new Map());
|
||||||
|
|
||||||
|
const register = (
|
||||||
|
channel: string,
|
||||||
|
fn: (response: RealtimeResponseEvent<unknown>) => void
|
||||||
|
) => {
|
||||||
|
if (subscriptions.value.has(channel)) return;
|
||||||
|
subscriptions.value.set(channel, client.subscribe(channel, fn));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
register,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -20,26 +20,26 @@ function getSampleData(): ReferenceEntry[] {
|
|||||||
content: `Its hard to imagine that a modern 27 foot sailboat with a classic look, superb
|
content: `Its hard to imagine that a modern 27 foot sailboat with a classic look, superb
|
||||||
stability, and easy to manage rig is such a fast boat. 123-126 PHRF. No longer do you
|
stability, and easy to manage rig is such a fast boat. 123-126 PHRF. No longer do you
|
||||||
have to substitute speed for comfort, or own separate boats for racing and cruising. The
|
have to substitute speed for comfort, or own separate boats for racing and cruising. The
|
||||||
8\’ long cockpit seats 4 to 5 comfortably. Below deck you can sleep 5. And with head and
|
8' long cockpit seats 4 to 5 comfortably. Below deck you can sleep 5. And with head and
|
||||||
stove, the J/27 is the perfect weekend cruiser.
|
stove, the J/27 is the perfect weekend cruiser.
|
||||||
|
|
||||||
Fun and Fast. There are some impressive victories to back this up, but that doesn\’t
|
Fun and Fast. There are some impressive victories to back this up, but that doesn't
|
||||||
tell the whole story. The J/27 is fun and responsive. Nothing is more exhilarating than
|
tell the whole story. The J/27 is fun and responsive. Nothing is more exhilarating than
|
||||||
popping the J/27\’s kite in a good breeze for a downhill sleigh ride. 15+ knots planing
|
popping the J/27's kite in a good breeze for a downhill sleigh ride. 15+ knots planing
|
||||||
off the wave-tops is easy. And most importantly, this off-wind speed doesn\’t sacrifice
|
off the wave-tops is easy. And most importantly, this off-wind speed doesn't sacrifice
|
||||||
upwind performance. Going to windward in the J/27 is a dream, it has the solid, balanced
|
upwind performance. Going to windward in the J/27 is a dream, it has the solid, balanced
|
||||||
"feel" of a traditional keelboat. The J/27 points higher and goes faster than many 30-35
|
"feel" of a traditional keelboat. The J/27 points higher and goes faster than many 30-35
|
||||||
footers!
|
footers!
|
||||||
|
|
||||||
One-Design Racing. Even more fun is sailing a one-design race around the buoys. The
|
One-Design Racing. Even more fun is sailing a one-design race around the buoys. The
|
||||||
J/27\’s close-windedness makes it very tactical, as even 5 degree wind shifts bring
|
J/27's close-windedness makes it very tactical, as even 5 degree wind shifts bring
|
||||||
significant gains. Then off wind, you quickly learn to play gibe angles as the boat\’s
|
significant gains. Then off wind, you quickly learn to play gibe angles as the boat's
|
||||||
acceleration gains you valuable ground on the competition. The J/27 is remarkably agile
|
acceleration gains you valuable ground on the competition. The J/27 is remarkably agile
|
||||||
and responsive in lighter winds, which is unusual for a boat that feels so solid.
|
and responsive in lighter winds, which is unusual for a boat that feels so solid.
|
||||||
|
|
||||||
All-Day Comfort. Sailing past larger boats is always satisfying... especially when it\’s
|
All-Day Comfort. Sailing past larger boats is always satisfying... especially when it's
|
||||||
effortless and you can\’t be written off as being wet and uncomfortable. Design is the
|
effortless and you can't be written off as being wet and uncomfortable. Design is the
|
||||||
difference. It\’s all done from a cockpit which holds several people more than is possible
|
difference. It's all done from a cockpit which holds several people more than is possible
|
||||||
on other 27-footers. Correctly angled backrests and decks at elbow level provide restful
|
on other 27-footers. Correctly angled backrests and decks at elbow level provide restful
|
||||||
and secure seating. Harken mainsheet, vang, traveler, and backstay systems; four Barient
|
and secure seating. Harken mainsheet, vang, traveler, and backstay systems; four Barient
|
||||||
winches; a beautiful double spreader, tapered, fractional rig spar by Hall . . . make
|
winches; a beautiful double spreader, tapered, fractional rig spar by Hall . . . make
|
||||||
@@ -57,14 +57,14 @@ function getSampleData(): ReferenceEntry[] {
|
|||||||
starboard is a comfortable quarter berth. Enough room below for a family of four or a
|
starboard is a comfortable quarter berth. Enough room below for a family of four or a
|
||||||
couple for a nice weekend romp to your favorite sailing anchorage.
|
couple for a nice weekend romp to your favorite sailing anchorage.
|
||||||
|
|
||||||
Durable and Stable. The J/27\’s secure big boat feel is created by concentrating 1530
|
Durable and Stable. The J/27's secure big boat feel is created by concentrating 1530
|
||||||
pounds of lead very low in the keel while using high strength to eight ratio laminates
|
pounds of lead very low in the keel while using high strength to eight ratio laminates
|
||||||
in the hull. Unidirectional E-glass on either side of pre-sealed Baltek CK57 aircraft
|
in the hull. Unidirectional E-glass on either side of pre-sealed Baltek CK57 aircraft
|
||||||
grade, Lloyd\’s approved, end grain balsa sandwich construction means superior torsion and
|
grade, Lloyd's approved, end grain balsa sandwich construction means superior torsion and
|
||||||
impact resistance. Light ends, low freeboard, and the low center of gravity of a lead keel
|
impact resistance. Light ends, low freeboard, and the low center of gravity of a lead keel
|
||||||
coupled with low wetted surface and a generous sailplan of 362 sq. ft. achieves exceptional
|
coupled with low wetted surface and a generous sailplan of 362 sq. ft. achieves exceptional
|
||||||
sail area and stability relative to displacement. Hence, sparkling performance in both
|
sail area and stability relative to displacement. Hence, sparkling performance in both
|
||||||
light and heavy air...something that doesn\’t happen with iron keels and box-like hulls.
|
light and heavy air...something that doesn't happen with iron keels and box-like hulls.
|
||||||
|
|
||||||
Strong Class Strict Rules. The J/27 Class Association, owner driven and over 190 boats
|
Strong Class Strict Rules. The J/27 Class Association, owner driven and over 190 boats
|
||||||
strong, sail in North American, Midwinter, and Regional championships. A superb J/27 Class
|
strong, sail in North American, Midwinter, and Regional championships. A superb J/27 Class
|
||||||
@@ -113,15 +113,11 @@ export const useReferenceStore = defineStore('reference', {
|
|||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
getCategory(state) {
|
getCategory(state) {
|
||||||
(category: string) => {
|
return (category: string) => {
|
||||||
return state.allItems.filter((c) => c.category === category);
|
return state.allItems.filter((c) => c.category === category);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {},
|
||||||
// increment () {
|
|
||||||
// this.counter++;
|
|
||||||
// }
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
283
app/stores/reservation.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import type { Reservation } from '~/utils/schedule.types';
|
||||||
|
import type { ComputedRef } from 'vue';
|
||||||
|
import { computed, reactive } from 'vue';
|
||||||
|
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||||
|
import { ID, Query } from 'appwrite';
|
||||||
|
import { date, useQuasar } from 'quasar';
|
||||||
|
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import { parseDate, today } from '@quasar/quasar-ui-qcalendar';
|
||||||
|
import type { LoadingTypes } from '~/utils/misc';
|
||||||
|
import { useAuthStore } from './auth';
|
||||||
|
import { isPast } from '~/utils/schedule';
|
||||||
|
import { useRealtimeStore } from './realtime';
|
||||||
|
|
||||||
|
export const useReservationStore = defineStore('reservation', () => {
|
||||||
|
const reservations = reactive<Map<string, Reservation>>(new Map());
|
||||||
|
const datesLoaded = reactive<Record<string, LoadingTypes>>({});
|
||||||
|
const userReservations = reactive<Map<string, Reservation>>(new Map());
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const realtimeStore = useRealtimeStore();
|
||||||
|
|
||||||
|
realtimeStore.register(
|
||||||
|
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.reservation}.documents`,
|
||||||
|
(response) => {
|
||||||
|
const payload = response.payload as unknown as Reservation;
|
||||||
|
if (payload.$id) {
|
||||||
|
if (
|
||||||
|
response.events.includes(
|
||||||
|
'databases.*.collections.*.documents.*.delete'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
reservations.delete(payload.$id);
|
||||||
|
userReservations.delete(payload.$id);
|
||||||
|
} else {
|
||||||
|
reservations.set(payload.$id, payload);
|
||||||
|
if (payload.user === authStore.currentUser?.$id)
|
||||||
|
userReservations.set(payload.$id, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchReservationsForDateRange = async (
|
||||||
|
start: string = today(),
|
||||||
|
end: string = start
|
||||||
|
) => {
|
||||||
|
const startDate = new Date(start < end ? start : end + 'T00:00');
|
||||||
|
const endDate = new Date(start < end ? end : start + 'T23:59');
|
||||||
|
|
||||||
|
if (getUnloadedDates(startDate, endDate).length === 0) return;
|
||||||
|
|
||||||
|
setDateLoaded(startDate, endDate, 'pending');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
[
|
||||||
|
Query.greaterThanEqual('end', startDate.toISOString()),
|
||||||
|
Query.lessThanEqual('start', endDate.toISOString()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
response.documents.forEach((d) =>
|
||||||
|
reservations.set(d.$id, d as unknown as Reservation)
|
||||||
|
);
|
||||||
|
setDateLoaded(startDate, endDate, 'loaded');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch reservations', error);
|
||||||
|
setDateLoaded(startDate, endDate, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReservationById = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await databases.getDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
return response as unknown as Reservation;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch reservation: ', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOrUpdateReservation = async (
|
||||||
|
reservation: Reservation
|
||||||
|
): Promise<Reservation> => {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
if (reservation.$id) {
|
||||||
|
response = await databases.updateDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
reservation.$id,
|
||||||
|
reservation
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
response = await databases.createDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
ID.unique(),
|
||||||
|
reservation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reservations.set(response.$id, response as unknown as Reservation);
|
||||||
|
userReservations.set(response.$id, response as unknown as Reservation);
|
||||||
|
console.info('Reservation booked: ', response);
|
||||||
|
return response as unknown as Reservation;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error creating Reservation: ' + e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteReservation = async (
|
||||||
|
reservation: string | Reservation | null | undefined
|
||||||
|
) => {
|
||||||
|
if (!reservation) return false;
|
||||||
|
const id = typeof reservation === 'string' ? reservation : reservation.$id;
|
||||||
|
if (!id) return false;
|
||||||
|
|
||||||
|
const status = $q.notify({
|
||||||
|
color: 'secondary',
|
||||||
|
textColor: 'white',
|
||||||
|
message: 'Deleting Reservation',
|
||||||
|
spinner: true,
|
||||||
|
closeBtn: 'Dismiss',
|
||||||
|
position: 'top',
|
||||||
|
timeout: 0,
|
||||||
|
group: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
reservations.delete(id);
|
||||||
|
userReservations.delete(id);
|
||||||
|
console.info(`Deleted reservation: ${id}`);
|
||||||
|
status({
|
||||||
|
color: 'warning',
|
||||||
|
message: 'Reservation Deleted',
|
||||||
|
spinner: false,
|
||||||
|
icon: 'delete',
|
||||||
|
timeout: 4000,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting reservation: ' + e);
|
||||||
|
status({
|
||||||
|
color: 'negative',
|
||||||
|
message: 'Failed to Delete Reservation',
|
||||||
|
spinner: false,
|
||||||
|
icon: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDateLoaded = (start: Date, end: Date, state: LoadingTypes) => {
|
||||||
|
if (start > end) return [];
|
||||||
|
let curDate = start;
|
||||||
|
while (curDate < end) {
|
||||||
|
datesLoaded[(parseDate(curDate) as Timestamp).date] = state;
|
||||||
|
curDate = date.addToDate(curDate, { days: 1 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUnloadedDates = (start: Date, end: Date): string[] => {
|
||||||
|
if (start > end) return [];
|
||||||
|
let curDate = start;
|
||||||
|
const unloaded = [];
|
||||||
|
while (curDate < end) {
|
||||||
|
const parsedDate = (parseDate(curDate) as Timestamp).date;
|
||||||
|
if (datesLoaded[parsedDate] === undefined) unloaded.push(parsedDate);
|
||||||
|
curDate = date.addToDate(curDate, { days: 1 });
|
||||||
|
}
|
||||||
|
return unloaded;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReservationsByDate = (
|
||||||
|
searchDate: string,
|
||||||
|
boat?: string
|
||||||
|
): ComputedRef<Reservation[]> => {
|
||||||
|
if (!datesLoaded[searchDate]) {
|
||||||
|
fetchReservationsForDateRange(searchDate);
|
||||||
|
}
|
||||||
|
const dayStart = new Date(searchDate + 'T00:00');
|
||||||
|
const dayEnd = new Date(searchDate + 'T23:59');
|
||||||
|
|
||||||
|
return computed(() => {
|
||||||
|
return Array.from(reservations.values()).filter((reservation) => {
|
||||||
|
const reservationStart = new Date(reservation.start);
|
||||||
|
const reservationEnd = new Date(reservation.end);
|
||||||
|
|
||||||
|
const isWithinDay =
|
||||||
|
reservationStart < dayEnd && reservationEnd > dayStart;
|
||||||
|
const matchesBoat = boat ? boat === reservation.resource : true;
|
||||||
|
return isWithinDay && matchesBoat;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConflictingReservations = (
|
||||||
|
resource: string,
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): Reservation[] => {
|
||||||
|
return Array.from(reservations.values()).filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.resource === resource &&
|
||||||
|
new Date(entry.start) < end &&
|
||||||
|
new Date(entry.end) > start
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isResourceTimeOverlapped = (
|
||||||
|
resource: string,
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): boolean => {
|
||||||
|
return getConflictingReservations(resource, start, end).length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isReservationOverlapped = (res: Reservation): boolean => {
|
||||||
|
return isResourceTimeOverlapped(
|
||||||
|
res.resource,
|
||||||
|
new Date(res.start),
|
||||||
|
new Date(res.end)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUserReservations = async () => {
|
||||||
|
if (!authStore.currentUser) return;
|
||||||
|
try {
|
||||||
|
const response = await databases.listDocuments(
|
||||||
|
AppwriteIds.databaseId,
|
||||||
|
AppwriteIds.collection.reservation,
|
||||||
|
[Query.equal('user', authStore.currentUser.$id)]
|
||||||
|
);
|
||||||
|
response.documents.forEach((d) =>
|
||||||
|
userReservations.set(d.$id, d as unknown as Reservation)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch reservations for user: ', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedUserReservations = computed((): Reservation[] =>
|
||||||
|
[...userReservations.values()].sort(
|
||||||
|
(a, b) => new Date(b.start).getTime() - new Date(a.start).getTime()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const futureUserReservations = computed((): Reservation[] => {
|
||||||
|
if (!sortedUserReservations.value) return [];
|
||||||
|
return sortedUserReservations.value.filter((b) => !isPast(b.end));
|
||||||
|
});
|
||||||
|
|
||||||
|
const pastUserReservations = computed((): Reservation[] => {
|
||||||
|
if (!sortedUserReservations.value) return [];
|
||||||
|
return sortedUserReservations.value?.filter((b) => isPast(b.end));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getReservationsByDate,
|
||||||
|
getReservationById,
|
||||||
|
createOrUpdateReservation,
|
||||||
|
deleteReservation,
|
||||||
|
fetchReservationsForDateRange,
|
||||||
|
isReservationOverlapped,
|
||||||
|
isResourceTimeOverlapped,
|
||||||
|
getConflictingReservations,
|
||||||
|
fetchUserReservations,
|
||||||
|
sortedUserReservations,
|
||||||
|
futureUserReservations,
|
||||||
|
pastUserReservations,
|
||||||
|
userReservations,
|
||||||
|
};
|
||||||
|
});
|
||||||
41
app/utils/appwrite.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Client, Account, Databases, Functions, ID, Teams } from 'appwrite';
|
||||||
|
|
||||||
|
const client = new Client();
|
||||||
|
|
||||||
|
function initAppwriteClient(endpoint: string, projectId: string) {
|
||||||
|
client.setEndpoint(endpoint).setProject(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppwriteIDConfig = {
|
||||||
|
databaseId: string;
|
||||||
|
collection: {
|
||||||
|
boat: string;
|
||||||
|
reservation: string;
|
||||||
|
interval: string;
|
||||||
|
intervalTemplate: string;
|
||||||
|
// task, taskTags, skillTags — parked; collections not yet created in bab_prod
|
||||||
|
};
|
||||||
|
function: {
|
||||||
|
userinfo: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppwriteIds: AppwriteIDConfig = {
|
||||||
|
databaseId: 'bab_prod',
|
||||||
|
collection: {
|
||||||
|
boat: 'boat',
|
||||||
|
reservation: 'reservation',
|
||||||
|
interval: 'interval',
|
||||||
|
intervalTemplate: 'intervalTemplate',
|
||||||
|
},
|
||||||
|
function: {
|
||||||
|
userinfo: 'userinfo',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const account = new Account(client);
|
||||||
|
const databases = new Databases(client);
|
||||||
|
const functions = new Functions(client);
|
||||||
|
const teams = new Teams(client);
|
||||||
|
|
||||||
|
export { client, account, databases, functions, teams, ID, AppwriteIds, initAppwriteClient };
|
||||||
20
app/utils/boat.types.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Models } from 'appwrite';
|
||||||
|
|
||||||
|
export interface Boat extends Models.Document {
|
||||||
|
$id: string;
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
class?: string;
|
||||||
|
year?: number;
|
||||||
|
imgSrc?: string;
|
||||||
|
iconSrc?: string;
|
||||||
|
bookingAvailable: boolean;
|
||||||
|
requiredCerts: string[];
|
||||||
|
maxPassengers: number;
|
||||||
|
defects: {
|
||||||
|
type: string;
|
||||||
|
severity: string;
|
||||||
|
description: string;
|
||||||
|
detail?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
7
app/utils/misc.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function getNewId(): string {
|
||||||
|
return [...Array(20)]
|
||||||
|
.map(() => Math.floor(Math.random() * 16).toString(16))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadingTypes = 'loaded' | 'pending' | 'error' | undefined;
|
||||||
112
app/utils/navlinks.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useAuthStore } from '~/stores/auth';
|
||||||
|
|
||||||
|
export type Link = {
|
||||||
|
name: string;
|
||||||
|
to?: string;
|
||||||
|
icon: string;
|
||||||
|
front_links?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
color?: string;
|
||||||
|
sublinks?: Link[];
|
||||||
|
requiredRoles?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const links: Link[] = [
|
||||||
|
{
|
||||||
|
name: 'Home',
|
||||||
|
to: '/',
|
||||||
|
icon: 'home',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Profile',
|
||||||
|
to: '/profile',
|
||||||
|
icon: 'account_circle',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Boats',
|
||||||
|
to: '/boat',
|
||||||
|
icon: 'sailing',
|
||||||
|
front_links: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Schedule',
|
||||||
|
to: '/schedule',
|
||||||
|
icon: 'calendar_month',
|
||||||
|
front_links: true,
|
||||||
|
enabled: true,
|
||||||
|
sublinks: [
|
||||||
|
{ name: 'My View', to: '/schedule/list', icon: 'list', front_links: false, enabled: true },
|
||||||
|
{ name: 'Book', to: '/schedule/book', icon: 'more_time', front_links: false, enabled: true },
|
||||||
|
{ name: 'Calendar', to: '/schedule/view', icon: 'calendar_month', front_links: false, enabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Certifications',
|
||||||
|
to: '/certification',
|
||||||
|
icon: 'verified',
|
||||||
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Checklists',
|
||||||
|
to: '/checklist',
|
||||||
|
icon: 'checklist',
|
||||||
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Reference',
|
||||||
|
to: '/reference',
|
||||||
|
icon: 'info_outline',
|
||||||
|
front_links: true,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Manage',
|
||||||
|
icon: 'tune',
|
||||||
|
enabled: true,
|
||||||
|
requiredRoles: ['Schedule Admins'],
|
||||||
|
color: 'negative',
|
||||||
|
sublinks: [
|
||||||
|
{
|
||||||
|
name: 'Schedule',
|
||||||
|
to: '/schedule/manage',
|
||||||
|
icon: 'edit_calendar',
|
||||||
|
front_links: false,
|
||||||
|
enabled: true,
|
||||||
|
color: 'accent',
|
||||||
|
requiredRoles: ['Schedule Admins'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function useNavLinks() {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
function hasRole(roles: string[] | undefined) {
|
||||||
|
if (roles === undefined) return true;
|
||||||
|
return authStore.hasRequiredRole(roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledLinks = links
|
||||||
|
.filter((link) => link.enabled)
|
||||||
|
.map((link) => {
|
||||||
|
if (link.sublinks) {
|
||||||
|
return {
|
||||||
|
...link,
|
||||||
|
sublinks: link.sublinks.filter(
|
||||||
|
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return link;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { enabledLinks };
|
||||||
|
}
|
||||||
75
app/utils/schedule.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { date } from 'quasar';
|
||||||
|
import type { Boat } from '~/utils/boat.types';
|
||||||
|
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
|
||||||
|
|
||||||
|
export function arrayToTimeTuples(arr: string[]) {
|
||||||
|
const timeTuples: TimeTuple[] = [];
|
||||||
|
for (let i = 0; i < arr.length; i += 2) {
|
||||||
|
timeTuples.push([arr[i]!, arr[i + 1]!]);
|
||||||
|
}
|
||||||
|
return timeTuples;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeTuplesOverlapped(tuples: TimeTuple[]): Interval[] {
|
||||||
|
return intervalsOverlapped(
|
||||||
|
tuples.map((tuples) => {
|
||||||
|
return {
|
||||||
|
resource: '',
|
||||||
|
start: '01/01/2001 ' + tuples[0],
|
||||||
|
end: '01/01/2001 ' + tuples[1],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
).map((t) => {
|
||||||
|
return { ...t, start: t.start.split(' ')[1]!, end: t.end.split(' ')[1]! };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intervalsOverlapped(blocks: Interval[]): Interval[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
blocks
|
||||||
|
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
|
||||||
|
.reduce((acc: Interval[], block, i, arr) => {
|
||||||
|
if (i > 0 && block.start < arr[i - 1]!.end)
|
||||||
|
acc.push(arr[i - 1]!, block);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyTimeTuples(tuples: TimeTuple[]): TimeTuple[] {
|
||||||
|
return tuples.map((t) => Object.assign([], t));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyIntervalTemplate(template: IntervalTemplate): IntervalTemplate {
|
||||||
|
return {
|
||||||
|
...template,
|
||||||
|
timeTuples: copyTimeTuples(template.timeTuples || []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInterval(resource: Boat, time: TimeTuple, blockDate: string): Interval {
|
||||||
|
return {
|
||||||
|
resource: resource.$id,
|
||||||
|
start: new Date(blockDate + 'T' + time[0]).toISOString(),
|
||||||
|
end: new Date(blockDate + 'T' + time[1]).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPast = (itemDate: Date | string): boolean => {
|
||||||
|
if (!(itemDate instanceof Date)) {
|
||||||
|
itemDate = new Date(itemDate);
|
||||||
|
}
|
||||||
|
return itemDate < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDate(inputDate: string | undefined): string {
|
||||||
|
if (!inputDate) return '';
|
||||||
|
return date.formatDate(new Date(inputDate), 'ddd MMM Do hh:mm A');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(inputDate: string | undefined): string {
|
||||||
|
if (!inputDate) return '';
|
||||||
|
return date.formatDate(new Date(inputDate), 'hh:mm A');
|
||||||
|
}
|
||||||
28
app/utils/schedule.types.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Models } from 'appwrite';
|
||||||
|
|
||||||
|
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
||||||
|
export type Reservation = Interval & {
|
||||||
|
user: string;
|
||||||
|
status?: StatusTypes;
|
||||||
|
reason: string;
|
||||||
|
comment: string;
|
||||||
|
members?: string[];
|
||||||
|
guests?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 24 hrs in advance only 2 weekday, and 1 weekend slot
|
||||||
|
// Within 24 hrs, any available slot
|
||||||
|
|
||||||
|
export type TimeTuple = [start: string, end: string];
|
||||||
|
|
||||||
|
export type Interval = Partial<Models.Document> & {
|
||||||
|
resource: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
user?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntervalTemplate = Partial<Models.Document> & {
|
||||||
|
name: string;
|
||||||
|
timeTuples: TimeTuple[];
|
||||||
|
};
|
||||||
1
app/utils/version.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const APP_VERSION = '0.0.0';
|
||||||
4
appwrite.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"projectId": "65ede55a213134f2b688",
|
||||||
|
"projectName": ""
|
||||||
|
}
|
||||||
3
backup/.env
Normal 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
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"projectId": "65ede55a213134f2b688",
|
||||||
|
"projectName": ""
|
||||||
|
}
|
||||||
88
docs/archive/handoffs/handoff-2026-03-15-auth-magic-link.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Session Handoff: Auth Refactor — Magic Link & Cleanup
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Session Duration:** ~1 hour
|
||||||
|
**Session Focus:** Remove Google/Discord OAuth, add magic link login, add About dialog
|
||||||
|
**Context Usage at Handoff:** ~30%
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. Analyzed full auth flow (store, boot, login page, router guard) → no output file, inline analysis
|
||||||
|
2. Removed Google OAuth → deleted `src/components/GoogleOauthComponent.vue`
|
||||||
|
3. Removed Discord OAuth → deleted `src/components/DiscordOauthComponent.vue`
|
||||||
|
4. Removed `googleLogin`, `discordLogin` from auth store → `src/stores/auth.ts`
|
||||||
|
5. Removed `OAuthProvider` import from auth store → `src/stores/auth.ts`
|
||||||
|
6. Added `createMagicURLSession()` to auth store (calls `account.createMagicURLToken`) → `src/stores/auth.ts`
|
||||||
|
7. Added `magicURLLogin()` to auth store (calls `account.updateMagicURLSession`) → `src/stores/auth.ts`
|
||||||
|
8. Updated `LoginPage.vue` — removed OAuth component imports/usage, added "Send Magic Link" button, added `onMounted` handler to detect magic link callback params and call `magicURLLogin` → `src/pages/LoginPage.vue`
|
||||||
|
9. Added "About" item to left drawer → `src/components/LeftDrawer.vue` — opens a Quasar Dialog with app name, version, description
|
||||||
|
10. Converted `src/version.js` → `src/version.ts` to eliminate TS hint
|
||||||
|
11. Updated `generate-version.js` to write to `src/version.ts` → `generate-version.js`
|
||||||
|
12. Fixed stale import in `SignupPage.vue` (`src/version.js` → `src/version`) → `src/pages/SignupPage.vue`
|
||||||
|
13. Fixed `LeftDrawer.vue` import path `boot/appwrite` → `src/boot/appwrite` (was a TS module resolution error)
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
|
||||||
|
- `.env.local` not being picked up by `quasar dev`: user interrupted the fix (was about to add `require('dotenv').config(...)` to `quasar.config.js`). **Status: UNRESOLVED.** User stopped this change — may prefer a different approach or wants to investigate themselves.
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
|
||||||
|
- Use `account.updateMagicURLSession(userId, secret)` (not `createSession`) for magic link completion — BECAUSE Appwrite SDK v14 uses a separate method for magic URL vs OTP token sessions. `createSession` is for OTP only.
|
||||||
|
- Magic link callback URL = `window.location.origin + '/login'` — Appwrite appends `?userId=xxx&secret=xxx` and the `onMounted` handler in LoginPage detects and consumes these.
|
||||||
|
- Keep OTP code flow alongside magic link — user did not ask to remove it; both are available.
|
||||||
|
- About dialog placed in LeftDrawer (not a separate page) — appropriate pattern for simple info display in a mobile app.
|
||||||
|
|
||||||
|
## Key Numbers Generated or Discovered This Session
|
||||||
|
|
||||||
|
- Appwrite SDK version: `^14.0.1`
|
||||||
|
- `@quasar/app-vite` version: `^1.9.1`
|
||||||
|
- App version string source: `src/version.ts`, written by `generate-version.js` (takes version as CLI arg)
|
||||||
|
- Dev server port: `4000` (set in `quasar.config.js` `devServer.strictport`)
|
||||||
|
|
||||||
|
## Conditional Logic Established
|
||||||
|
|
||||||
|
- IF magic link callback detected (`query.userId && query.secret` in route on LoginPage mount) THEN call `magicURLLogin(userId, secret)` BECAUSE this uses `updateMagicURLSession` which is the correct Appwrite v14 API.
|
||||||
|
- IF user enters email and clicks "Send Code" THEN OTP flow runs (6-digit code emailed, entered in second input field).
|
||||||
|
- IF user enters email and clicks "Send Magic Link" THEN magic link email sent; user clicks link; page reloads at `/login?userId=xxx&secret=xxx`; `onMounted` auto-completes login.
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `src/stores/auth.ts` | Modified | Removed `googleLogin`, `discordLogin`, `OAuthProvider` import; added `createMagicURLSession`, `magicURLLogin` |
|
||||||
|
| `src/pages/LoginPage.vue` | Modified | Removed OAuth components; added magic link button and `onMounted` callback handler |
|
||||||
|
| `src/components/LeftDrawer.vue` | Modified | Added "About" menu item that opens info dialog with version; fixed boot import path |
|
||||||
|
| `src/components/GoogleOauthComponent.vue` | Deleted | No longer used |
|
||||||
|
| `src/components/DiscordOauthComponent.vue` | Deleted | No longer used |
|
||||||
|
| `src/version.ts` | Renamed (was `.js`) | Eliminates TypeScript implicit-any hint |
|
||||||
|
| `src/pages/SignupPage.vue` | Modified | Updated `version.js` import to `version` |
|
||||||
|
| `generate-version.js` | Modified | Now writes to `src/version.ts` instead of `src/version.js` |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
1. **First**: Verify magic link flow end-to-end in dev environment (send link, click it, confirm auto-login works)
|
||||||
|
2. **Then**: Resolve `.env.local` not being picked up — options are: (a) add `require('dotenv').config({ path: '.env.local' })` to top of `quasar.config.js`, or (b) use `.env` instead of `.env.local`, or (c) investigate if `@quasar/app-vite` 1.9.x has a bug
|
||||||
|
3. **Then**: Check if `SignupPage.vue` / `register()` flow is still intended — it creates email+password accounts but the login page only offers passwordless flows; this is an inconsistency
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
|
||||||
|
- [ ] Should the OTP (6-digit code) flow be kept, or replaced entirely by magic link? — impacts LoginPage UX
|
||||||
|
- [ ] Should the SignupPage (email+password registration) be removed in favour of magic link only? — impacts `src/pages/SignupPage.vue`, `src/stores/auth.ts` `register()`, router `/signup` route
|
||||||
|
- [ ] Should "Forgot Password?" link be removed from LoginPage now that magic link is the primary flow? — it was already removed from LoginPage in this session (not present in current code)
|
||||||
|
- [ ] `.env.local` fix approach — user stopped the `dotenv` approach; confirm preferred method
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
|
||||||
|
- ASSUMED: `window.location.origin` is correct for magic link callback URL in all deployment environments — validate that prod URL matches what Appwrite Console has whitelisted as a redirect domain
|
||||||
|
- ASSUMED: Appwrite project has magic URL tokens enabled — validate in Appwrite Console → Auth settings
|
||||||
|
|
||||||
|
## What NOT to Re-Read
|
||||||
|
|
||||||
|
- `src/components/GoogleOauthComponent.vue` — deleted
|
||||||
|
- `src/components/DiscordOauthComponent.vue` — deleted
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
|
||||||
|
- `src/stores/auth.ts` — primary auth logic, fully refactored this session
|
||||||
|
- `src/pages/LoginPage.vue` — magic link + OTP UI, modified this session
|
||||||
|
- `src/boot/appwrite.ts` — contains duplicate `login()` function (email/password) that may be dead code post-refactor
|
||||||
|
- `quasar.config.js` — if resolving `.env.local` issue
|
||||||
101
docs/archive/handoffs/handoff-2026-03-15-build-fixes.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Session Handoff: Build Fixes & Dev Environment
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Session Duration:** ~2 hours
|
||||||
|
**Session Focus:** Resolve all TypeScript/ESLint build errors from dependency updates; fix dev server startup
|
||||||
|
**Context Usage at Handoff:** Medium
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. Fixed 30 TypeScript errors (14 files) → build now passes with 0 errors
|
||||||
|
2. Fixed 12 ESLint problems (6 errors, 6 warnings) → 0 remaining
|
||||||
|
3. Fixed `quasar dev` startup error (`FlatESLint is not a constructor`) → downgraded ESLint v10→v9
|
||||||
|
4. Fixed missing Appwrite env vars in `.env.local` → app connects to backend on dev
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
|
||||||
|
- **Build**: CLEAN — `yarn quasar build` exits 0, no TS or ESLint errors
|
||||||
|
- **Dev server**: FUNCTIONAL — `quasar dev` starts without errors; ESLint inline checking via `vite-plugin-checker` is restored
|
||||||
|
- **Runtime**: UNTESTED this session — app has not been manually tested against the dev Appwrite backend
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
|
||||||
|
- **`as unknown as Type` for all Appwrite store casts** — CONFIRMED: Appwrite v23 made `DefaultDocument` strict; it no longer overlaps domain types, so the double-cast is required. Applied to: `boat.ts`, `interval.ts`, `intervalTemplate.ts`, `reservation.ts`, `task.ts`
|
||||||
|
- **ESLint downgraded v10.0.3 → v9.39.4** — CONFIRMED: `vite-plugin-checker` v0.12.0 calls `FlatESLint` which was merged back into `ESLint` in v10; v9 preserves the API. Also downgraded `@eslint/js` (v10→v9) and `eslint-plugin-vue` (v10→v9)
|
||||||
|
- **`getWeekdaySkips` removed** — CONFIRMED: removed from `@quasar/quasar-ui-qcalendar` API; `createDayList` now takes `weekdays` array directly as 4th param (previously took `weekdaySkips` computed value)
|
||||||
|
- **`subtasks` removed from TaskCardComponent template** — ASSUMED SAFE: `Task` type has no `subtasks` field; template refs were dead code. See open question.
|
||||||
|
- **`no-debugger: 'off'`** — CONFIRMED: hardcoded because `process` is not available in ESLint globals when linting `.js` files (config file context)
|
||||||
|
- **`.env.local` variable names corrected** — CONFIRMED: file had `VITE_APPWRITE_ENDPOINT` / `VITE_APPWRITE_PROJECT`; `appwrite.ts` reads `VITE_APPWRITE_API_ENDPOINT` / `VITE_APPWRITE_API_PROJECT`
|
||||||
|
|
||||||
|
## Key Numbers Generated or Discovered This Session
|
||||||
|
|
||||||
|
- TypeScript errors at session start: 30 (across 14 files)
|
||||||
|
- ESLint problems at session start: 12 (6 errors, 6 warnings)
|
||||||
|
- TypeScript errors at session end: 0
|
||||||
|
- ESLint problems at session end: 0
|
||||||
|
- ESLint: v10.0.3 → v9.39.4
|
||||||
|
- `@eslint/js`: v10 → v9
|
||||||
|
- `eslint-plugin-vue`: v10 → v9
|
||||||
|
- `register-service-worker`: newly added (was missing from package.json)
|
||||||
|
|
||||||
|
## Conditional Logic Established
|
||||||
|
|
||||||
|
- IF Appwrite SDK returns `DefaultDocument` THEN cast via `as unknown as DomainType` BECAUSE v23 `DefaultDocument` is strict and no longer assignable to domain types that extend `Partial<Models.Document>`
|
||||||
|
- IF `vite-plugin-checker` is v0.12.x THEN ESLint must be v9.x BECAUSE v0.12.x uses `FlatESLint` constructor removed in ESLint v10
|
||||||
|
- IF `createDayList` is called from qcalendar THEN pass `weekdays` array as 4th arg directly BECAUSE `getWeekdaySkips` was removed from the qcalendar public API
|
||||||
|
- IF `.env.local` is updated THEN variable names must match `import.meta.env.VITE_APPWRITE_API_ENDPOINT` / `VITE_APPWRITE_API_PROJECT` as read in `src/boot/appwrite.ts`
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `src/stores/boat.ts` | Modified | `as unknown as Boat[]` |
|
||||||
|
| `src/stores/interval.ts` | Modified | `as unknown as Interval` (3 places) |
|
||||||
|
| `src/stores/intervalTemplate.ts` | Modified | Map callback cast + `as unknown as IntervalTemplate` (3 places); `timeTuple` cast |
|
||||||
|
| `src/stores/reservation.ts` | Modified | `as unknown as Reservation` (5 places) |
|
||||||
|
| `src/stores/task.ts` | Modified | `as unknown as Task[]`, `TaskTag[]`, `SkillTag[]`, `Task` (5 places) |
|
||||||
|
| `src/stores/sampledata/schedule.ts` | Modified | `id`→`$id`, `blocks`→`timeTuples`, removed `reservationDate` |
|
||||||
|
| `src/components/boat/BoatPreviewComponent.vue` | Modified | `boat.id` → `boat.$id` |
|
||||||
|
| `src/components/scheduling/boat/BoatScheduleTableComponent.vue` | Modified | `block.id`→`block.$id`; `NodeJS.Timeout`→`ReturnType<typeof setInterval>`; ternary→if/else |
|
||||||
|
| `src/components/scheduling/boat/CalendarHeaderComponent.vue` | Modified | Removed `getWeekdaySkips` import+computed; `createDayList` now passes `weekdays` directly |
|
||||||
|
| `src/components/task/TaskCardComponent.vue` | Modified | Removed `defineProps` explicit import; removed `subtasks` template refs |
|
||||||
|
| `src/components/task/TaskListComponent.vue` | Modified | `task.id` → `task.$id` |
|
||||||
|
| `src/components/task/TaskTableComponent.vue` | Modified | Removed `defineProps` from explicit import |
|
||||||
|
| `src/components/ResourceScheduleViewerComponent.vue` | Modified | Removed `|| undefined`; `catch { }`; removed stale eslint-disable comments |
|
||||||
|
| `src/pages/LoginPage.vue` | Modified | `catch { }` |
|
||||||
|
| `src/pages/schedule/ManageCalendar.vue` | Modified | `block.id` → `block.$id` |
|
||||||
|
| `src/boot/appwrite.ts` | Modified | Removed stale `console.log(API_ENDPOINT)` |
|
||||||
|
| `eslint.config.js` | Modified | `no-debugger` hardcoded to `'off'` |
|
||||||
|
| `quasar.config.ts` | Modified | ESLint checker restored (had been temporarily removed) |
|
||||||
|
| `package.json` / `yarn.lock` | Modified | ESLint v10→v9; `@eslint/js` v10→v9; `eslint-plugin-vue` v10→v9; added `register-service-worker` |
|
||||||
|
| `.env.local` | Modified | Variable names corrected: `VITE_APPWRITE_ENDPOINT`→`VITE_APPWRITE_API_ENDPOINT`, `VITE_APPWRITE_PROJECT`→`VITE_APPWRITE_API_PROJECT`; endpoint URL updated to include `/v1` |
|
||||||
|
| `docs/summaries/handoff-2026-03-15-build-fixes.md` | Created | This file |
|
||||||
|
| `docs/archive/handoffs/handoff-2026-03-15-dependency-updates.md` | Archived | Superseded by this handoff |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
1. **First**: Run `quasar dev` and manually test the login flow against the dev Appwrite backend to validate v23 API calls work at runtime
|
||||||
|
2. **Validate**: Boat listing, reservation creation/cancellation, interval loading — confirm no runtime errors from the v23 positional-param deprecations
|
||||||
|
3. **Commit**: Stage all modified files and commit as `"fix: Resolve build errors from dependency updates"` (single clean commit covering all TS/ESLint/qcalendar/env fixes)
|
||||||
|
4. **Optional**: Migrate Appwrite calls from deprecated positional-param style to object-param style (affects all stores — low priority, they still work)
|
||||||
|
5. **Optional**: Add `subtasks?: Task[]` to `Task` interface in `src/stores/task.ts` if that feature is planned
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
|
||||||
|
- [ ] `task.subtasks` removed from `TaskCardComponent` template — should `subtasks?: Task[]` be added to the `Task` interface for future use, or is subtask support not planned?
|
||||||
|
- [ ] Appwrite v23 deprecated positional-param overloads (hints in every store call). Migrate now or leave for later?
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
|
||||||
|
- ASSUMED: Appwrite v23 positional-param API calls behave identically at runtime to v14 — validate by doing a full login + reservation flow against the dev backend
|
||||||
|
- ASSUMED: `subtasks` in `TaskCardComponent` was dead/future code — no user confirmed this
|
||||||
|
|
||||||
|
## What NOT to Re-Read
|
||||||
|
|
||||||
|
- `docs/archive/handoffs/handoff-2026-03-15-dependency-updates.md` — archived; superseded by this file
|
||||||
|
- `docs/archive/handoffs/handoff-2026-03-15-auth-magic-link.md` — archived; auth work complete
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
|
||||||
|
- `src/stores/task.ts` — if adding `subtasks` to Task interface
|
||||||
|
- `src/boot/appwrite.ts` — if migrating to Appwrite v23 object-param style
|
||||||
|
- Any store file (`boat.ts`, `interval.ts`, `reservation.ts`, etc.) — if migrating Appwrite calls
|
||||||
114
docs/archive/handoffs/handoff-2026-03-15-dependency-updates.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Session Handoff: Dependency Updates & ESLint Cleanup
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Session Focus:** Complete Quasar v1→v2 migration, Appwrite SDK v14→v23 update, ESLint flat config cleanup
|
||||||
|
**Status:** BUILD PASSING — 0 errors, 0 warnings
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
### From prior sessions (captured in archived handoffs):
|
||||||
|
1. Auth flow refactored to passwordless (magic link + OTP), OAuth removed
|
||||||
|
2. Google/Discord OAuth components deleted
|
||||||
|
3. About dialog with version info added → `src/components/LeftDrawer.vue`
|
||||||
|
4. `quasar.config.js` → `quasar.config.ts` (ESM TypeScript)
|
||||||
|
5. `"type": "module"` added to `package.json`
|
||||||
|
6. Yarn 1.x → Yarn 4.13.0
|
||||||
|
7. ESLint legacy `.eslintrc.cjs` → flat config `eslint.config.js`
|
||||||
|
8. QCalendar app extension removed → direct npm package import
|
||||||
|
9. Boot/router/store wrappers updated to `#q-app/wrappers` imports
|
||||||
|
10. Appwrite SDK updated v14.0.1 → v23.0.0
|
||||||
|
11. `globals` package installed; browser + ES2021 globals added to ESLint config
|
||||||
|
|
||||||
|
### This session (build cleanup — all 30 TS errors + 12 ESLint issues resolved):
|
||||||
|
|
||||||
|
**TypeScript `as unknown as` casts** (Appwrite v23 `DefaultDocument` no longer overlaps domain types):
|
||||||
|
- `src/stores/boat.ts:36` — `as unknown as Boat[]`
|
||||||
|
- `src/stores/interval.ts:95,113,127` — `as unknown as Interval`
|
||||||
|
- `src/stores/intervalTemplate.ts` — map callback cast + `as unknown as IntervalTemplate` (3 places)
|
||||||
|
- `src/stores/reservation.ts:65,80,247` — `as unknown as Reservation`
|
||||||
|
- `src/stores/task.ts:53,65,77,109,132` — `as unknown as Task[]`, `TaskTag[]`, `SkillTag[]`, `Task`
|
||||||
|
|
||||||
|
**`.id` → `.$id` fixes** (Appwrite uses `$id`, not `id`):
|
||||||
|
- `src/components/boat/BoatPreviewComponent.vue:7`
|
||||||
|
- `src/components/scheduling/boat/BoatScheduleTableComponent.vue:54`
|
||||||
|
- `src/components/task/TaskListComponent.vue:4`
|
||||||
|
- `src/pages/schedule/ManageCalendar.vue:40`
|
||||||
|
- `src/stores/sampledata/schedule.ts:19,29,138` — also `id:` → `$id:` in object literals
|
||||||
|
|
||||||
|
**`defineProps` import conflict** (auto-imported in `<script setup>`, cannot also be explicitly imported):
|
||||||
|
- `src/components/task/TaskCardComponent.vue:20` — removed import; also removed `subtasks` template refs (not in Task type)
|
||||||
|
- `src/components/task/TaskTableComponent.vue:215` — removed `defineProps` from import
|
||||||
|
|
||||||
|
**ESLint fixes:**
|
||||||
|
- `eslint.config.js:52` — `process.env.NODE_ENV === 'production' ? 'error' : 'off'` → `'off'` (process not defined in .js ESLint globals)
|
||||||
|
- `src/components/ResourceScheduleViewerComponent.vue:173` — removed `|| undefined` (always truthy)
|
||||||
|
- `src/components/ResourceScheduleViewerComponent.vue:177` — `catch (e)` → `catch { }` (unused binding)
|
||||||
|
- `src/components/ResourceScheduleViewerComponent.vue:237-247` — removed unused `eslint-disable-next-line` comments
|
||||||
|
- `src/components/scheduling/boat/BoatScheduleTableComponent.vue:116` — `NodeJS.Timeout` → `ReturnType<typeof setInterval>`
|
||||||
|
- `src/components/scheduling/boat/BoatScheduleTableComponent.vue:129` — ternary as statement → `if/else`; also destructured `{ direction }` to fix unused `event` hint
|
||||||
|
- `src/pages/LoginPage.vue:131` — `catch (e)` → `catch { }`
|
||||||
|
|
||||||
|
**Other fixes:**
|
||||||
|
- `src-pwa/register-service-worker.ts` — installed `register-service-worker` package (was missing from package.json)
|
||||||
|
- `src/stores/sampledata/schedule.ts:50` — `template.blocks` → `template.timeTuples` (property was renamed)
|
||||||
|
- `src/stores/sampledata/schedule.ts:145` — removed `reservationDate` (not in Reservation type)
|
||||||
|
- `src/stores/intervalTemplate.ts:27` — `d.timeTuple` cast via `as unknown as { timeTuple: string[] }`
|
||||||
|
- `src/stores/intervalTemplate.ts:82` — `response.timeTuple` cast via `as unknown as { timeTuple: string[] }`
|
||||||
|
- `src/components/scheduling/boat/CalendarHeaderComponent.vue` — removed `getWeekdaySkips` (removed from qcalendar API); `createDayList` now takes `weekdays` directly as 4th arg; removed `weekdaySkips` computed
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- **`as unknown as Type` pattern** — correct approach for Appwrite v23 `DefaultDocument` casts; v23 made `DefaultDocument` strict, no longer assignable to domain types without double-cast — STATUS: CONFIRMED
|
||||||
|
- **`getWeekdaySkips` removed from qcalendar** — `createDayList` now accepts `weekdays` array directly as 4th param — STATUS: CONFIRMED (verified from ESM source)
|
||||||
|
- **`subtasks` removed from TaskCardComponent template** — `Task` type has no `subtasks` field; feature was dead code — STATUS: ASSUMED safe (see open question)
|
||||||
|
- **`no-debugger: 'off'`** — hardcoded instead of `process.env.NODE_ENV` conditional because `process` is not in ESLint globals for `.js` files — STATUS: CONFIRMED
|
||||||
|
|
||||||
|
## Key Numbers
|
||||||
|
|
||||||
|
- TS errors at session start: 30 (in 14 files)
|
||||||
|
- ESLint errors at session start: 12 (6 errors, 6 warnings)
|
||||||
|
- TS errors at session end: 0
|
||||||
|
- ESLint errors at session end: 0
|
||||||
|
- Packages added: `register-service-worker`
|
||||||
|
|
||||||
|
## Files Modified This Session
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `eslint.config.js` | `no-debugger` rule hardcoded to `'off'` |
|
||||||
|
| `src/components/ResourceScheduleViewerComponent.vue` | 3 ESLint fixes |
|
||||||
|
| `src/components/scheduling/boat/BoatScheduleTableComponent.vue` | `.id`→`.$id`, NodeJS.Timeout, if/else |
|
||||||
|
| `src/components/scheduling/boat/CalendarHeaderComponent.vue` | Removed `getWeekdaySkips`; updated `createDayList` call |
|
||||||
|
| `src/pages/LoginPage.vue` | `catch { }` |
|
||||||
|
| `src/components/boat/BoatPreviewComponent.vue` | `.id`→`.$id` |
|
||||||
|
| `src/components/task/TaskCardComponent.vue` | Removed `defineProps` import + subtasks refs |
|
||||||
|
| `src/components/task/TaskListComponent.vue` | `.id`→`.$id` |
|
||||||
|
| `src/components/task/TaskTableComponent.vue` | Removed `defineProps` from import |
|
||||||
|
| `src/pages/schedule/ManageCalendar.vue` | `.id`→`.$id` |
|
||||||
|
| `src/stores/boat.ts` | `as unknown as Boat[]` |
|
||||||
|
| `src/stores/interval.ts` | `as unknown as Interval` (3 places) |
|
||||||
|
| `src/stores/intervalTemplate.ts` | Multiple cast fixes + `timeTuple` access |
|
||||||
|
| `src/stores/reservation.ts` | `as unknown as Reservation` (multiple) |
|
||||||
|
| `src/stores/sampledata/schedule.ts` | `id`→`$id`, `blocks`→`timeTuples`, removed `reservationDate` |
|
||||||
|
| `src/stores/task.ts` | `as unknown as` for Task/TaskTag/SkillTag |
|
||||||
|
| `package.json` / `yarn.lock` | Added `register-service-worker` |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- [ ] `src/components/task/TaskCardComponent.vue` — `subtasks` removed from template. Should `subtasks?: Task[]` be added to the `Task` interface in `task.ts` for future use? OPEN
|
||||||
|
- [ ] Appwrite v23 deprecated positional-param overloads (hints in every store). Should stores be migrated to new object-param style? Low priority — code still works. OPEN
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- ASSUMED: `subtasks` feature in TaskCardComponent was dead/future code — safe to remove template refs
|
||||||
|
- ASSUMED: `no-debugger: 'off'` is fine for devel branch
|
||||||
|
|
||||||
|
## What NOT to Re-Read
|
||||||
|
|
||||||
|
- `docs/archive/handoffs/handoff-2026-03-15-auth-magic-link.md` — archived
|
||||||
|
|
||||||
|
## Next Session
|
||||||
|
|
||||||
|
- Commit all dependency update + build fix changes
|
||||||
|
- Test the app against the dev Appwrite backend (validate v23 API calls work at runtime)
|
||||||
|
- Consider migrating Appwrite calls from deprecated positional-param to object-param style (optional)
|
||||||
|
- Consider adding `subtasks?: Task[]` to `Task` interface if the feature is planned
|
||||||
186
docs/archive/handoffs/handoff-2026-03-18-nuxt-migration-plan.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# Handoff: Nuxt 3 Migration Plan
|
||||||
|
Date: 2026-03-18
|
||||||
|
Type: Architecture / Planning
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Migrate bab-app from Quasar 2 + Vue 3 PWA to **Nuxt 3 + Capacitor + FullCalendar**.
|
||||||
|
Retain Appwrite backend unchanged. Auth simplified to magic link / email OTP only.
|
||||||
|
|
||||||
|
## Current Stack (Source of Truth)
|
||||||
|
- Quasar 2 / Vue 3 / TypeScript / Pinia / Appwrite
|
||||||
|
- Build: PWA only (`quasar build -m pwa`)
|
||||||
|
- Appwrite project: `65ede55a213134f2b688` @ `https://appwrite.toal.ca/v1`
|
||||||
|
- Database: `bab_prod`
|
||||||
|
- Collections: boat, reservation, interval, intervalTemplate, task, taskTags, skillTags
|
||||||
|
- 9 Pinia stores, 21 components, 2 layouts, ~20 pages/routes
|
||||||
|
- Auth: email token + magic link + password (vue3-google-login present but UNUSED)
|
||||||
|
|
||||||
|
## Target Stack
|
||||||
|
- **Nuxt 3** (file-based routing, Nitro, Vite)
|
||||||
|
- **nuxt-quasar-ui** module — keeps all Quasar components during migration, de-risks UI rewrite
|
||||||
|
- **@fullcalendar/vue3** — replaces @quasar/quasar-ui-qcalendar
|
||||||
|
- **@capacitor/core** + plugins — camera, geolocation (native mobile future)
|
||||||
|
- **Pinia** — unchanged
|
||||||
|
- **Appwrite SDK** — unchanged, via Nuxt plugin
|
||||||
|
- **Auth**: magic link + email OTP only; remove vue3-google-login
|
||||||
|
|
||||||
|
## Key Architectural Decisions
|
||||||
|
- Use `nuxt-quasar-ui` to avoid a big-bang UI rewrite — keeps q-* components working
|
||||||
|
- File-based routing replaces routes.ts — directory structure maps 1:1 to current routes
|
||||||
|
- Boot file → Nuxt plugin (appwrite.ts becomes plugins/appwrite.ts)
|
||||||
|
- Router guards → Nuxt middleware (auth.ts global middleware)
|
||||||
|
- **PWA only initially** — Capacitor deferred until native camera/GPS features are needed
|
||||||
|
- FullCalendar resource-timeline view for multi-boat scheduling (replaces QCalendarResource)
|
||||||
|
- **FullCalendar non-commercial license confirmed** — key: `CC-Attribution-NonCommercial-NoDerivatives` (OYS is a registered Ontario not-for-profit #000082982)
|
||||||
|
- **Static output** — `nuxt generate`, served via nginx, consistent with current Ansible deploy
|
||||||
|
- **TestFlight / App Store deferred** — revisit when PWA hits UX or capability limits
|
||||||
|
|
||||||
|
## Migration Phases (see plan in this file)
|
||||||
|
|
||||||
|
### Phase 1 — Foundation (1-2 days) ✅ COMPLETE 2026-03-19
|
||||||
|
Scaffold new Nuxt 3 project alongside existing. Do NOT delete old project until Phase 6 complete.
|
||||||
|
|
||||||
|
New project: `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
- `npx nuxi@latest init bab-app-nuxt`
|
||||||
|
- Add modules: `nuxt-quasar-ui`, `@pinia/nuxt`, `@vite-pwa/nuxt`
|
||||||
|
- Configure `nuxt.config.ts`: Quasar options, Vite aliases, env vars
|
||||||
|
- Migrate `.env.local` vars (VITE_ prefix → NUXT_PUBLIC_ for public vars)
|
||||||
|
- Capacitor: DEFERRED — PWA only until native features needed
|
||||||
|
|
||||||
|
### Phase 2 — Appwrite Plugin + Types (0.5 days)
|
||||||
|
- Copy `src/boot/appwrite.ts` → `plugins/appwrite.ts`
|
||||||
|
- Change export pattern to Nuxt plugin format (`defineNuxtPlugin`)
|
||||||
|
- Provide `$appwrite` via `useNuxtApp()`
|
||||||
|
- Copy all TypeScript type files verbatim
|
||||||
|
|
||||||
|
### Phase 3 — Auth (1 day)
|
||||||
|
- Migrate `stores/auth.ts` → `stores/auth.ts` (Pinia, near-verbatim)
|
||||||
|
- Remove all password auth methods (createEmailPasswordSession, etc.)
|
||||||
|
- Keep: `createMagicURLToken`, `updateMagicURLSession`, `createEmailToken`, `updatePhoneSession`
|
||||||
|
- Remove `vue3-google-login` from package.json
|
||||||
|
- Create `middleware/auth.global.ts` — replaces router/index.ts navigation guard
|
||||||
|
- Public routes: `/login`, `/signup`, `/pwreset`, `/terms-of-service`, `/privacy-policy`
|
||||||
|
- Magic link callback: `pages/auth/callback.vue` — handles `?userId=&secret=` params
|
||||||
|
|
||||||
|
### Phase 4 — Remaining Stores (0.5 days)
|
||||||
|
All stores are framework-agnostic Pinia — direct copy with minor import path updates:
|
||||||
|
- stores/boat.ts
|
||||||
|
- stores/reservation.ts
|
||||||
|
- stores/interval.ts
|
||||||
|
- stores/intervalTemplate.ts
|
||||||
|
- stores/task.ts
|
||||||
|
- stores/reference.ts
|
||||||
|
- stores/realtime.ts
|
||||||
|
- stores/memberProfile.ts
|
||||||
|
|
||||||
|
### Phase 5 — File-Based Routing (Page Scaffold) (0.5 days)
|
||||||
|
Create empty page files matching Nuxt convention. Route mapping:
|
||||||
|
|
||||||
|
| Old path | Nuxt file |
|
||||||
|
|---|---|
|
||||||
|
| `/` | `pages/index.vue` |
|
||||||
|
| `/boat` | `pages/boat.vue` |
|
||||||
|
| `/certification` | `pages/certification.vue` |
|
||||||
|
| `/profile` | `pages/profile.vue` |
|
||||||
|
| `/checklist` | `pages/checklist.vue` |
|
||||||
|
| `/reference` | `pages/reference/index.vue` |
|
||||||
|
| `/reference/:id/view` | `pages/reference/[id]/view.vue` |
|
||||||
|
| `/schedule` | `pages/schedule/index.vue` |
|
||||||
|
| `/schedule/book` | `pages/schedule/book.vue` |
|
||||||
|
| `/schedule/view` | `pages/schedule/view.vue` |
|
||||||
|
| `/schedule/list` | `pages/schedule/list.vue` |
|
||||||
|
| `/schedule/edit/:id` | `pages/schedule/edit/[id].vue` |
|
||||||
|
| `/schedule/manage` | `pages/schedule/manage.vue` |
|
||||||
|
| `/task` | PARKED — task feature not migrated (collections absent from bab_prod) |
|
||||||
|
| `/task/:id/edit` | PARKED |
|
||||||
|
| `/admin/user` | `pages/admin/user.vue` |
|
||||||
|
| `/admin/boat` | `pages/admin/boat.vue` |
|
||||||
|
| `/login` | `pages/login.vue` |
|
||||||
|
| `/signup` | `pages/signup.vue` |
|
||||||
|
| `/pwreset` | `pages/pwreset.vue` |
|
||||||
|
| `/terms-of-service` | `pages/terms-of-service.vue` |
|
||||||
|
| `/privacy-policy` | `pages/privacy-policy.vue` |
|
||||||
|
|
||||||
|
Admin route guard: `definePageMeta({ middleware: ['auth', 'admin'] })` in admin pages.
|
||||||
|
Schedule manage guard: `definePageMeta({ middleware: ['auth', 'schedule-admin'] })`.
|
||||||
|
|
||||||
|
### Phase 6 — Layouts + Global Components (1 day)
|
||||||
|
- `layouts/default.vue` — replaces MainLayout.vue (q-layout, q-header, LeftDrawer, BottomNav)
|
||||||
|
- `layouts/admin.vue` — replaces AdminLayout.vue
|
||||||
|
- Migrate components verbatim first; replace Quasar components later if needed:
|
||||||
|
- components/BottomNavComponent.vue
|
||||||
|
- components/LeftDrawer.vue (OPEN: was listed in agent findings but not in original glob — confirm file exists)
|
||||||
|
- components/ToolbarComponent.vue
|
||||||
|
|
||||||
|
### Phase 7 — Page Migration (3-5 days)
|
||||||
|
Migrate pages in this order (lowest to highest complexity):
|
||||||
|
|
||||||
|
1. Public pages: login, signup, pwreset, terms, privacy (simple forms)
|
||||||
|
2. Profile, Certification, Checklist, Boat (read-only/simple CRUD)
|
||||||
|
3. Reference pages
|
||||||
|
4. Admin pages (UserAdmin, BoatAdmin — q-table heavy)
|
||||||
|
5. Schedule pages (most complex — last)
|
||||||
|
6. Task pages — PARKED. Skip until bab_prod collections (task, taskTags, skillTags) are created.
|
||||||
|
Affected: stores/task.ts, components/task/*, pages/task/*, BottomNav task link, navlinks.ts task entry.
|
||||||
|
|
||||||
|
### Phase 8 — FullCalendar (2-3 days)
|
||||||
|
Replace QCalendar with FullCalendar Vue 3.
|
||||||
|
|
||||||
|
Packages:
|
||||||
|
```
|
||||||
|
@fullcalendar/vue3
|
||||||
|
@fullcalendar/core
|
||||||
|
@fullcalendar/resource-timeline # multi-boat resource view
|
||||||
|
@fullcalendar/daygrid # month/day grid
|
||||||
|
@fullcalendar/timegrid # week/day time grid
|
||||||
|
@fullcalendar/interaction # drag and drop
|
||||||
|
@fullcalendar/list # list view
|
||||||
|
```
|
||||||
|
|
||||||
|
Mapping:
|
||||||
|
- `ResourceScheduleViewerComponent.vue` → new `FullCalendarResourceView.vue`
|
||||||
|
- Resources = boats (one row per boat)
|
||||||
|
- Events = reservations + intervals
|
||||||
|
- DnD for admin interval management (replaces IntervalTemplateComponent drag behavior)
|
||||||
|
- `CalendarHeaderComponent.vue` — FullCalendar has built-in header toolbar, simplify or remove
|
||||||
|
- `BoatScheduleTableComponent.vue` — replace with FullCalendar list/grid view
|
||||||
|
|
||||||
|
FullCalendar license: open-source plugins are free; resource-timeline requires **FullCalendar Premium** license ($0 for open-source/non-commercial projects — confirm this applies, otherwise budget ~$200/yr).
|
||||||
|
|
||||||
|
OPEN: Confirm if OYS Borrow a Boat qualifies for FullCalendar open-source license.
|
||||||
|
|
||||||
|
### Phase 9 — Capacitor (1 day)
|
||||||
|
- `npx cap add ios` / `npx cap add android`
|
||||||
|
- Install plugins (not wired to features yet, just scaffold):
|
||||||
|
- `@capacitor/camera`
|
||||||
|
- `@capacitor/geolocation`
|
||||||
|
- Create `composables/useCamera.ts` and `composables/useGeolocation.ts`
|
||||||
|
- Detect Capacitor.isNativePlatform() and fall back to browser APIs on web
|
||||||
|
- Update `nuxt.config.ts` ssr: false (Capacitor requires SPA mode)
|
||||||
|
- capacitor.config.ts: `webDir: '.output/public'`
|
||||||
|
|
||||||
|
### Phase 10 — CI/CD (0.5 days)
|
||||||
|
Update `.gitea/workflows/build.yaml`:
|
||||||
|
- Replace `quasar build -m pwa` with `nuxt build`
|
||||||
|
- Output dir: `.output/public/` (static) or `.output/` (server)
|
||||||
|
- Ansible deploy: serve `.output/public` as static site (nginx), same as current
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
- OPEN: Does OYS qualify for FullCalendar non-commercial license? (resource-timeline is premium)
|
||||||
|
- OPEN: Confirm `LeftDrawer.vue` exists in src/layouts/ or src/components/ — agent referenced it but not in initial glob
|
||||||
|
- OPEN: Is Capacitor native app publishing (App Store / Play Store) in scope, or just Capacitor for future native API access with PWA as primary delivery?
|
||||||
|
- ASSUMED: SSR is not needed — Appwrite client SDK runs browser-side; deploy as SPA/static
|
||||||
|
- ASSUMED: Nuxt deployed as static output (not Node server) — consistent with current nginx/Ansible deploy
|
||||||
|
|
||||||
|
## Effort Estimate
|
||||||
|
| Phase | Days |
|
||||||
|
|---|---|
|
||||||
|
| 1-4 (Foundation, Plugin, Auth, Stores) | 3 |
|
||||||
|
| 5-6 (Routing, Layouts) | 1.5 |
|
||||||
|
| 7 (Page migration) | 4 |
|
||||||
|
| 8 (FullCalendar) | 2.5 |
|
||||||
|
| 9 (Capacitor) | 1 |
|
||||||
|
| 10 (CI/CD) | 0.5 |
|
||||||
|
| **Total** | **~12.5 days** |
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Session Handoff: Nuxt Migration — Phases 3 & 4 Complete
|
||||||
|
**Date:** 2026-03-19
|
||||||
|
**Session Focus:** Auth store, middleware, callback page, and remaining Pinia stores
|
||||||
|
**Context Usage at Handoff:** ~50%
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. Phase 3 — Auth complete
|
||||||
|
2. Phase 4 — All remaining stores complete (except `task.ts`, still parked)
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
|
||||||
|
- **Migration plan**: 10-phase plan. Phases 1–4 marked complete. Phase 5 (Pages/Components) is next.
|
||||||
|
- **New project**: `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/` — stores and middleware in place. No pages migrated yet (other than `auth/callback.vue`).
|
||||||
|
- **Old project**: `/home/ptoal/Dev/mobile-projects/bab-app/` — untouched. Do not delete until Phase 6.
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
|
||||||
|
- **Magic link redirect URL**: changed from `/login` to `/auth/callback` — dedicated handler, cleaner separation. CONFIRMED.
|
||||||
|
- **realtime.ts generic type**: changed from `RealtimeResponseEvent<Interval>` (incorrect) to `RealtimeResponseEvent<unknown>` — original typed the callback fn parameter too narrowly since multiple channels use the same store. CONFIRMED.
|
||||||
|
- **Boat interface not re-exported from boat.ts**: `boat.ts` re-exports `Boat` from `~/utils/boat.types` via `export { type Boat }` to preserve import compatibility with components that do `import { Boat } from '~/stores/boat'`. CONFIRMED.
|
||||||
|
|
||||||
|
## Key Numbers
|
||||||
|
|
||||||
|
- **7 stores created**: `auth`, `boat`, `reservation`, `interval`, `intervalTemplate`, `reference`, `memberProfile`
|
||||||
|
- **2 middleware/pages created**: `middleware/auth.global.ts`, `pages/auth/callback.vue`
|
||||||
|
- **1 plugin updated**: `plugins/appwrite.client.ts`
|
||||||
|
- **Task stores**: still 0 — `task`, `taskTags`, `skillTags` collections not in `bab_prod`
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `bab-app-nuxt/app/stores/auth.ts` | Created | Magic link + OTP only; `register()`/`login()` removed; boat/reservation init wired |
|
||||||
|
| `bab-app-nuxt/app/stores/boat.ts` | Created | Re-exports `Boat` from `~/utils/boat.types`; near-verbatim port |
|
||||||
|
| `bab-app-nuxt/app/stores/reservation.ts` | Created | Near-verbatim port; imports updated |
|
||||||
|
| `bab-app-nuxt/app/stores/interval.ts` | Created | Near-verbatim port; `Boat` imported from `~/utils/boat.types` |
|
||||||
|
| `bab-app-nuxt/app/stores/intervalTemplate.ts` | Created | Near-verbatim port |
|
||||||
|
| `bab-app-nuxt/app/stores/reference.ts` | Created | Verbatim port (static data, no Appwrite) |
|
||||||
|
| `bab-app-nuxt/app/stores/realtime.ts` | Created | Generic type corrected to `unknown` |
|
||||||
|
| `bab-app-nuxt/app/stores/memberProfile.ts` | Created | Verbatim port (static data, no Appwrite) |
|
||||||
|
| `bab-app-nuxt/app/middleware/auth.global.ts` | Created | Global Nuxt route guard; public via `to.meta.public`; roles via `to.meta.requiredRoles` |
|
||||||
|
| `bab-app-nuxt/app/pages/auth/callback.vue` | Created | Handles `?userId=&secret=` magic link params; redirects to `/` |
|
||||||
|
| `bab-app-nuxt/app/plugins/appwrite.client.ts` | Modified | `authStore.init()` wired in |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
1. **Execute Phase 5 — Pages & Components**
|
||||||
|
- Identify pages in old `src/pages/` — migrate one at a time
|
||||||
|
- Key pages: `LoginPage.vue`, `IndexPage.vue`, `BoatPage.vue`, `ProfilePage.vue`, `CertificationPage.vue`, `ChecklistPage.vue`
|
||||||
|
- Schedule pages: `SchedulePageView.vue`, `ScheduleIndexPage.vue`, `BoatReservationPage.vue`, `BoatScheduleView.vue`, `ListReservationsPage.vue`, `ModifyBoatReservation.vue`, `ManageCalendar.vue`
|
||||||
|
- Admin pages: `UserAdminPage.vue`, `BoatAdminPage.vue`
|
||||||
|
- Static pages: `TermsOfServicePage.vue`, `PrivacyPolicyPage.vue`, `ErrorNotFound.vue`
|
||||||
|
- Add `definePageMeta({ public: true })` to: `login.vue`, `signup.vue`, `pwreset.vue`, `terms-of-service.vue`, `privacy-policy.vue`, `auth/callback.vue` (already done)
|
||||||
|
- Add `definePageMeta({ requiredRoles: ['Schedule Admins'] })` to `schedule/manage.vue`
|
||||||
|
- Add `definePageMeta({ requiredRoles: ['admin'] })` to all `admin/*.vue` pages
|
||||||
|
2. **Execute Phase 6 — Layout**
|
||||||
|
- Migrate `MainLayout.vue` and `AdminLayout.vue`
|
||||||
|
- Confirm `LeftDrawer.vue` exists in `src/components/` (ASSUMED — not yet confirmed)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- [ ] **OPEN**: `task`/`taskTags`/`skillTags` collections — will they ever be created in `bab_prod`?
|
||||||
|
- [ ] **OPEN**: What pages are in `src/pages/` — exact list not yet read (read on demand per CLAUDE.md rule 6)
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- ASSUMED: `LeftDrawer.vue` exists in `src/components/`. Verify before Phase 6.
|
||||||
|
- ASSUMED: `nuxt generate` (static) is sufficient. Still unvalidated.
|
||||||
|
- ASSUMED: `LoginPage.vue` in old app handles both magic link and OTP login UI — new app needs `pages/login.vue` built from scratch with `definePageMeta({ public: true })`.
|
||||||
|
|
||||||
|
## What NOT to Re-Read
|
||||||
|
|
||||||
|
- All stores in `bab-app-nuxt/app/stores/` — just created, content known
|
||||||
|
- `src/stores/auth.ts`, `src/stores/boat.ts`, etc. — fully migrated; do not re-read
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
|
||||||
|
- `docs/summaries/handoff-2026-03-19-nuxt-migration-phases-3-4.md` (this file)
|
||||||
|
- `src/pages/` — read one page at a time per processing protocol
|
||||||
|
- `src/layouts/MainLayout.vue` — needed for Phase 6 planning
|
||||||
|
- `bab-app-nuxt/app/stores/auth.ts` — if login page needs store shape reference
|
||||||
16
docs/context/archive-rules.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Archive Rules
|
||||||
|
|
||||||
|
## Raw File Archival
|
||||||
|
|
||||||
|
After creating a Source Document Summary for any raw file:
|
||||||
|
1. Move the raw file to `docs/archive/`
|
||||||
|
2. Record the move in the source summary's header: `Archived From: [original path]`
|
||||||
|
3. Do not read from `docs/archive/` unless the user explicitly says "go back to the original [filename]"
|
||||||
|
|
||||||
|
## Summary Lifecycle Rules
|
||||||
|
|
||||||
|
1. **Session handoffs expire**: After a new handoff is written, the previous handoff moves to `docs/archive/handoffs/`. Only the latest handoff stays in `docs/summaries/`.
|
||||||
|
2. **Decision records persist**: Decision records (DR-*) stay in `docs/summaries/` permanently — they are institutional memory.
|
||||||
|
3. **Source summaries persist**: Source document summaries stay until the project ends — they replace raw documents.
|
||||||
|
4. **Analysis summaries**: Keep only the latest version. If re-run, the new one replaces the old (archive the old one).
|
||||||
|
5. **Maximum active summaries**: If `docs/summaries/` exceeds 15 files, consolidate older source summaries into a single `project-digest.md` and archive the originals.
|
||||||