26 Commits

Author SHA1 Message Date
6777c065ee fix: trigger release
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m33s
2026-03-20 14:39:07 -04:00
f2c4e73b8c fix(build): fix ansible trigger
Some checks failed
Build BAB Application Deployment Artifact / build (push) Has been cancelled
2026-03-20 13:45:28 -04:00
e82cdfd00f fix: trigger release
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m25s
2026-03-20 13:38:25 -04:00
7643662fcc fix(build): Cache improvements
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m14s
2026-03-20 09:39:56 -04:00
5c56d77a23 fix(build): Triggering Ansible
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m34s
2026-03-20 09:20:04 -04:00
eaae9b7487 feat: New build
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m41s
2026-03-20 00:39:36 -04:00
f012025917 ci: fix versioning
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m4s
2026-03-20 00:34:49 -04:00
ac65cd683a ci: fix cache
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 3m19s
2026-03-20 00:26:19 -04:00
e689e3efd8 ci: Add caching
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m41s
2026-03-20 00:23:06 -04:00
94d3a2716e ci: semantic-release update
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m19s
2026-03-19 16:30:48 -04:00
18d9f998f5 ci: Add pre-commit hook
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m28s
2026-03-19 15:50:36 -04:00
bb3042014e refactor: everything to nuxt.js 2026-03-19 14:30:36 -04:00
6e1f58cd8e fix: yarn install
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m39s
2026-03-18 23:17:18 -04:00
cc6903a799 fix: Update for new yarn
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 3m8s
2026-03-18 23:11:38 -04:00
6c4d047bf0 fix: lock yarn version
Some checks failed
Build BAB Application Deployment Artifact / build (push) Has been cancelled
2026-03-18 23:09:03 -04:00
2874ea3be1 fix(ui): hidden components on hard reload
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m16s
2026-03-15 22:44:07 -04:00
26bc33a095 fix(ui): layout fixes 2026-03-15 22:12:35 -04:00
67c7a3c050 chore: Update dependencies to latest
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m17s
fix: claude fixes to various errors
2026-03-15 10:41:12 -04:00
5d08b1c927 fix: Update AppwriteIDs for new dev
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m33s
2026-03-15 07:53:55 -04:00
148b8ff49d fix: add devel branch to builds
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m24s
2026-03-14 23:28:36 -04:00
c4113f63a4 Merge branch 'alpha' into devel 2026-03-14 23:25:03 -04:00
6274e4936d fix: Broken tag
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m48s
2026-03-14 23:07:10 -04:00
e1259688a4 chore: Add Claude Fix some bugs.
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m40s
2026-03-14 22:50:00 -04:00
e2a4dd851d Test
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m2s
2025-11-17 22:47:05 -05:00
2a61cc105f Test 2024-10-03 11:50:15 -04:00
d6f58ddabd chore: add version files.
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 4m41s
fix(ui): small tweaks to layout.
2024-06-25 08:33:05 -04:00
180 changed files with 19597 additions and 14120 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,47 +1,70 @@
name: Build BAB Application Deployment Artifact
run-name: ${{ gitea.actor }} is building a BAB App artifact 🚀
on:
push:
branches:
- main
- alpha
- devel
jobs:
build:
runs-on: ubuntu-latest
env:
RUNNER_TOOL_CACHE: /toolcache
runs-on: ubuntu-18.04
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: Install yarn
run: npm install --global yarn
- name: Install yarn dependencies
run: yarn install
- name: Install Quasar CLI
run: yarn global add @quasar/cli
- name: Temporary - Invoke custom qcalendar build
run: quasar ext invoke @quasar/qcalendar
node-version: "20"
- name: Enable Corepack and Yarn
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Cache node_modules
id: 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
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn install --immutable
- name: Create env file
run: |
echo "${{ vars.ENV_FILE }}" > .env.local
run: echo "${{ vars.ENV_FILE }}" > .env
- name: Show env file
run: |
/bin/cat .env.local
run: cat .env
- name: Build and Release
id: build
run: |
npx semantic-release
run: yarn semantic-release
env:
GITEA_TOKEN: ${{ secrets.GT_TOKEN }}
GITEA_URL: ${{ vars.GT_URL }}
- name: Trigger Ansible Deploy Playbook
uses: https://github.com/distributhor/workflow-webhook@v3
with:
webhook_url: ${{ vars.WEBHOOK_URL }}
verbose: true
data: '{ "artifact_url": "${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/v${{ steps.build.outputs.VERSION }}/release-${{ steps.build.outputs.VERSION }}.tar.gz" }'
if: steps.build.outputs.VERSION != ''
run: |
response=$(curl -sS \
-H "Authorization: Bearer ${{ secrets.WEBHOOK_SECRET }}" \
-H "Content-Type: application/json" \
-d '{"artifact_url":"${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/v${{ steps.build.outputs.VERSION }}/release-${{ steps.build.outputs.VERSION }}.tar.gz"}' \
-w "\n%{http_code}" \
"${{ vars.WEBHOOK_URL }}")
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
echo "Response: $body"
echo "HTTP status: $http_code"
[ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]

59
.gitignore vendored
View File

@@ -1,43 +1,40 @@
.DS_Store
.thumbs.db
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Quasar core related directories
.quasar
/dist
/quasar.config.*.temporary.compiled*
# Logs
logs
*.log
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/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
# Misc
.DS_Store
.thumbs.db
.fleet
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
# local .env files
.env*
# Local env files
.env
.env.*
!.env.example
# version file
src/version.js
VERSION
# Yarn 4
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
# Release artifacts
release-*.gz
CHANGELOG.md
VERSION

1
.husky/pre-commit Executable file
View File

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

3
.npmrc
View File

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

View File

@@ -3,6 +3,7 @@
"main",
"next",
{ "name": "beta", "prerelease": true },
{ "name": "devel", "prerelease": true },
{ "name": "alpha", "prerelease": true }
],
"plugins": [
@@ -12,8 +13,8 @@
[
"@semantic-release/exec",
{
"prepareCmd": "npm run generate-version '${nextRelease.version}' && quasar build -m pwa",
"publishCmd": "tar -czvf release-${nextRelease.version}.tar.gz -C dist/pwa . && echo '::set-output name=VERSION::${nextRelease.version}'"
"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\""
}
],
[

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

File diff suppressed because one or more lines are too long

3
.yarnrc.yml Normal file
View File

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

51
CLAUDE.md Normal file
View File

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

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.0.0

5
app/app.vue Normal file
View File

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

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,141 +1,13 @@
<template>
<div class="q-pa-xs row q-gutter-xs">
<q-card
flat
class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
<q-card-section>
<div class="text-h5 q-mt-none q-mb-xs">
{{ reservation ? 'Modify Booking' : 'New Booking' }}
</div>
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
</q-card-section>
<q-list class="q-px-xs">
<q-item
class="q-pa-none"
clickable
@click="boatSelect = true">
<q-card
v-if="boat"
class="col-12">
<q-card-section>
<q-img
:src="boat.imgSrc"
:fit="'scale-down'">
<div class="row absolute-top">
<div class="col text-h7 text-left">
{{ boat.name }}
</div>
<div class="col text-right text-caption">
{{ boat.class }}
</div>
</div>
</q-img>
</q-card-section>
<q-separator />
<q-card-section horizontal>
<q-card-section class="col-9">
<q-list
dense
class="row">
<q-item class="q-ma-none col-12">
<q-item-section avatar>
<q-badge
color="primary"
label="Start" />
</q-item-section>
<q-item-section class="text-body2">
{{ formatDate(bookingForm.interval?.start) }}
</q-item-section>
</q-item>
<q-item class="q-ma-none col-12">
<q-item-section avatar>
<q-badge
color="primary"
label="End" />
</q-item-section>
<q-item-section
class="text-body2"
style="min-width: 150px">
{{ formatDate(bookingForm.interval?.end) }}
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-separator vertical />
<q-card-section class="col-3 flex flex-center bg-grey-4">
{{ bookingDuration.hours }} hours
<div v-if="bookingDuration.minutes">
<q-separator />
{{ bookingDuration.minutes }} mins
</div>
</q-card-section>
</q-card-section>
</q-card>
<div
v-else
class="col-12">
<q-field filled>Tap to Select a Boat / Time</q-field>
</div>
</q-item>
<q-item class="q-px-none">
<q-item-section>
<q-select
filled
v-model="bookingForm.reason"
:options="reason_options"
label="Reason for sail" />
</q-item-section>
</q-item>
<q-item class="q-px-none">
<q-item-section>
<q-input
v-model="bookingForm.comment"
clearable
autogrow
filled
label="Additional Comments (optional)" />
</q-item-section>
</q-item>
</q-list>
<q-card-actions align="right">
<q-btn
label="Delete"
color="negative"
size="lg"
v-if="reservation?.$id"
@click="onDelete" />
<q-btn
label="Reset"
@click="onReset"
size="lg"
color="secondary" />
<q-btn
label="Submit"
@click="onSubmit"
size="lg"
color="primary" />
</q-card-actions>
</q-card>
<q-dialog
v-model="boatSelect"
full-width>
<BoatScheduleTableComponent
:model-value="bookingForm.interval"
@update:model-value="updateInterval" />
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useAuthStore } from 'src/stores/auth';
import { Boat, useBoatStore } from 'src/stores/boat';
import { Interval, Reservation } from 'src/stores/schedule.types';
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
import { formatDate } from 'src/utils/schedule';
import { useReservationStore } from 'src/stores/reservation';
import { 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';
import { useRouter } from 'vue-router';
interface BookingForm {
$id?: string;
@@ -170,7 +42,7 @@ watch(reservation, (newReservation) => {
if (!newReservation) {
bookingForm.value = newForm;
} else {
const updatedReservation = {
bookingForm.value = {
...newReservation,
user: auth.currentUser?.$id,
interval: {
@@ -179,11 +51,10 @@ watch(reservation, (newReservation) => {
resource: newReservation.resource,
},
};
bookingForm.value = updatedReservation;
}
});
const updateInterval = (interval: Interval | null) => {
const updateInterval = (interval: Interval | null | undefined) => {
bookingForm.value.interval = interval;
boatSelect.value = false;
};
@@ -195,14 +66,12 @@ const bookingDuration = computed((): { hours: number; minutes: number } => {
const delta = Math.abs(end - start) / 1000;
const hours = Math.floor(delta / 3600) % 24;
const minutes = Math.floor(delta - hours * 3600) % 60;
return { hours: hours, minutes: minutes };
return { hours, minutes };
}
return { hours: 0, minutes: 0 };
});
const bookingName = computed(() =>
auth.getUserNameById(bookingForm.value?.user)
);
const bookingName = computed(() => auth.getUserNameById(bookingForm.value?.user));
const boat = computed((): Boat | null => {
const boatId = bookingForm.value.interval?.resource;
@@ -239,7 +108,6 @@ const onSubmit = async () => {
auth.currentUser
)
) {
// TODO: Make a proper validator
return false;
}
const newReservation = <Reservation>{
@@ -273,13 +141,87 @@ const onSubmit = async () => {
spinner: false,
});
} catch (e) {
status({
color: 'negative',
icon: 'error',
spinner: false,
message: 'Failed to book!' + e,
});
status({ color: 'negative', icon: 'error', spinner: false, message: 'Failed to book!' + e });
}
$router.go(-1);
};
</script>
<template>
<div class="q-pa-xs row q-gutter-xs">
<q-card flat class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
<q-card-section>
<div class="text-h5 q-mt-none q-mb-xs">
{{ reservation ? 'Modify Booking' : 'New Booking' }}
</div>
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
</q-card-section>
<q-list class="q-px-xs">
<q-item class="q-pa-none" clickable @click="boatSelect = true">
<q-card v-if="boat" class="col-12">
<q-card-section>
<q-img :src="boat.imgSrc" :fit="'scale-down'">
<div class="row absolute-top">
<div class="col text-h7 text-left">{{ boat.name }}</div>
<div class="col text-right text-caption">{{ boat.class }}</div>
</div>
</q-img>
</q-card-section>
<q-separator />
<q-card-section horizontal>
<q-card-section class="col-9">
<q-list dense class="row">
<q-item class="q-ma-none col-12">
<q-item-section avatar>
<q-badge color="primary" label="Start" />
</q-item-section>
<q-item-section class="text-body2">
{{ formatDate(bookingForm.interval?.start) }}
</q-item-section>
</q-item>
<q-item class="q-ma-none col-12">
<q-item-section avatar>
<q-badge color="primary" label="End" />
</q-item-section>
<q-item-section class="text-body2" style="min-width: 150px">
{{ formatDate(bookingForm.interval?.end) }}
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-separator vertical />
<q-card-section class="col-3 flex flex-center bg-grey-4">
{{ bookingDuration.hours }} hours
<div v-if="bookingDuration.minutes">
<q-separator />
{{ bookingDuration.minutes }} mins
</div>
</q-card-section>
</q-card-section>
</q-card>
<div v-else class="col-12">
<q-field filled>Tap to Select a Boat / Time</q-field>
</div>
</q-item>
<q-item class="q-px-none">
<q-item-section>
<q-select filled v-model="bookingForm.reason" :options="reason_options" label="Reason for sail" />
</q-item-section>
</q-item>
<q-item class="q-px-none">
<q-item-section>
<q-input v-model="bookingForm.comment" clearable autogrow filled label="Additional Comments (optional)" />
</q-item-section>
</q-item>
</q-list>
<q-card-actions align="right">
<q-btn label="Delete" color="negative" size="lg" v-if="reservation?.$id" @click="onDelete" />
<q-btn label="Reset" @click="onReset" size="lg" color="secondary" />
<q-btn label="Submit" @click="onSubmit" size="lg" color="primary" />
</q-card-actions>
</q-card>
<q-dialog v-model="boatSelect" full-width>
<BoatScheduleTableComponent :model-value="bookingForm.interval" @update:model-value="updateInterval" />
</q-dialog>
</div>
</template>

View File

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

View File

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

View File

@@ -1,3 +1,29 @@
<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"
@@ -6,21 +32,12 @@
: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">
<!-- TODO: Template this to be DRY -->
<q-item
clickable
v-ripple
:to="link.to">
<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 }}
@@ -28,18 +45,11 @@
</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">
<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 }}
@@ -49,10 +59,11 @@
</div>
</q-list>
</template>
<q-item
clickable
v-ripple
@click="logout()">
<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>
@@ -61,19 +72,6 @@
</q-drawer>
</template>
<script lang="ts" setup>
import { defineComponent } from 'vue';
import { enabledLinks } from 'src/router/navlinks.js';
import { logout } from 'boot/appwrite';
defineProps(['drawer']);
defineEmits(['drawer-toggle']);
defineComponent({
name: 'LeftDrawer',
});
</script>
<style lang="sass" scoped>
.menu-list .q-item
border-radius: 0 32px 32px 0

View File

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

View File

@@ -1,40 +1,116 @@
<!-- This has been abandoned for now. Going to block-based booking. Will probably need the schedule viewer functionality at some point in the future, though -->
<!-- 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. Select the duration below.
Use the calendar to pick a date. Tap a box in the grid for the boat and start time.
</div>
<div style="width: 100%; display: flex; justify-content: center">
<div
style="
width: 50%;
max-width: 350px;
display: flex;
justify-content: space-between;
">
<span
class="q-button"
style="cursor: pointer; user-select: none"
@click="onPrev">
&lt;
</span>
<div style="width: 50%; max-width: 350px; display: flex; justify-content: space-between">
<span class="q-button" style="cursor: pointer; user-select: none" @click="onPrev">&lt;</span>
{{ formattedMonth }}
<span
class="q-button"
style="cursor: pointer; user-select: none"
@click="onNext">
&gt;
</span>
<span class="q-button" style="cursor: pointer; user-select: none" @click="onNext">&gt;</span>
</div>
</div>
<div
style="
display: flex;
justify-content: center;
align-items: center;
flex-wrap: nowrap;
">
<div style="display: flex; justify-content: center; align-items: center; flex-wrap: nowrap">
<div style="display: flex; width: 100%">
<q-calendar-month
ref="calendar"
@@ -73,176 +149,20 @@
@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 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" />
<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>
<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>
<script setup lang="ts">
import { ref } from 'vue';
import {
QCalendarResource,
TimestampOrNull,
today,
parseTimestamp,
addToDate,
Timestamp,
parsed,
} from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat';
import { useReservationStore } from 'src/stores/reservation';
import { date } from 'quasar';
import { computed } from 'vue';
import type { StatusTypes } from 'src/stores/schedule.types';
import { useIntervalStore } from 'src/stores/interval';
import { storeToRefs } from 'pinia';
interface EventData {
event: object;
scope: {
timestamp: object;
columnindex: number;
activeDate: boolean;
droppable: boolean;
};
}
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
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 date = new Date(selectedDate.value);
return monthFormatter()?.format(date);
});
const disabledBefore = computed(() => {
const todayTs = parseTimestamp(today()) as Timestamp;
return addToDate(todayTs, { day: -1 }).date;
});
function monthFormatter() {
try {
return new Intl.DateTimeFormat('en-CA' || undefined, {
month: 'long',
timeZone: 'UTC',
});
} catch (e) {
//
}
}
function getEvents(scope: ResourceIntervalScope) {
const resourceEvents = reservationStore.getReservationsByDate(
selectedDate.value,
scope.resource.$id
);
return resourceEvents.value.map((event) => {
return {
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) {
// TODO: Add a duration picker, here.
emit('onClickTime', data);
}
function onUpdateDuration(value: EventData) {
emit('onUpdateDuration', value);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onClickInterval = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onClickHeadResources = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onClickResource = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onResourceExpanded = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onMoved = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onChange = () => {};
</script>

View File

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

View File

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

View File

@@ -1,37 +1,25 @@
<script setup lang="ts">
import type { Boat } from '~/utils/boat.types';
defineProps({ boats: Array<Boat> });
</script>
<template>
<div v-if="boats">
<div v-if="boats" class="row">
<q-card
v-for="boat in boats"
:key="boat.id"
class="q-ma-sm">
: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'">
<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-actions align="evenly">
<q-btn flat>Info</q-btn>
<q-btn flat>Book</q-btn>
<q-btn flat>Check-Out</q-btn>
<q-btn flat>Check-In</q-btn>
</q-card-actions> -->
</q-card>
</div>
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
</template>
<script setup lang="ts">
import { Boat } from 'src/stores/boat';
defineProps({
boats: Array<Boat>,
});
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,93 +1,7 @@
<template>
<div>
<q-card>
<q-toolbar>
<q-toolbar-title>Select a Boat and Time</q-toolbar-title>
<q-btn
icon="close"
flat
round
dense
v-close-popup />
</q-toolbar>
<q-separator />
<CalendarHeaderComponent v-model="selectedDate" />
<div class="boat-schedule-table-component">
<QCalendarDay
ref="calendar"
class="q-pa-xs"
flat
animated
dense
:disabled-before="disabledBefore"
interval-height="24"
interval-count="18"
interval-start="06:00"
:short-interval-label="true"
v-model="selectedDate"
:column-count="boats.length"
v-touch-swipe.left.right="handleSwipe">
<template #head-day="{ scope }">
<div style="text-align: center; font-weight: 800">
{{ getBoatDisplayName(scope) }}
</div>
</template>
<template #day-body="{ scope }">
<div
v-for="block in getAvailableIntervals(
scope.timestamp,
boats[scope.columnIndex]
).value"
:key="block.$id">
<div
class="timeblock"
:disabled="beforeNow(new Date(block.end))"
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
:style="
blockStyles(
block,
scope.timeStartPos,
scope.timeDurationHeight
)
"
:id="block.id"
@click="selectBlock($event, scope, block)">
{{ boats[scope.columnIndex].name }}
<br />
{{
selectedBlock?.$id === block.$id ? 'Selected' : 'Available'
}}
</div>
</div>
<div
v-for="reservation in getBoatReservations(scope)"
:key="reservation.$id">
<div
class="reservation column"
:style="
reservationStyles(
reservation,
scope.timeStartPos,
scope.timeDurationHeight
)
">
{{ getUserName(reservation.user) || 'loading...' }}
<br />
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
</div>
</div>
</template>
</QCalendarDay>
</div>
</q-card>
</div>
</template>
<script setup lang="ts">
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
QCalendarDay,
Timestamp,
diffTimestamp,
today,
parseTimestamp,
@@ -95,15 +9,14 @@ import {
addToDate,
} from '@quasar/quasar-ui-qcalendar';
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useBoatStore } from 'src/stores/boat';
import { useAuthStore } from 'src/stores/auth';
import { Interval, Reservation } from 'src/stores/schedule.types';
import { useBoatStore } from '~/stores/boat';
import { useAuthStore } from '~/stores/auth';
import type { Interval, Reservation } from '~/utils/schedule.types';
import { storeToRefs } from 'pinia';
import { useReservationStore } from 'src/stores/reservation';
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
import { useIntervalStore } from 'src/stores/interval';
import { useReservationStore } from '~/stores/reservation';
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
import { useIntervalStore } from '~/stores/interval';
const intervalTemplateStore = useIntervalTemplateStore();
const reservationStore = useReservationStore();
@@ -111,23 +24,26 @@ const { boats } = storeToRefs(useBoatStore());
const selectedBlock = defineModel<Interval | null>();
const selectedDate = ref(today());
const { getAvailableIntervals } = useIntervalStore();
const calendar = ref<QCalendarDay | null>(null);
const calendar = ref<typeof QCalendarDay | null>(null);
const now = ref(new Date());
let intervalId: string | number | NodeJS.Timeout | undefined;
let intervalId: ReturnType<typeof setInterval> | undefined;
onMounted(async () => {
await useBoatStore().fetchBoats();
await intervalTemplateStore.fetchIntervalTemplates();
intervalId = setInterval(function () {
now.value = new Date();
}, 60000);
intervalId = setInterval(() => { now.value = new Date(); }, 60000);
});
onUnmounted(() => clearInterval(intervalId));
function handleSwipe({ ...event }) {
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
function handleSwipe({ direction }: { direction: string }) {
if (direction === 'right') {
calendar.value?.prev();
} else {
calendar.value?.next();
}
}
function reservationStyles(
reservation: Reservation,
timeStartPos: (t: string) => string,
@@ -159,9 +75,7 @@ function blockStyles(
}
function getBoatDisplayName(scope: DayBodyScope) {
return boats && boats.value[scope.columnIndex]
? boats.value[scope.columnIndex].displayName
: '';
return boats.value[scope.columnIndex]?.displayName ?? '';
}
function beforeNow(time: Date) {
@@ -174,19 +88,11 @@ function genericBlockStyle(
timeStartPos: (t: string) => string,
timeDurationHeight: (d: number) => string
) {
const s = {
top: '',
height: '',
opacity: '',
};
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';
parseInt(timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)) - 1 + 'px';
}
return s;
}
@@ -199,8 +105,7 @@ interface DayBodyScope {
}
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
if (scope.timestamp.disabled || new Date(block.end) < new Date())
return false;
if (scope.timestamp.disabled || new Date(block.end) < new Date()) return false;
selectedBlock.value = block;
}
@@ -209,13 +114,14 @@ const boatReservations = computed((): Record<string, Reservation[]> => {
.getReservationsByDate(selectedDate.value)
.value.reduce((result, reservation) => {
if (!result[reservation.resource]) result[reservation.resource] = [];
result[reservation.resource].push(reservation);
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] : [];
return boat ? boatReservations.value[boat.$id] ?? [] : [];
}
const disabledBefore = computed(() => {
@@ -224,6 +130,65 @@ const disabledBefore = computed(() => {
});
</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

View File

@@ -1,71 +1,25 @@
<template>
<div class="title-bar" style="display: flex">
<button
tabindex="0"
class="date-button direction-button direction-button__left"
@click="onPrev"
>
<span class="q-calendar__focus-helper" tabindex="-1" />
</button>
<div class="dates-holder">
<div :key="parsedStart?.date" class="internal-dates-holder">
<div v-for="day in days" :key="day.date" :style="dayStyle">
<button
tabindex="0"
style="width: 100%"
:class="dayClass(day)"
@click="selectedDate = day.date"
>
<span class="q-calendar__focus-helper" tabindex="-1" />
<div style="width: 100%; font-size: 0.9em">
{{ monthFormatter(day, true) }}
</div>
<div style="width: 100%; font-size: 1.2em; font-weight: 700">
{{ dayFormatter(day, false) }}
</div>
<div style="width: 100%; font-size: 1em">
{{ weekdayFormatter(day, true) }}
</div>
</button>
</div>
</div>
</div>
<button
tabindex="0"
class="date-button direction-button direction-button__right"
@click="onNext"
>
<span class="q-calendar__focus-helper" tabindex="-1" />
</button>
</div>
</template>
<script setup lang="ts">
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
Timestamp,
addToDate,
createDayList,
createNativeLocaleFormatter,
getEndOfWeek,
getStartOfWeek,
getWeekdaySkips,
parseTimestamp,
today,
} from '@quasar/quasar-ui-qcalendar';
import { ref, reactive, computed } from 'vue';
const selectedDate = defineModel<string>();
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]),
locale = ref('en-CA'),
monthFormatter = monthFormatterFunc(),
dayFormatter = dayFormatterFunc(),
weekdayFormatter = weekdayFormatterFunc();
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]);
const locale = ref('en-CA');
const monthFormatter = monthFormatterFunc();
const dayFormatter = dayFormatterFunc();
const weekdayFormatter = weekdayFormatterFunc();
const weekdaySkips = computed(() => {
return getWeekdaySkips(weekdays);
});
const today2 = computed(() => parseTimestamp(today()));
const parsedStart = computed(() =>
getStartOfWeek(
@@ -83,90 +37,69 @@ const parsedEnd = computed(() =>
)
);
const today2 = computed(() => {
return parseTimestamp(today());
});
const days = computed(() => {
if (parsedStart.value && parsedEnd.value) {
return createDayList(
parsedStart.value,
parsedEnd.value,
today2.value as Timestamp,
weekdaySkips.value
);
return createDayList(parsedStart.value, parsedEnd.value, today2.value as Timestamp, weekdays);
}
return [];
});
const dayStyle = computed(() => {
const width = 100 / weekdays.length + '%';
return {
width,
};
});
const dayStyle = computed(() => ({ width: 100 / weekdays.length + '%' }));
function onPrev() {
const ts = addToDate(parsedStart.value, { day: -7 });
selectedDate.value = ts.date;
selectedDate.value = addToDate(parsedStart.value, { day: -7 }).date;
}
function onNext() {
const ts = addToDate(parsedStart.value, { day: 7 });
selectedDate.value = ts.date;
selectedDate.value = addToDate(parsedStart.value, { day: 7 }).date;
}
function dayClass(day: Timestamp) {
return {
'date-button': true,
'selected-date-button': selectedDate.value === day.date,
};
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
);
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
);
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
);
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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,18 @@
<script lang="ts" setup>
import { useNavLinks } from '~/utils/navlinks';
definePageMeta({ title: 'OYS Borrow a Boat' });
const { enabledLinks } = useNavLinks();
</script>
<template>
<ToolbarComponent />
<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-item
v-for="link in enabledLinks.filter((x) => x.front_links)"
:key="link.name"
>
:key="link.name">
<q-btn
:icon="link.icon"
color="primary"
@@ -15,14 +21,8 @@
:label="link.name"
rounded
class="full-width"
:align="'left'"
/>
:align="'left'" />
</q-item>
</q-list>
</q-page>
</template>
<script lang="ts" setup>
import { enabledLinks } from 'src/router/navlinks.js';
import ToolbarComponent from 'components/ToolbarComponent.vue';
</script>

View File

@@ -1,3 +1,66 @@
<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>
@@ -5,9 +68,7 @@
<q-card
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
<q-card-section>
<q-img
fit="scale-down"
src="~assets/oysqn_logo.png" />
<q-img fit="scale-down" src="/oysqn_logo.png" />
</q-card-section>
<q-card-section>
<div class="text-center q-pt-sm">
@@ -21,37 +82,33 @@
label="E-Mail"
type="email"
color="darkblue"
filled></q-input>
filled />
<q-input
v-if="userId"
v-model="token"
label="6-digit code"
type="number"
color="darkblue"
filled></q-input>
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="doTokenLogin"
color="primary"
label="Login with E-mail"
@click="sendMagicLink"
color="secondary"
label="Send Magic Link"
style="width: 300px" />
</div>
<div class="row justify-center q-ma-sm">
<GoogleOauthComponent />
</div>
<div class="row justify-center q-ma-sm">
<DiscordOauthComponent />
</div>
<div class="row justify-center">
<q-btn
flat
color="secondary"
to="/pwreset"
label="Forgot Password?" />
type="button"
@click="doTokenLogin"
color="primary"
:label="userId ? 'Login' : 'Send Code'"
style="width: 300px" />
</div>
</q-card-section>
</q-card>
@@ -62,92 +119,9 @@
<style>
.bg-image {
background-image: url('/src/assets/oys_lighthouse.jpg');
background-image: url('~/assets/oys_lighthouse.jpg');
background-repeat: no-repeat;
background-position-x: center;
background-size: cover;
/* background-image: linear-gradient(
135deg,
#ed232a 0%,
#ffffff 75%,
#14539a 100%
); */
}
</style>
<script setup lang="ts">
import { ref } from 'vue';
import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
import DiscordOauthComponent from 'src/components/DiscordOauthComponent.vue';
import { Dialog, Notify } from 'quasar';
import { useAuthStore } from 'src/stores/auth';
import { useRouter } from 'vue-router';
import { AppwriteException } from 'appwrite';
import { APP_VERSION } from 'src/version.js';
const email = ref('');
const token = ref('');
const userId = ref();
const router = useRouter();
console.log('version:' + APP_VERSION);
const doTokenLogin = async () => {
const authStore = useAuthStore();
if (!userId.value) {
try {
const sessionToken = await authStore.createTokenSession(email.value);
userId.value = sessionToken.userId;
Dialog.create({ message: 'Check your e-mail for your login code.' });
} catch (e) {
Dialog.create({
message: 'An error occurred. Please ask for help in Discord',
});
}
} else {
const notification = Notify.create({
type: 'primary',
position: 'top',
spinner: true,
message: 'Logging you in...',
timeout: 8000,
group: false,
});
try {
await authStore.tokenLogin(userId.value, token.value);
notification({
type: 'positive',
message: 'Logged in!',
timeout: 2000,
spinner: false,
icon: 'check_circle',
});
router.replace({ name: 'index' });
} catch (error: unknown) {
if (error instanceof AppwriteException) {
if (error.type === 'user_session_already_exists') {
useRouter().replace({ name: 'index' });
notification({
type: 'positive',
message: 'Already Logged in!',
timeout: 2000,
spinner: false,
icon: 'check_circle',
});
return;
}
Dialog.create({
title: 'Login Error!',
message: error.message,
persistent: true,
});
}
notification({
type: 'negative',
message: 'Login failed.',
timeout: 2000,
});
}
}
};
</script>

View File

@@ -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>

View File

@@ -1,8 +1,28 @@
<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>
<toolbar-component pageTitle="Member Profile" />
<q-page
padding
class="row">
<q-page padding class="row">
<q-list class="col-sm-4 col-12">
<q-separator />
<q-item>
@@ -16,9 +36,7 @@
v-model="newName"
@keydown.enter.prevent="editName"
v-if="newName !== undefined" />
<div v-else>
{{ authStore.currentUser?.name }}
</div>
<div v-else>{{ authStore.currentUser?.name }}</div>
</q-item-section>
<q-item-section avatar>
<q-btn
@@ -47,52 +65,12 @@
<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>
<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>
<script setup lang="ts">
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
import { useAuthStore } from 'src/stores/auth';
import { ref } from 'vue';
const authStore = useAuthStore();
const newName = ref();
const editName = async () => {
if (newName.value) {
try {
await authStore.updateName(newName.value);
newName.value = undefined;
} catch (e) {
console.log(e);
}
} else {
newName.value = authStore.currentUser?.name || '';
}
};
</script>

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

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

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

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
<template>
<q-page padding>
<!-- content -->
</q-page>
</template>
<script setup lang="ts">
definePageMeta({ title: 'Schedule' });
</script>
<template>
<NuxtPage />
</template>

View File

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

View File

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

View File

@@ -1,8 +1,13 @@
<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-item v-for="link in navlinks" :key="link.name">
<q-btn
:icon="link.icon"
:color="link.color ? link.color : 'primary'"
@@ -15,11 +20,3 @@
</q-item>
</q-page>
</template>
<script setup lang="ts">
import { enabledLinks } from 'src/router/navlinks';
const navlinks = enabledLinks.find(
(link) => link.name === 'Schedule'
)?.sublinks;
</script>

View File

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

View File

@@ -1,180 +1,21 @@
<template>
<div class="fit row wrap justify-start items-start content-start">
<div class="q-pa-md">
<div
class="scheduler"
style="max-width: 1200px">
<NavigationBar
@next="onNext"
@today="onToday"
@prev="onPrev" />
<q-calendar-scheduler
ref="calendar"
v-model="selectedDate"
v-model:model-resources="boats"
resource-key="$id"
resource-label="name"
view="week"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
animated
bordered
:drag-enter-func="onDragEnter"
:drag-over-func="onDragOver"
:drag-leave-func="onDragLeave"
:drop-func="onDrop"
day-min-height="50px"
cell-width="150px">
<template #day="{ scope }">
<div
v-if="
filteredIntervals(scope.timestamp, scope.resource).value.length
"
style="
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
align-items: center;
font-size: 12px;
">
<template
v-for="block in sortedIntervals(scope.timestamp, scope.resource)
.value"
:key="block.id">
<q-chip class="cursor-pointer">
{{ date.formatDate(block.start, 'HH:mm') }} -
{{ date.formatDate(block.end, 'HH:mm') }}
<!-- <q-popup-edit
:model-value="block"
v-slot="scope"
buttons
@save="saveInterval"
>
TODO: Why isn't this saving?
<q-input
:model-value="date.formatDate(scope.value.start, 'HH:mm')"
dense
autofocus
type="time"
label="start"
@keyup.enter="scope.set"
@update:model-value="
(t) => {
block.start = new Date(
scope.value.start.split('T')[0] + 'T' + t
).toISOString();
}
"
/>
TODO: Clean this up
<q-input
:model-value="date.formatDate(scope.value.end, 'HH:mm')"
dense
type="time"
label="end"
@keyup.enter="scope.set"
@update:model-value="
(t) =>
(block.end = new Date(
scope.value.end.split('T')[0] + 'T' + t
).toISOString())
"
/>
</q-popup-edit>-->
</q-chip>
<q-btn
size="xs"
icon="delete"
round
@click="deleteBlock(block)" />
</template>
</div>
</template>
</q-calendar-scheduler>
</div>
</div>
<div
class="q-pa-md"
style="width: 400px">
<q-list
padding
bordered
class="rounded-borders">
<q-item>
<q-item-section>
<q-item-label overline>Availability Templates</q-item-label>
<q-item-label caption>
Drag and drop a template to a boat / date to create booking
availability
</q-item-label>
</q-item-section>
</q-item>
<q-card-actions align="right">
<q-btn
label="Add Template"
color="primary"
@click="createTemplate" />
</q-card-actions>
<q-item v-if="newTemplate.$id === 'unsaved'">
<IntervalTemplateComponent
:model-value="newTemplate"
:edit="true"
@cancel="resetNewTemplate"
@saved="resetNewTemplate" />
</q-item>
<q-separator spaced />
<IntervalTemplateComponent
v-for="template in intervalTemplates"
:key="template.$id"
:model-value="template" />
</q-list>
</div>
</div>
<q-dialog v-model="alert">
<q-card>
<q-card-section>
<div class="text-h6">Warning!</div>
</q-card-section>
<q-card-section class="q-pt-none">
Conflicting times! Please delete overlapped items!
<q-chip
v-for="item in overlapped"
:key="item.index">
{{ boats.find((b) => b.$id === item.boatId)?.name }}:
{{ date.formatDate(item.start, 'hh:mm') }} -
{{ date.formatDate(item.end, 'hh:mm') }}
</q-chip>
</q-card-section>
<q-card-actions align="right">
<q-btn
flat
label="OK"
color="primary"
v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
import {
QCalendarScheduler,
Timestamp,
today,
} from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat';
import { useIntervalStore } from 'src/stores/interval';
import type { Boat } from '~/utils/boat.types';
import { useIntervalStore } from '~/stores/interval';
import { computed, onMounted, ref } from 'vue';
import type {
Interval,
IntervalTemplate,
TimeTuple,
} from 'src/stores/schedule.types';
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
import { date } from 'quasar';
import IntervalTemplateComponent from 'src/components/scheduling/IntervalTemplateComponent.vue';
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
import IntervalTemplateComponent from '~/components/scheduling/IntervalTemplateComponent.vue';
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
import { storeToRefs } from 'pinia';
import { buildInterval, intervalsOverlapped } from 'src/utils/schedule';
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
import { buildInterval, intervalsOverlapped } from '~/utils/schedule';
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
definePageMeta({ requiredRoles: ['Schedule Admins'] });
const selectedDate = ref(today());
const { fetchBoats } = useBoatStore();
@@ -191,25 +32,18 @@ const newTemplate = ref<IntervalTemplate>({
timeTuples: [['09:00', '12:00']],
});
/* TODOS:
* Need more validation:
- Interval start < end
- Intervals don't overlap
* Need to handle case of overnight blocks.
*/
onMounted(async () => {
await fetchBoats();
await intervalTemplateStore.fetchIntervalTemplates();
});
const filteredIntervals = (date: Timestamp, boat: Boat) => {
return intervalStore.getIntervals(date, boat);
const filteredIntervals = (ts: Timestamp, boat: Boat) => {
return intervalStore.getIntervals(ts, boat);
};
const sortedIntervals = (date: Timestamp, boat: Boat) => {
const sortedIntervals = (ts: Timestamp, boat: Boat) => {
return computed(() =>
filteredIntervals(date, boat).value.sort(
filteredIntervals(ts, boat).value.sort(
(a, b) => Date.parse(a.start) - Date.parse(b.start)
)
);
@@ -225,26 +59,26 @@ function resetNewTemplate() {
function createTemplate() {
newTemplate.value.$id = 'unsaved';
}
function createIntervals(boat: Boat, templateId: string, date: string) {
const intervals = intervalsFromTemplate(boat, templateId, date);
function createIntervals(boat: Boat, templateId: string, dateStr: string) {
const intervals = intervalsFromTemplate(boat, templateId, dateStr);
intervals.forEach((interval) => intervalStore.createInterval(interval));
}
function getIntervals(date: Timestamp, boat: Boat) {
return intervalStore.getIntervals(date, boat);
function getIntervals(ts: Timestamp, boat: Boat) {
return intervalStore.getIntervals(ts, boat);
}
function intervalsFromTemplate(
boat: Boat,
templateId: string,
date: string
dateStr: string
): Interval[] {
const template = intervalTemplateStore
.getIntervalTemplates()
.value.find((t) => t.$id === templateId);
return template
? template.timeTuples.map((timeTuple: TimeTuple) =>
buildInterval(boat, timeTuple, date)
buildInterval(boat, timeTuple, dateStr)
)
: [];
}
@@ -278,7 +112,6 @@ function onDragLeave(e: DragEvent, type: string) {
}
function onDrop(
//TODO: Move all overlap checking to the store. This is too messy right now.
e: DragEvent,
type: string,
scope: { resource: Boat; timestamp: Timestamp }
@@ -288,7 +121,7 @@ function onDrop(
if ((type === 'day' || type === 'head-day') && e.dataTransfer) {
const templateId = e.dataTransfer.getData('ID');
const date = scope.timestamp.date;
const dateStr = scope.timestamp.date;
const resource = scope.resource;
const existingIntervals = getIntervals(scope.timestamp, resource);
const boatsToApply = type === 'head-day' ? boats.value : [resource];
@@ -296,13 +129,13 @@ function onDrop(
.map((boat) =>
intervalsOverlapped(
existingIntervals.value.concat(
intervalsFromTemplate(boat, templateId, date)
intervalsFromTemplate(boat, templateId, dateStr)
)
)
)
.flat(1);
if (overlapped.value.length === 0) {
boatsToApply.map((b) => createIntervals(b, templateId, date));
boatsToApply.map((b) => createIntervals(b, templateId, dateStr));
} else {
alert.value = true;
}
@@ -312,13 +145,94 @@ function onDrop(
return false;
}
function onToday() {
calendar.value.moveToToday();
}
function onPrev() {
calendar.value.prev();
}
function onNext() {
calendar.value.next();
}
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>

View File

@@ -1,10 +1,71 @@
<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" />
<navigation-bar @today="onToday" @prev="onPrev" @next="onNext" />
</div>
<div class="col q-ma-sm">
<q-calendar-scheduler
@@ -22,10 +83,7 @@
style="--calendar-resources-width: 40px">
<template #day="{ scope }">
<div
v-for="interval in getSortedIntervals(
scope.timestamp,
scope.resource
)"
v-for="interval in getSortedIntervals(scope.timestamp, scope.resource)"
:key="interval.$id"
class="q-pb-xs row"
@click="createReservationFromInterval(interval)">
@@ -36,7 +94,7 @@
:transparent="interval.user != undefined"
:color="interval.user ? 'secondary' : 'primary'"
:outline="!interval.user"
:id="interval.id">
:id="interval.$id">
{{
interval.user
? useAuthStore().getUserNameById(interval.user)
@@ -54,107 +112,6 @@
</q-page>
</template>
<script setup lang="ts">
import { useReservationStore } from 'src/stores/reservation';
import { ref } from 'vue';
import { useAuthStore } from 'src/stores/auth';
const reservationStore = useReservationStore();
import { getDate } from '@quasar/quasar-ui-qcalendar';
import { QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat';
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
import { useQuasar } from 'quasar';
import { formatTime } from 'src/utils/schedule';
import { useIntervalStore } from 'src/stores/interval';
import { Interval, Reservation } from 'src/stores/schedule.types';
import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
const boatStore = useBoatStore();
const calendar = ref();
const $q = useQuasar();
const $router = useRouter();
const { getAvailableIntervals } = useIntervalStore();
const { selectedDate } = storeToRefs(useIntervalStore());
const currentUser = useAuthStore().currentUser;
// interface DayScope {
// timestamp: Timestamp;
// columnIndex: number;
// resource: object;
// resourceIndex: number;
// indentLevel: number;
// activeDate: boolean;
// droppable: boolean;
// }
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
return getAvailableIntervals(timestamp, boat)
.value.concat(boatReservationEvents(timestamp, boat))
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
};
// Method declarations
// function slotStyle(
// event: Reservation,
// timeStartPos: (time: TimestampOrNull) => string,
// timeDurationHeight: (minutes: number) => string
// ) {
// const s = {
// top: '',
// height: '',
// 'align-items': 'flex-start',
// };
// if (timeStartPos && timeDurationHeight) {
// s.top = timeStartPos(parsed(event.start)) + 'px';
// s.height =
// timeDurationHeight(date.getDateDiff(event.end, event.start, 'minutes')) +
// 'px';
// }
// return s;
// }
const createReservationFromInterval = (interval: Interval | Reservation) => {
if (interval.user) {
if (interval.user === currentUser?.$id) {
$router.push({ name: 'edit-reservation', params: { id: interval.$id } });
} else {
return false;
}
} else {
$router.push({
name: 'reserve-boat',
query: { interval: interval.$id },
});
}
};
function handleSwipe({ ...event }) {
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
}
const boatReservationEvents = (
timestamp: Timestamp,
resource: Boat | undefined
): Reservation[] => {
if (!resource) return [] as Reservation[];
return reservationStore.getReservationsByDate(
getDate(timestamp),
(resource as Boat).$id
).value;
};
function onToday() {
calendar.value.moveToToday();
}
function onPrev() {
calendar.value.prev();
}
function onNext() {
calendar.value.next();
}
</script>
<style lang="sass">
.q-calendar-scheduler__resource
background-color: $primary

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

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import { ID, account, functions, teams } from 'boot/appwrite';
import { ExecutionMethod, OAuthProvider, type Models } from 'appwrite';
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';
@@ -36,35 +36,16 @@ export const useAuthStore = defineStore('auth', () => {
);
};
async function register(email: string, password: string) {
await account.create(ID.unique(), email, password);
return await login(email, password);
}
async function login(email: string, password: string) {
await account.createEmailPasswordSession(email, password);
await init();
}
async function createTokenSession(email: string) {
return await account.createEmailToken(ID.unique(), email);
}
async function googleLogin() {
await account.createOAuth2Session(
OAuthProvider.Google,
'https://oys.undock.ca',
'https://oys.undock.ca/login'
async function createMagicURLSession(email: string) {
return await account.createMagicURLToken(
ID.unique(),
email,
window.location.origin + '/auth/callback'
);
await init();
}
async function discordLogin() {
await account.createOAuth2Session(
OAuthProvider.Discord,
'https://oys.undock.ca',
'https://oys.undock.ca/login'
);
await init();
}
async function tokenLogin(userId: string, token: string) {
@@ -72,6 +53,11 @@ export const useAuthStore = defineStore('auth', () => {
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 {
@@ -96,11 +82,13 @@ export const useAuthStore = defineStore('auth', () => {
} catch (e) {
console.error('Failed to get username. Error: ' + e);
}
return userNames.value[id];
return userNames.value[id] ?? 'Unknown';
}
function logout() {
return account.deleteSession('current').then((currentUser.value = null));
return account.deleteSession('current').then(() => {
currentUser.value = null;
});
}
async function updateName(name: string) {
@@ -112,13 +100,11 @@ export const useAuthStore = defineStore('auth', () => {
currentUser,
getUserNameById,
hasRequiredRole,
register,
updateName,
login,
googleLogin,
discordLogin,
createTokenSession,
createMagicURLSession,
tokenLogin,
magicURLLogin,
logout,
init,
};

View File

@@ -1,28 +1,9 @@
import { Models } from 'appwrite';
import { defineStore } from 'pinia';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { AppwriteIds, databases } from '~/utils/appwrite';
import { ref } from 'vue';
import type { Boat } from '~/utils/boat.types';
// const boatSource = null;
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;
}[];
}
export { type Boat } from '~/utils/boat.types';
export const useBoatStore = defineStore('boat', () => {
const boats = ref<Boat[]>([]);
@@ -33,7 +14,7 @@ export const useBoatStore = defineStore('boat', () => {
AppwriteIds.databaseId,
AppwriteIds.collection.boat
);
boats.value = response.documents as Boat[];
boats.value = response.documents as unknown as Boat[];
} catch (error) {
console.error('Failed to fetch boats', error);
}

View File

@@ -1,28 +1,28 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { Boat } from './boat';
import { Timestamp, today } from '@quasar/quasar-ui-qcalendar';
import { Interval } from './schedule.types';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import 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 { LoadingTypes } from 'src/utils/misc';
import type { LoadingTypes } from '~/utils/misc';
import { useRealtimeStore } from './realtime';
export const useIntervalStore = defineStore('interval', () => {
const intervals = ref(new Map<string, Interval>()); // Intervals by DocID
const dateStatus = ref(new Map<string, LoadingTypes>()); // State of load by date
const 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 Interval;
const payload = response.payload as unknown as Interval;
if (!payload.$id) return;
if (
response.events.includes('databases.*.collections.*.documents.*.delete')
@@ -88,11 +88,11 @@ export const useIntervalStore = defineStore('interval', () => {
'start',
new Date(dateString + 'T23:59').toISOString()
),
Query.limit(50), // We are asuming that we won't have more than 50 intervals per day.
Query.limit(50),
]
);
response.documents.forEach((d) =>
intervals.value.set(d.$id, d as Interval)
intervals.value.set(d.$id, d as unknown as Interval)
);
dateStatus.value.set(dateString, 'loaded');
console.info(`Loaded ${response.documents.length} intervals from server`);
@@ -110,11 +110,12 @@ export const useIntervalStore = defineStore('interval', () => {
ID.unique(),
interval
);
intervals.value.set(response.$id, response as 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) {
@@ -124,7 +125,7 @@ export const useIntervalStore = defineStore('interval', () => {
interval.$id,
{ ...interval, $id: undefined }
);
intervals.value.set(response.$id, response as Interval);
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');
@@ -133,6 +134,7 @@ export const useIntervalStore = defineStore('interval', () => {
console.error('Error updating Interval: ' + e);
}
};
const deleteInterval = async (id: string) => {
try {
await databases.deleteDocument(

View File

@@ -1,15 +1,16 @@
import { Ref, ref } from 'vue';
import { IntervalTemplate } from './schedule.types';
import type { Ref } from 'vue';
import { ref } from 'vue';
import type { IntervalTemplate } from '~/utils/schedule.types';
import { defineStore } from 'pinia';
import { AppwriteIds, databases } from 'src/boot/appwrite';
import { ID, Models } from 'appwrite';
import { arrayToTimeTuples } from 'src/utils/schedule';
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[]> => {
// Should subscribe to get new intervaltemplates when they are created
if (!intervalTemplates.value) fetchIntervalTemplates();
return intervalTemplates;
};
@@ -20,14 +21,13 @@ export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
AppwriteIds.databaseId,
AppwriteIds.collection.intervalTemplate
);
intervalTemplates.value = response.documents.map(
(d: Models.Document): IntervalTemplate => {
intervalTemplates.value = response.documents.map((d): IntervalTemplate => {
const doc = d as unknown as { timeTuple: string[] } & Models.Document;
return {
...d,
timeTuples: arrayToTimeTuples(d.timeTuple),
} as IntervalTemplate;
}
);
...doc,
timeTuples: arrayToTimeTuples(doc.timeTuple),
} as unknown as IntervalTemplate;
});
} catch (error) {
console.error('Failed to fetch timeblock templates', error);
}
@@ -41,11 +41,12 @@ export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
ID.unique(),
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
);
intervalTemplates.value.push(response as IntervalTemplate);
intervalTemplates.value.push(response as unknown as IntervalTemplate);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);
console.error('Error creating IntervalTemplate: ' + e);
}
};
const deleteIntervalTemplate = async (id: string) => {
try {
await databases.deleteDocument(
@@ -60,6 +61,7 @@ export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
console.error('Error deleting IntervalTemplate: ' + e);
}
};
const updateIntervalTemplate = async (
template: IntervalTemplate,
id: string
@@ -79,8 +81,10 @@ export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
? b
: ({
...response,
timeTuples: arrayToTimeTuples(response.timeTuple),
} as IntervalTemplate)
timeTuples: arrayToTimeTuples(
(response as unknown as { timeTuple: string[] }).timeTuple
),
} as unknown as IntervalTemplate)
);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);

View File

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

View File

@@ -1,17 +1,16 @@
import { defineStore } from 'pinia';
import { client } from 'src/boot/appwrite';
import { Interval } from './schedule.types';
import { client } from '~/utils/appwrite';
import { ref } from 'vue';
import { RealtimeResponseEvent } from 'appwrite';
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<Interval>) => void
fn: (response: RealtimeResponseEvent<unknown>) => void
) => {
if (subscriptions.value.has(channel)) return; // Already subscribed. But maybe different callback fn?
if (subscriptions.value.has(channel)) return;
subscriptions.value.set(channel, client.subscribe(channel, fn));
};

View File

@@ -20,26 +20,26 @@ function getSampleData(): ReferenceEntry[] {
content: `Its hard to imagine that a modern 27 foot sailboat with a classic look, superb
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
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.
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
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
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
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
footers!
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
significant gains. Then off wind, you quickly learn to play gibe angles as the boat\s
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
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.
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
difference. It\s all done from a cockpit which holds several people more than is possible
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
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
and secure seating. Harken mainsheet, vang, traveler, and backstay systems; four Barient
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
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
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
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
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, sail in North American, Midwinter, and Regional championships. A superb J/27 Class
@@ -113,15 +113,11 @@ export const useReferenceStore = defineStore('reference', {
getters: {
getCategory(state) {
(category: string) => {
return (category: string) => {
return state.allItems.filter((c) => c.category === category);
};
},
},
actions: {
// increment () {
// this.counter++;
// }
},
actions: {},
});

View File

@@ -1,13 +1,15 @@
import { defineStore } from 'pinia';
import type { Reservation } from './schedule.types';
import { ComputedRef, computed, reactive } from 'vue';
import { AppwriteIds, databases } from 'src/boot/appwrite';
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 { Timestamp, parseDate, today } from '@quasar/quasar-ui-qcalendar';
import { LoadingTypes } from 'src/utils/misc';
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 'src/utils/schedule';
import { isPast } from '~/utils/schedule';
import { useRealtimeStore } from './realtime';
export const useReservationStore = defineStore('reservation', () => {
@@ -22,7 +24,7 @@ export const useReservationStore = defineStore('reservation', () => {
realtimeStore.register(
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.reservation}.documents`,
(response) => {
const payload = response.payload as Reservation;
const payload = response.payload as unknown as Reservation;
if (payload.$id) {
if (
response.events.includes(
@@ -39,7 +41,7 @@ export const useReservationStore = defineStore('reservation', () => {
}
}
);
// Fetch reservations for a specific date range
const fetchReservationsForDateRange = async (
start: string = today(),
end: string = start
@@ -62,7 +64,7 @@ export const useReservationStore = defineStore('reservation', () => {
);
response.documents.forEach((d) =>
reservations.set(d.$id, d as Reservation)
reservations.set(d.$id, d as unknown as Reservation)
);
setDateLoaded(startDate, endDate, 'loaded');
} catch (error) {
@@ -70,6 +72,7 @@ export const useReservationStore = defineStore('reservation', () => {
setDateLoaded(startDate, endDate, 'error');
}
};
const getReservationById = async (id: string) => {
try {
const response = await databases.getDocument(
@@ -77,7 +80,7 @@ export const useReservationStore = defineStore('reservation', () => {
AppwriteIds.collection.reservation,
id
);
return response as Reservation;
return response as unknown as Reservation;
} catch (error) {
console.error('Failed to fetch reservation: ', error);
}
@@ -103,10 +106,10 @@ export const useReservationStore = defineStore('reservation', () => {
reservation
);
}
reservations.set(response.$id, response as Reservation);
userReservations.set(response.$id, response as 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 Reservation;
return response as unknown as Reservation;
} catch (e) {
console.error('Error creating Reservation: ' + e);
throw e;
@@ -157,7 +160,6 @@ export const useReservationStore = defineStore('reservation', () => {
}
};
// Set the loading state for dates
const setDateLoaded = (start: Date, end: Date, state: LoadingTypes) => {
if (start > end) return [];
let curDate = start;
@@ -179,7 +181,6 @@ export const useReservationStore = defineStore('reservation', () => {
return unloaded;
};
// Get reservations by date and optionally filter by boat
const getReservationsByDate = (
searchDate: string,
boat?: string
@@ -203,7 +204,6 @@ export const useReservationStore = defineStore('reservation', () => {
});
};
// Get conflicting reservations for a resource within a time range
const getConflictingReservations = (
resource: string,
start: Date,
@@ -217,7 +217,6 @@ export const useReservationStore = defineStore('reservation', () => {
);
};
// Check if a resource has time overlap
const isResourceTimeOverlapped = (
resource: string,
start: Date,
@@ -226,7 +225,6 @@ export const useReservationStore = defineStore('reservation', () => {
return getConflictingReservations(resource, start, end).length > 0;
};
// Check if a reservation overlaps with existing reservations
const isReservationOverlapped = (res: Reservation): boolean => {
return isResourceTimeOverlapped(
res.resource,
@@ -244,7 +242,7 @@ export const useReservationStore = defineStore('reservation', () => {
[Query.equal('user', authStore.currentUser.$id)]
);
response.documents.forEach((d) =>
userReservations.set(d.$id, d as Reservation)
userReservations.set(d.$id, d as unknown as Reservation)
);
} catch (error) {
console.error('Failed to fetch reservations for user: ', error);

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

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

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

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

View File

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

View File

@@ -1,8 +1,8 @@
import { useAuthStore } from 'src/stores/auth';
import { useAuthStore } from '~/stores/auth';
export type Link = {
name: string;
to: string;
to?: string;
icon: string;
front_links?: boolean;
enabled?: boolean;
@@ -11,7 +11,7 @@ export type Link = {
requiredRoles?: string[];
};
export const links = <Link[]>[
export const links: Link[] = [
{
name: 'Home',
to: '/',
@@ -40,36 +40,9 @@ export const links = <Link[]>[
front_links: true,
enabled: true,
sublinks: [
{
name: 'My View',
to: '/schedule/list',
icon: 'list',
front_links: false,
enabled: true,
},
{
name: 'Book',
to: '/schedule/book',
icon: 'more_time',
front_links: false,
enabled: true,
},
{
name: 'Calendar',
to: '/schedule/view',
icon: 'calendar_month',
front_links: false,
enabled: true,
},
{
name: 'Manage',
to: '/schedule/manage',
icon: 'edit_calendar',
front_links: false,
enabled: true,
color: 'accent',
requiredRoles: ['Schedule Admins'],
},
{ name: '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 },
],
},
{
@@ -94,28 +67,46 @@ export const links = <Link[]>[
enabled: false,
},
{
name: 'Tasks',
to: '/task',
icon: 'build',
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;
const hasRole = authStore.hasRequiredRole(roles);
return hasRole;
return authStore.hasRequiredRole(roles);
}
export const enabledLinks = links
const enabledLinks = links
.filter((link) => link.enabled)
.map((link) => {
if (link.sublinks) {
link.sublinks = link.sublinks.filter(
return {
...link,
sublinks: link.sublinks.filter(
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
);
),
};
}
return link;
});
return { enabledLinks };
}

View File

@@ -1,15 +1,11 @@
import { date } from 'quasar';
import { Boat } from 'src/stores/boat';
import {
Interval,
IntervalTemplate,
TimeTuple,
} from 'src/stores/schedule.types';
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]]);
timeTuples.push([arr[i]!, arr[i + 1]!]);
}
return timeTuples;
}
@@ -24,20 +20,18 @@ export function timeTuplesOverlapped(tuples: TimeTuple[]): Interval[] {
};
})
).map((t) => {
return { ...t, start: t.start.split(' ')[1], end: t.end.split(' ')[1] };
return { ...t, start: t.start.split(' ')[1]!, end: t.end.split(' ')[1]! };
});
}
export function intervalsOverlapped(
blocks: Interval[] | Interval[]
): Interval[] {
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);
if (i > 0 && block.start < arr[i - 1]!.end)
acc.push(arr[i - 1]!, block);
return acc;
}, [])
)
@@ -47,36 +41,27 @@ export function intervalsOverlapped(
export function copyTimeTuples(tuples: TimeTuple[]): TimeTuple[] {
return tuples.map((t) => Object.assign([], t));
}
export function copyIntervalTemplate(
template: IntervalTemplate
): IntervalTemplate {
export function copyIntervalTemplate(template: IntervalTemplate): IntervalTemplate {
return {
...template,
timeTuples: copyTimeTuples(template.timeTuples || []),
};
}
export function buildInterval(
resource: Boat,
time: TimeTuple,
blockDate: string
): Interval {
/* When the time zone offset is absent, date-only forms are interpreted
as a UTC time and date-time forms are interpreted as local time. */
const result = {
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(),
};
return result;
}
export const isPast = (itemDate: Date | string): boolean => {
if (!(itemDate instanceof Date)) {
itemDate = new Date(itemDate);
}
const currentDate = new Date();
return itemDate < currentDate;
return itemDate < new Date();
};
export function formatDate(inputDate: string | undefined): string {

View File

@@ -1,4 +1,4 @@
import { Models } from 'appwrite';
import type { Models } from 'appwrite';
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
export type Reservation = Interval & {
@@ -12,9 +12,6 @@ export type Reservation = Interval & {
// 24 hrs in advance only 2 weekday, and 1 weekend slot
// Within 24 hrs, any available slot
/* TODO: Figure out how best to separate out where qcalendar bits should be.
e.g.: Should there be any qcalendar stuff in this store? Or should we have just JS Date
objects in here? */
export type TimeTuple = [start: string, end: string];
@@ -22,6 +19,7 @@ export type Interval = Partial<Models.Document> & {
resource: string;
start: string;
end: string;
user?: string;
};
export type IntervalTemplate = Partial<Models.Document> & {

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
# Document Processing Protocol
Use this whenever you need to process multiple documents or large files.
## For 1-3 Short Documents (< 2K words each)
Read sequentially. After each document, write a Source Document Summary (Template 1 from `templates/claude-templates.md`) to disk. Then proceed with work using summaries only.
## For 4+ Documents OR Any Document > 2K Words
**Step 1:** List all documents with file sizes. Present to user for prioritization.
**Step 2:** Process each document individually:
- Read one document
- Extract into Source Document Summary format
- Write to `./docs/summaries/source-[filename].md`
- Release the document from active consideration before reading the next
**Step 3:** After all documents are processed, read only the summaries to form your working context.
**Step 4:** Cross-reference summaries for contradictions or dependencies. Note these explicitly.
**Step 5:** Proceed with the actual task using summaries as your reference.

View File

@@ -0,0 +1,18 @@
# Subagent Deployment Rules
## When to Use Subagent vs. Main Agent
| Situation | Approach | Why |
|-----------|----------|-----|
| Reading/analyzing documents | Subagent | Keeps source content out of main context |
| Research and competitive analysis | Subagent | Heavy reading, return summary only |
| Writing deliverables | Main agent | Needs full decision-making context |
| Schema/architecture design | Main agent | Needs holistic project understanding |
| Code generation | Subagent | Isolated implementation, return result |
| Review and QA | Subagent | Fresh perspective, no bias from writing |
## Output Requirements
Subagent output must conform to the Output Contracts in `templates/claude-templates.md`. No free-form prose returns.
Optimal subagent return size: 1,000-2,000 tokens of structured summary. Longer returns consume main agent context without proportional benefit.

View File

@@ -0,0 +1,106 @@
# Session Handoff: Nuxt Migration — Phases 1 & 2 Complete
**Date:** 2026-03-19
**Session Duration:** ~2 hours
**Session Focus:** Framework selection, migration planning, and execution of Phases 1 & 2 of Quasar → Nuxt 3 migration
**Context Usage at Handoff:** ~60%
## What Was Accomplished
1. Framework analysis (React Native vs Flutter vs Next/Nuxt) → decision: Nuxt 3 + Capacitor (deferred) + FullCalendar
2. Migration plan created → `docs/summaries/handoff-2026-03-18-nuxt-migration-plan.md` (now the active plan reference)
3. Phase 1 — Foundation complete → `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/`
4. Phase 2 — Appwrite plugin + types complete → `app/utils/`, `app/plugins/`
5. Integration tests written and passing (11/11) → `tests/appwrite-connection.test.ts`
6. Task feature parked → removed from AppwriteIds and tests
## Exact State of Work in Progress
- **Migration plan**: 10-phase plan in `docs/summaries/handoff-2026-03-18-nuxt-migration-plan.md`. Phases 12 marked complete. Phase 3 (Auth) is next.
- **New project**: `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/` — scaffolded and running. Dev server confirmed clean on `localhost:3000`.
- **Old project**: `/home/ptoal/Dev/mobile-projects/bab-app/` — untouched, still operational. Do not delete until Phase 6 is complete.
## Decisions Made This Session
- **Nuxt 3 + nuxt-quasar-ui** — keeps all Quasar (`q-*`) components working; avoids big-bang UI rewrite. CONFIRMED.
- **FullCalendar non-commercial license** — key `CC-Attribution-NonCommercial-NoDerivatives` confirmed valid. OYS is Ontario not-for-profit #000082982. CONFIRMED.
- **PWA only initially** — Capacitor deferred until native camera/GPS features are actively needed. CONFIRMED.
- **Static output** — `nuxt generate``.output/public/` served via nginx. Consistent with existing Ansible deploy. CONFIRMED.
- **TestFlight/App Store deferred** — revisit when PWA hits UX or capability limits. CONFIRMED.
- **Auth: magic link + email OTP only** — password auth and Google (`vue3-google-login`) removed. CONFIRMED.
- **Task feature parked** — collections `task`, `taskTags`, `skillTags` absent from `bab_prod`. Not migrated until Appwrite collections are created. CONFIRMED.
## Key Numbers Generated or Discovered This Session
- **11/11 tests passing** — backend connection verified
- **4 collections confirmed reachable** — `boat`, `reservation`, `interval`, `intervalTemplate` (return 401 unauthenticated = exist and require auth)
- **3 collections absent** — `task`, `taskTags`, `skillTags` return 404 from `bab_prod`
- **Appwrite project ID**: `65ede55a213134f2b688`
- **Appwrite endpoint**: `https://appwrite.toal.ca/v1`
- **Database ID**: `bab_prod`
- **~12 days** total estimated migration effort (unchanged)
- **Nuxt version**: 4.4.2 (installed as `nuxt@^4.4.2`)
- **nuxt-quasar-ui version**: 3.0.1
- **fontIcons config** must be an array `['material-icons']`, not a string — bug if passed as string (iterates characters)
## Conditional Logic Established
- IF task feature is needed, THEN create collections `task`, `taskTags`, `skillTags` in Appwrite dashboard first, THEN migrate `stores/task.ts`, `components/task/*`, `pages/task/*`
- IF Capacitor is added later, THEN change `nuxt.config.ts` `webDir` to `.output/public` and run `npx cap add ios && npx cap add android`
- IF App Store publishing is pursued, THEN use TestFlight (iOS) + Play Store internal testing track, NOT full public store listing (3050 user club app)
## Files Created or Modified
| File Path | Action | Description |
|-----------|--------|-------------|
| `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/` | Created | New Nuxt 4.4.2 project root |
| `bab-app-nuxt/nuxt.config.ts` | Created | ssr:false, nuxt-quasar-ui, @pinia/nuxt, @vite-pwa/nuxt, PWA manifest, runtimeConfig |
| `bab-app-nuxt/.env.local` | Created | `NUXT_PUBLIC_APPWRITE_ENDPOINT`, `NUXT_PUBLIC_APPWRITE_PROJECT_ID` |
| `bab-app-nuxt/public/icons/` | Created | Copied from old project — all PWA icon sizes |
| `bab-app-nuxt/app/utils/appwrite.ts` | Created | Appwrite client + services + AppwriteIds (task collections excluded) |
| `bab-app-nuxt/app/utils/boat.types.ts` | Created | `Boat` interface (extracted so schedule.ts can depend on it without the store) |
| `bab-app-nuxt/app/utils/schedule.types.ts` | Created | `Interval`, `Reservation`, `IntervalTemplate`, `TimeTuple` |
| `bab-app-nuxt/app/utils/misc.ts` | Created | `getNewId()`, `LoadingTypes` |
| `bab-app-nuxt/app/utils/schedule.ts` | Created | Schedule utilities; `date` from quasar kept; imports updated to `~/utils/*` |
| `bab-app-nuxt/app/plugins/appwrite.client.ts` | Created | Stub plugin; auth store init wired in Phase 3 |
| `bab-app-nuxt/vitest.config.ts` | Created | Vitest with `loadEnv` to pick up `.env.local` |
| `bab-app-nuxt/tests/appwrite-connection.test.ts` | Created | 11 integration tests verifying backend connectivity |
| `bab-app-nuxt/package.json` | Modified | Added `test` and `test:watch` scripts |
| `docs/summaries/handoff-2026-03-18-nuxt-migration-plan.md` | Modified | Added confirmed decisions, updated Phase 1 complete, task parked in Phase 7 |
## What the NEXT Session Should Do
1. **First**: Read `docs/summaries/handoff-2026-03-18-nuxt-migration-plan.md` for the full 10-phase plan and current state
2. **Then**: Execute **Phase 3 — Auth**
- Migrate `src/stores/auth.ts``bab-app-nuxt/app/stores/auth.ts`
- Strip password auth methods; keep magic link + email OTP only
- Remove `vue3-google-login` from `bab-app-nuxt/package.json`
- Create `app/middleware/auth.global.ts` (replaces old router navigation guard in `src/router/index.ts`)
- Create `app/pages/auth/callback.vue` — handles magic link redirect `?userId=&secret=` params
- Wire `authStore.init()` call into `app/plugins/appwrite.client.ts`
3. **Then**: Execute **Phase 4 — Remaining Stores** (all are near-verbatim Pinia ports)
- `stores/boat.ts` — import `Boat` from `~/utils/boat.types` instead of defining inline
- `stores/reservation.ts`, `interval.ts`, `intervalTemplate.ts`, `reference.ts`, `realtime.ts`, `memberProfile.ts`
- Skip `stores/task.ts` (parked)
## Open Questions Requiring User Input
- [ ] **Appwrite `task`/`taskTags`/`skillTags` collections** — will these ever be created in `bab_prod`? Determines whether task feature gets migrated at all, or is permanently dropped.
## Assumptions That Need Validation
- ASSUMED: `nuxt generate` (static) is sufficient — no SSR/server-side rendering needed. Validate: all Appwrite calls are client-side only (no server routes needed).
- ASSUMED: `LeftDrawer.vue` exists in `src/components/` (referenced in codebase exploration but not confirmed by direct file read). Verify before Phase 6.
## What NOT to Re-Read
- `src/boot/appwrite.ts` — fully migrated; summarized above
- `src/utils/misc.ts`, `src/utils/schedule.ts`, `src/stores/schedule.types.ts` — fully migrated; summarized above
- `templates/claude-templates.md` — not needed next session
## Files to Load Next Session
- `docs/summaries/handoff-2026-03-18-nuxt-migration-plan.md` — active migration plan with all phases
- `bab-app-nuxt/app/utils/appwrite.ts` — current AppwriteIds shape (needed for store migration)
- `bab-app-nuxt/app/plugins/appwrite.client.ts` — needs auth init wired in Phase 3
- `src/stores/auth.ts` — source for Phase 3 migration (read once, then discard)
- `src/router/index.ts` — source for middleware logic (read once, then discard)

View File

@@ -0,0 +1,112 @@
# Session Handoff: Nuxt Migration — Phases 35 Complete
**Date:** 2026-03-19
**Session Focus:** Auth, all stores, all pages, all layouts, all components
**Context at Handoff:** High — recommend fresh session for Phase 6+
## What Was Accomplished
1. Phase 3 — Auth (stores/auth.ts, middleware, callback page, plugin)
2. Phase 4 — All 7 non-task stores
3. Phase 5 — All pages, layouts, and non-task components
## Exact State of bab-app-nuxt
### Layouts
- `app/layouts/default.vue` — MainLayout port; reads `route.meta.title` for header title
- `app/layouts/admin.vue` — AdminLayout port; tabs updated to `/admin/user` and `/admin/boat`
- `app/app.vue` — Updated to `<NuxtLayout><NuxtPage /></NuxtLayout>`
### Pages (all created)
| Nuxt Path | Old Path | Notes |
|-----------|----------|-------|
| `pages/index.vue` | `IndexPage.vue` | Uses `useNavLinks()` composable |
| `pages/login.vue` | `LoginPage.vue` | `layout: false`; magic link `onMounted` removed (→ `auth/callback.vue`) |
| `pages/auth/callback.vue` | (new) | Handles `?userId=&secret=` from magic link |
| `pages/boat.vue` | `BoatPage.vue` | |
| `pages/profile.vue` | `ProfilePage.vue` | |
| `pages/certification.vue` | `CertificationPage.vue` | |
| `pages/checklist.vue` | `ChecklistPage.vue` | |
| `pages/schedule.vue` | `SchedulePageView.vue` | Parent wrapper; `<NuxtPage />` |
| `pages/schedule/index.vue` | `ScheduleIndexPage.vue` | |
| `pages/schedule/book.vue` | `BoatReservationPage.vue` | |
| `pages/schedule/view.vue` | `BoatScheduleView.vue` | |
| `pages/schedule/list.vue` | `ListReservationsPage.vue` | |
| `pages/schedule/edit/[id].vue` | `ModifyBoatReservation.vue` | |
| `pages/schedule/manage.vue` | `ManageCalendar.vue` | `requiredRoles: ['Schedule Admins']` |
| `pages/reference.vue` | `ReferencePage.vue` | Parent wrapper |
| `pages/reference/index.vue` | `ReferenceIndexPage.vue` | |
| `pages/reference/reference/[id]/view.vue` | `ReferenceItemPage.vue` | Double `reference` path preserved from old routes |
| `pages/admin/user.vue` | `UserAdminPage.vue` | `layout: 'admin'`, `requiredRoles: ['admin']` |
| `pages/admin/boat.vue` | `BoatAdminPage.vue` | `layout: 'admin'`, `requiredRoles: ['admin']` |
| `pages/signup.vue` | `SignupPage.vue` | **STUB** — password auth removed; shows "contact admin" |
| `pages/pwreset.vue` | `ResetPassword.vue` | **STUB** — password auth removed |
| `pages/terms-of-service.vue` | `TermsOfServicePage.vue` | `layout: false` |
| `pages/privacy-policy.vue` | `PrivacyPolicyPage.vue` | `layout: false` |
| `pages/[...slug].vue` | `ErrorNotFound.vue` | Catch-all 404 |
### Components (all created under `app/components/`)
- `boat/BoatComponent.vue` — stub
- `boat/BoatPickerComponent.vue`
- `boat/BoatPreviewComponent.vue`
- `CertificationComponent.vue`
- `ReferencePreviewComponent.vue`
- `BoatReservationComponent.vue`
- `ResourceScheduleViewerComponent.vue` — abandoned/retained for reference
- `scheduling/NavigationBar.vue`
- `scheduling/ReservationCardComponent.vue` — named route updated to `/schedule/edit/${id}`
- `scheduling/BoatSelection.vue` — empty stub
- `scheduling/IntervalTemplateComponent.vue`
- `scheduling/boat/BoatScheduleTableComponent.vue`
- `scheduling/boat/CalendarHeaderComponent.vue`
- `LeftDrawer.vue` — uses `useNavLinks()` composable; logout via `authStore.logout()`
- `BottomNavComponent.vue`
### Utils added
- `app/utils/navlinks.ts``useNavLinks()` composable (replaces module-level `enabledLinks`)
- `app/utils/version.ts``APP_VERSION = '0.0.0'`
- `app/assets/` — All assets copied from old project
### Skipped (task feature parked)
- `task/TaskEditPage.vue`, `task/TaskPage.vue`, `admin/TaskAdminPage.vue`
- `task/TaskEditComponent.vue`, `task/TaskListComponent.vue`, `task/TaskCardComponent.vue`, `task/TaskTableComponent.vue`
- `NewPasswordComponent.vue` (password auth removed)
## Key Decisions Made This Session
- **ToolbarComponent dropped** — layout `default.vue` handles title via `route.meta.title`. Pages use `definePageMeta({ title: '...' })` instead.
- **Public pages pattern** — `definePageMeta({ public: true, layout: false })` for all auth-less full-layout pages
- **Magic link redirect** — `/auth/callback` (not `/login`) — matches Phase 3 decision
- **signup.vue / pwreset.vue as stubs** — password auth removed; stubs show redirect message. CONFIRMED.
- **navlinks.ts → composable** — original had module-level `useAuthStore()` which would break in Nuxt; converted to `useNavLinks()` composable
- **Nuxt named routes not used** — all navigation uses explicit paths (`/schedule/edit/${id}` not `{ name: 'edit-reservation' }`) to avoid Nuxt route name collisions
## Open Questions
- [ ] **OPEN**: `task`/`taskTags`/`skillTags` collections — will they ever be created in `bab_prod`?
- [x] **RESOLVED**: `signup.vue` — admin-only invite is permanent. `signup.vue` stub is the final state; self-service registration will not be built.
## What NEXT Session Should Do
1. **Phase 6 — Build & Test**
- Run `yarn dev` in `bab-app-nuxt/` — verify dev server starts clean
- Navigate to `/login` — verify page renders
- Test login flow (magic link or OTP)
- Navigate through key pages post-login
- Fix any TypeScript or import errors that surface
2. **Phase 7 — nuxt.config.ts QCalendar registration**
- `@quasar/quasar-ui-qcalendar` is imported directly in components — verify this works in Nuxt (no plugin registration needed if imported directly)
- If calendar components don't render, add CSS import to `nuxt.config.ts`
3. **Phase 8 — Deploy** (if Phase 6 passes)
- `yarn generate` → verify `.output/public/` built correctly
- Update Ansible deploy playbook if needed (path may differ from old `dist/`)
## Files to Load Next Session
- `bab-app-nuxt/nuxt.config.ts` — check QCalendar CSS import, verify PWA config
- `bab-app-nuxt/package.json` — verify no missing deps
- Any error output from `yarn dev`
## What NOT to Re-Read
- All files in `bab-app-nuxt/app/` — just created; content known
- All files in `bab-app/src/` — fully migrated; do not re-read

View File

@@ -0,0 +1,82 @@
# Session Handoff: Nuxt Migration — Phase 6 Complete
**Date:** 2026-03-19
**Session Focus:** Build & TypeScript error fixes
**Context at Handoff:** Low — clean build, ready for Phase 7+
## What Was Accomplished
**Phase 6 — Build & Test**
1. Added missing dependency `@quasar/quasar-ui-qcalendar@4.1.2` to `bab-app-nuxt/package.json` (was in original app but omitted from nuxt package.json)
2. Fixed all TypeScript errors — 0 errors after fixes (verified via `nuxi typecheck`)
3. Dev server (`yarn dev`) starts clean — no errors, one benign PWA workbox warning
## TypeScript Fixes Applied
### verbatimModuleSyntax: split type-only imports (TS1484/TS1485)
| File | Fixed |
|------|-------|
| `app/stores/reservation.ts` | `ComputedRef` from vue, `Timestamp` from qcalendar |
| `app/stores/intervalTemplate.ts` | `Ref` from vue, `Models` from appwrite |
| `app/stores/interval.ts` | `Timestamp` from qcalendar |
| `app/components/ResourceScheduleViewerComponent.vue` | `TimestampOrNull`, `Timestamp` from qcalendar |
| `app/components/scheduling/boat/BoatScheduleTableComponent.vue` | `Timestamp` from qcalendar |
| `app/components/scheduling/boat/CalendarHeaderComponent.vue` | `Timestamp` from qcalendar |
| `app/pages/schedule/manage.vue` | `Timestamp` from qcalendar |
| `app/pages/schedule/view.vue` | `Timestamp` from qcalendar |
### Actual type errors fixed
| File | Error | Fix |
|------|-------|-----|
| `app/stores/auth.ts:85` | `string \| undefined` return | `?? 'Unknown'` |
| `app/utils/schedule.ts:8` | array index `string \| undefined` | non-null assertion `!` on `arr[i]` |
| `app/utils/schedule.ts:23` | `split()[1]` is `string \| undefined` | non-null assertion `!` |
| `app/utils/schedule.ts:33-34` | `arr[i-1]` possibly undefined | non-null assertion `!` |
| `app/components/BoatReservationComponent.vue:57` | `updateInterval` handler type mismatch with `defineModel` emit | widened to `Interval \| null \| undefined` |
| `app/components/scheduling/boat/BoatScheduleTableComponent.vue:79` | `boats.value[i].displayName` after guard | `?.displayName ?? ''` |
| `app/components/scheduling/boat/BoatScheduleTableComponent.vue:119` | `result[key].push()` after undefined check | non-null assertion `!` |
| `app/components/scheduling/boat/BoatScheduleTableComponent.vue:126` | `Record[key]` returns `T \| undefined` | `?? []` |
| `app/components/scheduling/boat/BoatScheduleTableComponent.vue:175` | `boats[i].name` in template | `?.name` |
## Known Non-Errors (ignored)
- **PWA workbox WARN**: `_nuxt/builds/**/*.json` pattern matches nothing in dev mode — expected, not present in dev SW dist
- **vue-router volar warning**: `Cannot find module '@vue/language-core'` during `nuxi typecheck` — npx version mismatch, does not affect build
- **Deprecated Appwrite API hints (TS6387)**: `databases.listDocuments`, `createDocument`, etc. show as deprecated — these are hints, not errors; the old API still works. OPEN: migrate to new Appwrite SDK v14+ API signatures in a future session.
## Current State
- `yarn dev` — clean build, no errors
- `nuxi typecheck` — 0 TS errors
- All pages, stores, layouts, components in place from Phases 15
- `@quasar/quasar-ui-qcalendar@4.1.2` installed
## What NEXT Session Should Do
1. **Phase 7 — QCalendar CSS / runtime verification**
- Start `yarn dev`, open browser to `http://localhost:3000`
- Navigate to `/login` — verify page renders
- Test login flow (OTP or magic link)
- Navigate to `/schedule/book` — verify QCalendarDay renders correctly
- If calendar has no styling, add to `nuxt.config.ts`:
```ts
css: ['@quasar/quasar-ui-qcalendar/dist/index.css']
```
- Navigate to `/admin/user` and `/admin/boat` — verify admin layout renders
2. **Phase 8 — Generate & Deploy**
- `yarn generate` → verify `.output/public/` built
- Update Ansible deploy playbook if dist path changed from old `dist/` to `.output/public/`
- Check `.gitea/workflows/build.yaml` — may need path update
## Open Questions
- [ ] **OPEN**: Appwrite SDK deprecated API calls — migrate to v14+ signatures? (TS6387 hints in all stores)
- [ ] **OPEN**: `task`/`taskTags`/`skillTags` collections — will they ever be created in `bab_prod`?
## Files to Load Next Session
- `bab-app-nuxt/nuxt.config.ts` — if CSS import needed
- `.gitea/workflows/build.yaml` — check output path for deploy
- Any browser console errors from `yarn dev`

View File

@@ -0,0 +1,93 @@
# Session Handoff: Nuxt Migration — Phase 7 Complete
**Date:** 2026-03-19
**Session Focus:** Runtime verification and fix
**Context at Handoff:** Low — all pages working in dev, ready for Phase 8
## What Was Accomplished
**Phase 7 — Runtime Verification & Fixes**
All pages now load correctly in dev. Eight issues found and fixed:
### Fix 1: QCalendar CSS not imported
- Added `css: ['@quasar/quasar-ui-qcalendar/dist/index.css']` to `nuxt.config.ts`
### Fix 2: Appwrite client initialized at module load time with wrong env var pattern
- `app/utils/appwrite.ts` used `import.meta.env.NUXT_PUBLIC_*` — not available in Nuxt browser context
- **Fix**: extracted `initAppwriteClient(endpoint, projectId)` function, called from plugin after `useRuntimeConfig()`
- `app/plugins/appwrite.client.ts` now calls `useRuntimeConfig()` and guards against empty config
### Fix 3: `.env.local` not loaded by Nuxt 4
- Nuxt 4 auto-loads `.env` but NOT `.env.local`
- **Fix**: copied `.env.local``.env` (both are gitignored via `.env.*`)
### Fix 4: `AddressbarColor` plugin missing
- Added `'AddressbarColor'` to Quasar plugins list in `nuxt.config.ts`
- Added `?.` guard on `q.addressbarColor?.set(...)` in `app/layouts/default.vue`
### Fix 5: Static image assets not resolved in Vite
- `src="~/assets/oysqn_logo.png"` in component props is not processed by Vite (unlike Webpack's `~` convention)
- **Fix**: copied `oysqn_logo.png`, `oysqn_logo_only.png`, `oys_lighthouse.jpg` to `public/`
- Updated 4 pages (`login.vue`, `index.vue`, `signup.vue`, `pwreset.vue`) to use `/oysqn_logo.png`
- CSS `url('~/assets/...')` in `<style>` blocks IS handled by Vite — left unchanged
### Fix 6: Quasar SCSS variables (`$primary` etc.) undefined
- Old Quasar/Webpack auto-imported variables; Vite does not
- **Fix**: added to `nuxt.config.ts`:
```ts
vite: {
css: {
preprocessorOptions: {
sass: {
additionalData: '@use "quasar/src/css/variables.sass" as *\n',
},
},
},
},
```
- Affects: `schedule/view.vue`, `LeftDrawer.vue`, `CalendarHeaderComponent.vue`, `BoatScheduleTableComponent.vue`
### Fix 7: `sass-embedded` missing
- Added `sass-embedded` as dev dependency
### Fix 8: PWA service worker caching stale HTML in dev
- Old SW was caching HTML with empty Appwrite config
- **Fix**: set `pwa.devOptions.enabled: false` in `nuxt.config.ts`
## Current State
- `yarn dev` — all pages load correctly
- Login, schedule, admin pages verified working
- Appwrite auth flow functional
- QCalendar renders with correct styling
## Files Changed This Session
- `bab-app-nuxt/nuxt.config.ts` — CSS import, Vite SASS config, AddressbarColor plugin, PWA dev disabled
- `bab-app-nuxt/app/utils/appwrite.ts` — lazy init via `initAppwriteClient()`
- `bab-app-nuxt/app/plugins/appwrite.client.ts` — uses `useRuntimeConfig()`, guards empty config
- `bab-app-nuxt/app/layouts/default.vue` — optional chaining on `route?.meta?.title` and `q.addressbarColor?.set()`
- `bab-app-nuxt/app/pages/login.vue`, `index.vue`, `signup.vue`, `pwreset.vue` — logo src path fixed
- `bab-app-nuxt/public/` — added `oysqn_logo.png`, `oysqn_logo_only.png`, `oys_lighthouse.jpg`
- `bab-app-nuxt/.env` — created from `.env.local` for Nuxt 4 dotenv loading
## What NEXT Session Should Do
### Phase 8 — Generate & Deploy
1. **Update `.gitea/workflows/build.yaml`** — current CI is for the old Quasar app:
- Remove `yarn dlx @quasar/cli ext invoke @quasar/qcalendar` step (Quasar CLI, not needed)
- Add `cd bab-app-nuxt` or set working-directory for install/build steps
- Change build command to `yarn generate`
- Change artifact path from `dist/` to `bab-app-nuxt/.output/public/`
- Env vars: CI uses `vars.ENV_FILE` → needs to write to `bab-app-nuxt/.env`
2. **Test `yarn generate`** in `bab-app-nuxt/` — verify `.output/public/` is produced
3. **Check Ansible deploy playbook** — update dist path if needed
## Open Questions
- [ ] **OPEN**: Appwrite SDK deprecated API calls — migrate to v14+ signatures? (TS6387 hints in all stores)
- [ ] **OPEN**: `task`/`taskTags`/`skillTags` collections — will they ever be created in `bab_prod`?
- [ ] **OPEN**: Should `.env.local` be removed now that `.env` is in place, to avoid confusion?

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