Initial project scaffolding
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
SUPABASE_KEY=your-anon-key
|
||||||
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.local
|
||||||
76
CLAUDE.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# 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 app (oysqn.app) — a mobile-first PWA for managing a Borrow a Boat program for a Yacht Club. Backend is Supabase.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
- **App**: OYS Borrow a Boat (oysqn.app) — rewrite of bab-app
|
||||||
|
- **Stack**: Nuxt 4 (SSR=false), Ionic Vue (@ionic/vue), PrimeVue 4, TypeScript, Supabase (BaaS)
|
||||||
|
- **Purpose**: Manage a Borrow a Boat program for a Yacht Club
|
||||||
|
- **Personas**: BAB Member, Certified Skipper, Program Administrator, Boatswain, Volunteer, Instructor
|
||||||
|
- **Docs**: docs/planning/ contains personas, user/role/permission model
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### App Shell
|
||||||
|
- `app/app.vue` — IonApp + IonMenu + IonRouterOutlet (NO NuxtLayout/NuxtPage)
|
||||||
|
- Each page is self-contained: IonPage > IonHeader > IonContent
|
||||||
|
- No Nuxt layout system — layouts handled at the page level via IonPage
|
||||||
|
- `app/plugins/ionic.client.ts` — installs IonicVue with mode: 'md'
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
- Nuxt file-based routing → Vue Router → IonRouterOutlet handles transitions
|
||||||
|
- `app/middleware/auth.ts` — global auth guard via useSupabaseUser()
|
||||||
|
- Auth pages use `definePageMeta({ layout: false })` (effectively a no-op with IonRouterOutlet, but documents intent)
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- Supabase Auth — magic link + OTP only (no password auth)
|
||||||
|
- `useSupabaseUser()` composable (from @nuxtjs/supabase)
|
||||||
|
- `app/stores/auth.ts` — isAdmin computed from user_metadata.role
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Supabase (replaces Appwrite)
|
||||||
|
- Types in `types/supabase.ts` — regenerate with: `npx supabase gen types typescript --project-id YOUR_ID > types/supabase.ts`
|
||||||
|
- `useSupabaseClient<Database>()` typed against `types/supabase.ts`
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
- Ionicons only (`ionicons/icons`) — no PrimeIcons
|
||||||
|
- Always import individual icon names from `ionicons/icons` (tree-shakeable)
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
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)
|
||||||
|
- docs/context/ — reusable domain knowledge
|
||||||
|
- docs/planning/ — original planning documents (personas, roles/permissions)
|
||||||
|
- types/supabase.ts — Supabase generated types (placeholder until Supabase project configured)
|
||||||
|
- app/ — Nuxt app source (Nuxt 4 structure)
|
||||||
|
- app/plugins/ionic.client.ts — Ionic Vue plugin
|
||||||
|
- app/stores/ — Pinia stores (Supabase-backed)
|
||||||
|
- app/composables/ — shared composables
|
||||||
|
|
||||||
|
## 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.
|
||||||
104
app/app.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<IonApp>
|
||||||
|
<IonMenu content-id="main-content" menu-id="main-menu">
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonTitle>OYS Borrow a Boat</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent>
|
||||||
|
<IonList lines="none">
|
||||||
|
<!-- All authenticated users -->
|
||||||
|
<IonItem button router-link="/" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="homeOutline" />
|
||||||
|
<IonLabel>Home</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem button router-link="/schedule" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="calendarOutline" />
|
||||||
|
<IonLabel>Schedule</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem button router-link="/boat" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="boatOutline" />
|
||||||
|
<IonLabel>Boats</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem button router-link="/reference" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="bookOutline" />
|
||||||
|
<IonLabel>Reference</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem button router-link="/profile" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="personOutline" />
|
||||||
|
<IonLabel>Profile</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<!-- Boatswain + Admin: schedule management -->
|
||||||
|
<template v-if="authStore.isBoatswain">
|
||||||
|
<IonItemDivider>
|
||||||
|
<IonLabel>Management</IonLabel>
|
||||||
|
</IonItemDivider>
|
||||||
|
<IonItem button router-link="/schedule/manage" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="calendarNumberOutline" />
|
||||||
|
<IonLabel>Manage Schedule</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Admin only -->
|
||||||
|
<template v-if="authStore.isAdmin">
|
||||||
|
<IonItem button router-link="/admin/user" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="peopleOutline" />
|
||||||
|
<IonLabel>Users</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem button router-link="/admin/boat" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="constructOutline" />
|
||||||
|
<IonLabel>Manage Boats</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sign out -->
|
||||||
|
<IonItemDivider />
|
||||||
|
<IonItem button @click="signOut">
|
||||||
|
<IonIcon slot="start" :icon="logOutOutline" />
|
||||||
|
<IonLabel>Sign Out</IonLabel>
|
||||||
|
<IonNote slot="end">{{ authStore.displayName }}</IonNote>
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
</IonContent>
|
||||||
|
</IonMenu>
|
||||||
|
|
||||||
|
<IonRouterOutlet id="main-content" />
|
||||||
|
</IonApp>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
IonApp, IonMenu, IonHeader, IonToolbar, IonTitle,
|
||||||
|
IonContent, IonList, IonItem, IonItemDivider, IonIcon, IonLabel, IonNote,
|
||||||
|
IonRouterOutlet, menuController,
|
||||||
|
} from '@ionic/vue'
|
||||||
|
import {
|
||||||
|
homeOutline, calendarOutline, boatOutline, personOutline,
|
||||||
|
bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline,
|
||||||
|
} from 'ionicons/icons'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
async function closeMenu() {
|
||||||
|
await menuController.close('main-menu')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await closeMenu()
|
||||||
|
await authStore.signOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch member profile on app load (populates role for menu visibility)
|
||||||
|
onMounted(() => {
|
||||||
|
if (authStore.user) authStore.fetchMember()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-fetch when user changes (e.g., after login)
|
||||||
|
watch(() => authStore.user, (u) => {
|
||||||
|
if (u) authStore.fetchMember()
|
||||||
|
else authStore.member = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
15
app/composables/useIonToast.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { toastController } from '@ionic/vue'
|
||||||
|
|
||||||
|
export function useIonToast() {
|
||||||
|
async function showToast(message: string, color: 'success' | 'danger' | 'warning' = 'success') {
|
||||||
|
const toast = await toastController.create({
|
||||||
|
message,
|
||||||
|
duration: 2000,
|
||||||
|
color,
|
||||||
|
position: 'bottom',
|
||||||
|
})
|
||||||
|
await toast.present()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { showToast }
|
||||||
|
}
|
||||||
10
app/middleware/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
|
const user = useSupabaseUser()
|
||||||
|
|
||||||
|
const publicRoutes = ['/login', '/signup', '/auth/callback']
|
||||||
|
if (publicRoutes.includes(to.path)) return
|
||||||
|
|
||||||
|
if (!user.value) {
|
||||||
|
return navigateTo('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
13
app/pages/auth/callback.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonContent class="ion-padding ion-text-center">
|
||||||
|
<p>Signing you in...</p>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { IonPage, IonContent } from '@ionic/vue'
|
||||||
|
|
||||||
|
definePageMeta({ layout: false })
|
||||||
|
</script>
|
||||||
22
app/pages/index.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonMenuButton />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Home</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<h2>Welcome to OYS Borrow a Boat</h2>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
||||||
|
IonButtons, IonMenuButton,
|
||||||
|
} from '@ionic/vue'
|
||||||
|
</script>
|
||||||
18
app/pages/login.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonTitle>Sign In</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<!-- TODO: Auth form -->
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/vue'
|
||||||
|
|
||||||
|
definePageMeta({ layout: false })
|
||||||
|
</script>
|
||||||
7
app/plugins/ionic.client.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IonicVue } from '@ionic/vue'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.vueApp.use(IonicVue, {
|
||||||
|
mode: 'md', // Use Material Design style on all platforms for consistency
|
||||||
|
})
|
||||||
|
})
|
||||||
115
app/stores/auth.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { Database, MemberRole } from '~/types/supabase'
|
||||||
|
|
||||||
|
export type Member = Database['public']['Tables']['members']['Row']
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
const user = useSupabaseUser()
|
||||||
|
const member = ref<Member | null>(null)
|
||||||
|
const userNames = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const role = computed<MemberRole | null>(() => member.value?.role ?? null)
|
||||||
|
|
||||||
|
function hasRequiredRole(requiredRoles: MemberRole[]): boolean {
|
||||||
|
if (!role.value) return false
|
||||||
|
return requiredRoles.includes(role.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = computed(() => hasRequiredRole(['admin']))
|
||||||
|
const isBoatswain = computed(() => hasRequiredRole(['admin', 'boatswain']))
|
||||||
|
|
||||||
|
const displayName = computed(() => {
|
||||||
|
if (!member.value) return user.value?.email ?? ''
|
||||||
|
const { first_name, last_name } = member.value
|
||||||
|
if (first_name || last_name) return `${first_name} ${last_name}`.trim()
|
||||||
|
return user.value?.email ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchMember() {
|
||||||
|
if (!user.value) { member.value = null; return }
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('members')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.value.id)
|
||||||
|
.single()
|
||||||
|
member.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns cached name or triggers async fetch; updates reactively when resolved.
|
||||||
|
function getUserNameById(id: string | null | undefined): string {
|
||||||
|
if (!id) return 'No User'
|
||||||
|
if (!userNames.value[id]) {
|
||||||
|
userNames.value[id] = 'Loading...'
|
||||||
|
supabase
|
||||||
|
.from('members')
|
||||||
|
.select('first_name, last_name')
|
||||||
|
.eq('user_id', id)
|
||||||
|
.single()
|
||||||
|
.then(({ data }: { data: Pick<Member, 'first_name' | 'last_name'> | null }) => {
|
||||||
|
if (data) {
|
||||||
|
userNames.value[id] = `${data.first_name} ${data.last_name}`.trim() || 'Unknown'
|
||||||
|
} else {
|
||||||
|
userNames.value[id] = 'Unknown'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return userNames.value[id]!
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await supabase.auth.signOut()
|
||||||
|
member.value = null
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMagicLink(email: string) {
|
||||||
|
const { error } = await supabase.auth.signInWithOtp({
|
||||||
|
email,
|
||||||
|
options: { emailRedirectTo: window.location.origin + '/auth/callback' },
|
||||||
|
})
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOtp(email: string) {
|
||||||
|
const { error } = await supabase.auth.signInWithOtp({ email })
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyOtp(email: string, token: string) {
|
||||||
|
const { error } = await supabase.auth.verifyOtp({ email, token, type: 'email' })
|
||||||
|
if (error) throw error
|
||||||
|
await fetchMember()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateName(firstName: string, lastName: string) {
|
||||||
|
if (!user.value) return
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('members')
|
||||||
|
.update({ first_name: firstName, last_name: lastName })
|
||||||
|
.eq('user_id', user.value.id)
|
||||||
|
if (error) throw error
|
||||||
|
if (member.value) {
|
||||||
|
member.value.first_name = firstName
|
||||||
|
member.value.last_name = lastName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
member,
|
||||||
|
role,
|
||||||
|
isAdmin,
|
||||||
|
isBoatswain,
|
||||||
|
displayName,
|
||||||
|
hasRequiredRole,
|
||||||
|
getUserNameById,
|
||||||
|
fetchMember,
|
||||||
|
sendMagicLink,
|
||||||
|
sendOtp,
|
||||||
|
verifyOtp,
|
||||||
|
updateName,
|
||||||
|
signOut,
|
||||||
|
}
|
||||||
|
})
|
||||||
23
app/stores/boat.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
// TODO: Replace with generated Supabase types after `npx supabase gen types typescript`
|
||||||
|
interface Boat {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
active: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBoatStore = defineStore('boat', () => {
|
||||||
|
const supabase = useSupabaseClient()
|
||||||
|
const boats = ref<Map<string, Boat>>(new Map())
|
||||||
|
|
||||||
|
async function fetchBoats() {
|
||||||
|
const { data, error } = await supabase.from('boats').select('*').order('name')
|
||||||
|
if (error) throw error
|
||||||
|
boats.value = new Map((data as Boat[]).map((b) => [b.id, b]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { boats, fetchBoats }
|
||||||
|
})
|
||||||
360
app/types/supabase.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
export type Json =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| { [key: string]: Json | undefined }
|
||||||
|
| Json[]
|
||||||
|
|
||||||
|
// Domain types
|
||||||
|
export type MemberRole = 'member' | 'skipper' | 'admin' | 'boatswain' | 'volunteer' | 'instructor'
|
||||||
|
export type ReservationStatus = 'pending' | 'tentative' | 'confirmed'
|
||||||
|
export interface Defect {
|
||||||
|
type: string
|
||||||
|
severity: string
|
||||||
|
description: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
// time_tuples shape: [[startHHMM, endHHMM], ...] e.g. [["08:00","12:00"]]
|
||||||
|
export type TimeTuple = [string, string]
|
||||||
|
|
||||||
|
export type Database = {
|
||||||
|
__InternalSupabase: {
|
||||||
|
PostgrestVersion: "14.4"
|
||||||
|
}
|
||||||
|
public: {
|
||||||
|
Tables: {
|
||||||
|
boats: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display_name: string | null
|
||||||
|
class: string | null
|
||||||
|
year: number | null
|
||||||
|
img_src: string | null
|
||||||
|
icon_src: string | null
|
||||||
|
booking_available: boolean
|
||||||
|
required_certs: string[]
|
||||||
|
max_passengers: number
|
||||||
|
defects: Defect[]
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
display_name?: string | null
|
||||||
|
class?: string | null
|
||||||
|
year?: number | null
|
||||||
|
img_src?: string | null
|
||||||
|
icon_src?: string | null
|
||||||
|
booking_available?: boolean
|
||||||
|
required_certs?: string[]
|
||||||
|
max_passengers?: number
|
||||||
|
defects?: Defect[]
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
display_name?: string | null
|
||||||
|
class?: string | null
|
||||||
|
year?: number | null
|
||||||
|
img_src?: string | null
|
||||||
|
icon_src?: string | null
|
||||||
|
booking_available?: boolean
|
||||||
|
required_certs?: string[]
|
||||||
|
max_passengers?: number
|
||||||
|
defects?: Defect[]
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
members: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
email: string
|
||||||
|
slack_id: string | null
|
||||||
|
certifications: string[]
|
||||||
|
role: MemberRole
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
user_id: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
email: string
|
||||||
|
slack_id?: string | null
|
||||||
|
certifications?: string[]
|
||||||
|
role?: MemberRole
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
user_id?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
email?: string
|
||||||
|
slack_id?: string | null
|
||||||
|
certifications?: string[]
|
||||||
|
role?: MemberRole
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interval_templates: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
time_tuples: TimeTuple[]
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
time_tuples?: TimeTuple[]
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
time_tuples?: TimeTuple[]
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
intervals: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
boat_id: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
user_id: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
boat_id: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
user_id?: string | null
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
boat_id?: string
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
user_id?: string | null
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reservations: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
boat_id: string
|
||||||
|
user_id: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
status: ReservationStatus
|
||||||
|
reason: string
|
||||||
|
comment: string
|
||||||
|
member_ids: string[]
|
||||||
|
guest_ids: string[]
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
boat_id: string
|
||||||
|
user_id: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
status?: ReservationStatus
|
||||||
|
reason?: string
|
||||||
|
comment?: string
|
||||||
|
member_ids?: string[]
|
||||||
|
guest_ids?: string[]
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
boat_id?: string
|
||||||
|
user_id?: string
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
status?: ReservationStatus
|
||||||
|
reason?: string
|
||||||
|
comment?: string
|
||||||
|
member_ids?: string[]
|
||||||
|
guest_ids?: string[]
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reference_docs: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
category: string
|
||||||
|
tags: string[]
|
||||||
|
subtitle: string | null
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
title: string
|
||||||
|
category: string
|
||||||
|
tags?: string[]
|
||||||
|
subtitle?: string | null
|
||||||
|
content: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
category?: string
|
||||||
|
tags?: string[]
|
||||||
|
subtitle?: string | null
|
||||||
|
content?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Views: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Functions: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Enums: {
|
||||||
|
member_role: MemberRole
|
||||||
|
reservation_status: ReservationStatus
|
||||||
|
}
|
||||||
|
CompositeTypes: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
|
||||||
|
|
||||||
|
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
|
||||||
|
|
||||||
|
export type Tables<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
||||||
|
DefaultSchema["Views"])
|
||||||
|
? (DefaultSchema["Tables"] &
|
||||||
|
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesInsert<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Tables"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||||
|
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesUpdate<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Tables"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||||
|
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type Enums<
|
||||||
|
DefaultSchemaEnumNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Enums"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaEnumNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||||
|
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||||
|
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type CompositeTypes<
|
||||||
|
PublicCompositeTypeNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["CompositeTypes"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||||
|
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
||||||
|
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export const Constants = {
|
||||||
|
public: {
|
||||||
|
Enums: {},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
69
docs/archive/handoffs/handoff-2026-03-25-project-scaffold.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Session Handoff: oysqn.app Initial Scaffold
|
||||||
|
**Date:** 2026-03-25
|
||||||
|
**Session Duration:** ~30 min
|
||||||
|
**Session Focus:** Create new project oysqn.app — Nuxt + Ionic Vue + PrimeVue + Supabase
|
||||||
|
**Context Usage at Handoff:** Low
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. Created `/home/ptoal/Dev/mobile-projects/oysqn.app/` project scaffold
|
||||||
|
2. Configured Nuxt 4 + @ionic/vue + PrimeVue 4 + @nuxtjs/supabase + @pinia/nuxt + @vite-pwa/nuxt
|
||||||
|
3. Created app shell: `app/app.vue` with IonApp + IonMenu + IonRouterOutlet
|
||||||
|
4. Created ionic plugin: `app/plugins/ionic.client.ts`
|
||||||
|
5. Created skeleton pages: `app/pages/index.vue`, `app/pages/login.vue`, `app/pages/auth/callback.vue`
|
||||||
|
6. Created skeleton stores: `app/stores/auth.ts`, `app/stores/boat.ts`
|
||||||
|
7. Created auth middleware: `app/middleware/auth.ts`
|
||||||
|
8. Created shared composable: `app/composables/useToast.ts`
|
||||||
|
9. Created placeholder Supabase types: `types/supabase.ts`
|
||||||
|
10. Copied docs: `docs/planning/`, `docs/context/`, `templates/`
|
||||||
|
11. Created project-specific `CLAUDE.md`
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
|
||||||
|
- Dependencies NOT yet installed — run `yarn install` to install
|
||||||
|
- Supabase project NOT yet configured — `.env` file needed with SUPABASE_URL + SUPABASE_KEY
|
||||||
|
- Supabase schema NOT yet created — `types/supabase.ts` is a placeholder with inferred types from bab-app domain
|
||||||
|
- No icons in `public/icons/` — copy from bab-app or generate new ones
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
|
||||||
|
- USE IonRouterOutlet instead of NuxtPage BECAUSE user decision — enables Ionic page transitions and lifecycle hooks
|
||||||
|
- USE Supabase INSTEAD OF Appwrite BECAUSE user decision — new project rewrite
|
||||||
|
- USE mode: 'md' in IonicVue plugin BECAUSE consistent cross-platform appearance in PWA
|
||||||
|
- USE @nuxtjs/supabase module BECAUSE handles SSR-safe client creation and useSupabaseUser() composable
|
||||||
|
- USE magic link + OTP auth only (no password) BECAUSE carried forward from bab-app — confirmed auth model
|
||||||
|
- NO @ionic/vue-router BECAUSE Nuxt manages the router; IonRouterOutlet from @ionic/vue works with standard Vue Router
|
||||||
|
|
||||||
|
## Key Numbers
|
||||||
|
|
||||||
|
- 0 TypeScript errors at scaffold time (no yarn install yet)
|
||||||
|
- 3 skeleton pages created
|
||||||
|
- 2 skeleton stores created
|
||||||
|
- 3 tables in placeholder types/supabase.ts: boats, members, reservations
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
1. **First**: Run `yarn install` in `/home/ptoal/Dev/mobile-projects/oysqn.app/`
|
||||||
|
2. **Then**: Create Supabase project at supabase.com, copy URL + anon key to `.env`
|
||||||
|
3. **Then**: Run `npx supabase gen types typescript --project-id YOUR_ID > types/supabase.ts` to get real types
|
||||||
|
4. **Then**: Design Supabase schema (tables: boats, members, reservations, interval_templates, intervals, certifications, reference_docs)
|
||||||
|
5. **Then**: Implement auth pages (login.vue — magic link/OTP)
|
||||||
|
6. **Then**: Run `yarn dev` and verify IonRouterOutlet renders correctly
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
|
||||||
|
- [ ] Supabase project ID — needed to generate real types and configure .env
|
||||||
|
- [ ] Should the scheduling refactor (new resource picker + booking flow) be designed before or after implementing auth + boat pages?
|
||||||
|
- [ ] Copy icons from bab-app or generate new ones for oysqn.app?
|
||||||
|
- [ ] Should the admin section use a separate IonMenu or just role-based visibility in the same menu?
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
|
||||||
|
- ASSUMED: magic link + OTP auth carried forward from bab-app — validate with Patrick
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
|
||||||
|
- `app/app.vue` — if modifying the app shell
|
||||||
|
- `app/plugins/ionic.client.ts` — if debugging Ionic setup
|
||||||
|
- `types/supabase.ts` — after regenerating from real Supabase project
|
||||||
|
- `CLAUDE.md` — for project context
|
||||||
16
docs/context/archive-rules.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Archive Rules
|
||||||
|
|
||||||
|
## Raw File Archival
|
||||||
|
|
||||||
|
After creating a Source Document Summary for any raw file:
|
||||||
|
1. Move the raw file to `docs/archive/`
|
||||||
|
2. Record the move in the source summary's header: `Archived From: [original path]`
|
||||||
|
3. Do not read from `docs/archive/` unless the user explicitly says "go back to the original [filename]"
|
||||||
|
|
||||||
|
## Summary Lifecycle Rules
|
||||||
|
|
||||||
|
1. **Session handoffs expire**: After a new handoff is written, the previous handoff moves to `docs/archive/handoffs/`. Only the latest handoff stays in `docs/summaries/`.
|
||||||
|
2. **Decision records persist**: Decision records (DR-*) stay in `docs/summaries/` permanently — they are institutional memory.
|
||||||
|
3. **Source summaries persist**: Source document summaries stay until the project ends — they replace raw documents.
|
||||||
|
4. **Analysis summaries**: Keep only the latest version. If re-run, the new one replaces the old (archive the old one).
|
||||||
|
5. **Maximum active summaries**: If `docs/summaries/` exceeds 15 files, consolidate older source summaries into a single `project-digest.md` and archive the originals.
|
||||||
23
docs/context/processing-protocol.md
Normal 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.
|
||||||
18
docs/context/subagent-rules.md
Normal 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.
|
||||||
8
docs/planning/personas.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Personas
|
||||||
|
|
||||||
|
- BAB Member
|
||||||
|
- Certified Skipper
|
||||||
|
- Program Administrator
|
||||||
|
- Boatswain
|
||||||
|
- Volunteer
|
||||||
|
- Instructor
|
||||||
125
docs/summaries/handoff-2026-03-25-initial-setup.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Session Handoff: oysqn.app Initial Setup
|
||||||
|
**Date:** 2026-03-25
|
||||||
|
**Session Duration:** ~1 hour
|
||||||
|
**Session Focus:** Create new project oysqn.app — scaffold, schema extraction, icon copy, role-based menu
|
||||||
|
**Context Usage at Handoff:** Medium
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. Created project directory `/home/ptoal/Dev/mobile-projects/oysqn.app/` and scaffolded full Nuxt 4 + Ionic Vue + PrimeVue + Supabase stack → all files listed in Files table below
|
||||||
|
2. Installed dependencies via `yarn install --ignore-engines` (Node 20 compat issue with `rollup-plugin-visualizer@7.0.1` requiring Node >=22) → `yarn.lock` created, `.yarnrc` set with `--ignore-engines true`
|
||||||
|
3. Extracted schema from bab-app stores and translated Appwrite model → Supabase SQL → `supabase/schema.sql`
|
||||||
|
4. Populated full TypeScript types → `app/types/supabase.ts`
|
||||||
|
5. Rebuilt auth store with Supabase equivalents: magic link, OTP, `hasRequiredRole()`, `fetchMember()`, `getUserNameById()` → `app/stores/auth.ts`
|
||||||
|
6. Updated `app/app.vue` with role-based IonMenu (3 visibility tiers)
|
||||||
|
7. Copied all icons and brand assets from bab-app → `public/icons/`, `public/`
|
||||||
|
8. Renamed composable `useToast` → `useIonToast` to avoid conflict with PrimeVue's auto-imported `useToast` → `app/composables/useIonToast.ts`
|
||||||
|
9. `yarn typecheck` passes with 0 errors
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
|
||||||
|
- Supabase project NOT created — `.env` is empty placeholder; schema SQL exists but has not been run
|
||||||
|
- `app/stores/boat.ts` uses `as any` cast for supabase client — placeholder until real types wired via `supabase gen types`
|
||||||
|
- Auth store uses `as any` cast for same reason — will resolve once `supabase gen types` output replaces `app/types/supabase.ts`
|
||||||
|
- No pages beyond skeleton `index.vue`, `login.vue`, `auth/callback.vue` — all page content is TODO
|
||||||
|
- No `app/stores/interval.ts`, `app/stores/intervalTemplate.ts`, `app/stores/reservation.ts`, `app/stores/reference.ts` yet — to be built
|
||||||
|
- Scheduling refactor deferred — new resource picker + booking flow design not started
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
|
||||||
|
- USE `IonRouterOutlet` in `app.vue` instead of `<NuxtPage>` BECAUSE user decision — enables Ionic page transitions and lifecycle hooks — STATUS: confirmed
|
||||||
|
- USE Supabase INSTEAD OF Appwrite BECAUSE user decision — new project rewrite — STATUS: confirmed
|
||||||
|
- USE `mode: 'md'` in IonicVue plugin BECAUSE consistent cross-platform appearance for PWA — STATUS: confirmed
|
||||||
|
- USE `@nuxtjs/supabase` module BECAUSE handles SSR-safe client creation and `useSupabaseUser()` composable automatically — STATUS: confirmed
|
||||||
|
- NO `@ionic/vue-router` BECAUSE Nuxt manages the router; `IonRouterOutlet` from `@ionic/vue` works with standard Vue Router — STATUS: confirmed
|
||||||
|
- USE magic link + OTP only (no password auth) BECAUSE carried forward from bab-app confirmed auth model — STATUS: confirmed
|
||||||
|
- USE `role` column on `members` table INSTEAD OF Appwrite Teams BECAUSE Supabase native pattern; simpler RLS — STATUS: confirmed
|
||||||
|
- USE `handle_new_user()` trigger BECAUSE auto-creates `members` row on first sign-in without extra client code — STATUS: provisional (not yet tested)
|
||||||
|
- USE `as any` cast on `useSupabaseClient()` in stores FOR NOW BECAUSE `@nuxtjs/supabase` doesn't propagate generic Database type through PostgREST column-select type inference; will resolve after `supabase gen types` — STATUS: provisional
|
||||||
|
- SAME IonMenu for all roles with conditional sections BECAUSE user decision — STATUS: confirmed
|
||||||
|
- USE `.yarnrc` with `--ignore-engines true` BECAUSE Node 20 installed, `rollup-plugin-visualizer` requires Node >=22 — STATUS: confirmed
|
||||||
|
|
||||||
|
## Key Numbers Generated or Discovered This Session
|
||||||
|
|
||||||
|
- 6 tables in schema: `boats`, `members`, `interval_templates`, `intervals`, `reservations`, `reference_docs`
|
||||||
|
- 3 menu visibility tiers: all-authenticated / isBoatswain (admin+boatswain) / isAdmin only
|
||||||
|
- 0 TypeScript errors (`yarn typecheck` clean)
|
||||||
|
- 31 icon/asset files copied from bab-app to `public/icons/`
|
||||||
|
- 1 trigger in schema: `handle_new_user()` on `auth.users` insert
|
||||||
|
|
||||||
|
## Conditional Logic Established
|
||||||
|
|
||||||
|
- IF `member.role` is in `['admin', 'boatswain']` THEN `isBoatswain` is true → shows "Manage Schedule" menu item
|
||||||
|
- IF `member.role` is `'admin'` THEN `isAdmin` is true → shows "Users" + "Manage Boats" menu items
|
||||||
|
- IF `authStore.user` changes to non-null THEN `fetchMember()` is called → populates `member` ref → menu items become visible
|
||||||
|
- IF `cert` in `members.certifications` matches entry in `boats.required_certs` THEN user may book that boat — ASSUMED (not yet enforced in code, logic carried from bab-app)
|
||||||
|
- IF `interval.boat_id` matches `boats.id` THEN interval appears in that boat's resource column in FullCalendar (scheduling refactor — deferred)
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `package.json` | Created | Nuxt 4 + @ionic/vue + primevue + @nuxtjs/supabase + @pinia/nuxt + @vite-pwa/nuxt |
|
||||||
|
| `nuxt.config.ts` | Created | Nuxt config: SSR=false, PrimeVue OYS theme (#027be3), PWA manifest, Ionic CSS |
|
||||||
|
| `tsconfig.json` | Created | Extends `.nuxt/tsconfig.json` |
|
||||||
|
| `.gitignore` | Created | Standard Nuxt gitignore |
|
||||||
|
| `.env.example` | Created | `SUPABASE_URL` + `SUPABASE_KEY` placeholders |
|
||||||
|
| `.yarnrc` | Created | `--ignore-engines true` for Node 20 compat |
|
||||||
|
| `yarn.lock` | Created | Resolved dependency lockfile |
|
||||||
|
| `supabase/schema.sql` | Created | Full Supabase schema: 6 tables, RLS policies, `handle_new_user` trigger |
|
||||||
|
| `app/app.vue` | Created | IonApp + IonMenu (role-based) + IonRouterOutlet; fetchMember on mount/user-watch |
|
||||||
|
| `app/plugins/ionic.client.ts` | Created | IonicVue plugin, mode: 'md' |
|
||||||
|
| `app/middleware/auth.ts` | Created | Global Supabase auth guard via `useSupabaseUser()` |
|
||||||
|
| `app/stores/auth.ts` | Created | Full auth store: role, isAdmin, isBoatswain, fetchMember, sendMagicLink, sendOtp, verifyOtp, getUserNameById, signOut, updateName |
|
||||||
|
| `app/stores/boat.ts` | Created | Skeleton boat store with placeholder types |
|
||||||
|
| `app/types/supabase.ts` | Created | Full Database type: 6 tables + MemberRole, ReservationStatus, Defect, TimeTuple |
|
||||||
|
| `app/composables/useIonToast.ts` | Created | Ionic toastController wrapper (renamed from useToast to avoid PrimeVue conflict) |
|
||||||
|
| `app/pages/index.vue` | Created | Skeleton home page (IonPage shell) |
|
||||||
|
| `app/pages/login.vue` | Created | Skeleton login page |
|
||||||
|
| `app/pages/auth/callback.vue` | Created | Supabase auth callback page |
|
||||||
|
| `CLAUDE.md` | Created | Project-specific instructions for new stack |
|
||||||
|
| `docs/planning/personas.md` | Created | Copied from bab-app |
|
||||||
|
| `docs/context/archive-rules.md` | Created | Copied from bab-app |
|
||||||
|
| `docs/context/processing-protocol.md` | Created | Copied from bab-app |
|
||||||
|
| `docs/context/subagent-rules.md` | Created | Copied from bab-app |
|
||||||
|
| `templates/claude-templates.md` | Created | Copied from bab-app |
|
||||||
|
| `public/icons/` | Created | 31 icon/asset files copied from bab-app |
|
||||||
|
| `public/favicon.ico` | Created | Copied from bab-app |
|
||||||
|
| `public/oysqn_logo.png` | Created | Copied from bab-app |
|
||||||
|
| `public/oysqn_logo_only.png` | Created | Copied from bab-app |
|
||||||
|
| `public/oys_lighthouse.jpg` | Created | Copied from bab-app |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
1. **First**: Create Supabase project at supabase.com; copy URL + anon key to `.env`
|
||||||
|
2. **Then**: Run schema: paste `supabase/schema.sql` into Supabase SQL editor and execute
|
||||||
|
3. **Then**: Run `npx supabase gen types typescript --project-id YOUR_ID > app/types/supabase.ts` to replace placeholder types and remove `as any` casts
|
||||||
|
4. **Then**: Implement `app/pages/login.vue` — OTP flow: email input → sendOtp() → token input → verifyOtp() → redirect to `/`
|
||||||
|
5. **Then**: Implement `app/pages/auth/callback.vue` — handle magic link redirect (Supabase sets session from URL hash)
|
||||||
|
6. **Then**: Run `yarn dev` and verify IonRouterOutlet renders, menu appears, auth redirect fires
|
||||||
|
7. **Then**: Build remaining stores: `reservation.ts`, `interval.ts`, `intervalTemplate.ts`, `reference.ts`
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
|
||||||
|
- [ ] Supabase project ID — needed before types, schema, and `.env` can be configured
|
||||||
|
- [ ] Scheduling refactor design — should new resource picker and booking flow be designed before or after auth + boat pages are implemented?
|
||||||
|
- [ ] Should `members.role` ever be set to something other than `'member'` automatically, or is role assignment always manual (admin sets it)?
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
|
||||||
|
- ASSUMED: `handle_new_user()` trigger correctly fires on Supabase magic link / OTP first sign-in — validate by creating a test user and checking `members` table
|
||||||
|
- ASSUMED: `IonRouterOutlet` in `app.vue` works without `<NuxtLayout>` wrapper in Nuxt 4 — validate at `yarn dev`
|
||||||
|
- ASSUMED: `useSupabaseUser()` watcher in `app.vue` fires after OTP verification completes — validate in auth flow test
|
||||||
|
- ASSUMED: cert matching logic (`members.certifications` vs `boats.required_certs`) carried forward as string array comparison — validate against domain rules in `docs/planning/`
|
||||||
|
|
||||||
|
## What NOT to Re-Read
|
||||||
|
|
||||||
|
- `docs/archive/handoffs/handoff-2026-03-25-project-scaffold.md` — superseded by this handoff
|
||||||
|
- bab-app stores — schema extraction complete; source of truth is now `supabase/schema.sql` + `app/types/supabase.ts`
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
|
||||||
|
- `supabase/schema.sql` — if modifying schema before running it
|
||||||
|
- `app/stores/auth.ts` — when implementing login page
|
||||||
|
- `app/pages/login.vue` — primary implementation target
|
||||||
|
- `app/types/supabase.ts` — after regenerating from real Supabase project
|
||||||
108
nuxt.config.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import Aura from '@primevue/themes/aura'
|
||||||
|
import { definePreset } from '@primevue/themes'
|
||||||
|
|
||||||
|
const OysTheme = definePreset(Aura, {
|
||||||
|
semantic: {
|
||||||
|
primary: {
|
||||||
|
50: '#e6f2fd',
|
||||||
|
100: '#c4e0fa',
|
||||||
|
200: '#8ac4f5',
|
||||||
|
300: '#51a7f0',
|
||||||
|
400: '#298be6',
|
||||||
|
500: '#027be3',
|
||||||
|
600: '#0262b5',
|
||||||
|
700: '#014988',
|
||||||
|
800: '#01315a',
|
||||||
|
900: '#00182d',
|
||||||
|
950: '#000c17',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
ssr: false,
|
||||||
|
|
||||||
|
modules: [
|
||||||
|
'@primevue/nuxt-module',
|
||||||
|
'@pinia/nuxt',
|
||||||
|
'@vite-pwa/nuxt',
|
||||||
|
'@nuxtjs/supabase',
|
||||||
|
],
|
||||||
|
|
||||||
|
primevue: {
|
||||||
|
options: {
|
||||||
|
theme: {
|
||||||
|
preset: OysTheme,
|
||||||
|
options: {
|
||||||
|
darkModeSelector: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ripple: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
supabase: {
|
||||||
|
redirectOptions: {
|
||||||
|
login: '/login',
|
||||||
|
callback: '/auth/callback',
|
||||||
|
exclude: ['/login', '/signup', '/auth/callback'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
meta: [{ name: 'theme-color', content: '#027be3' }],
|
||||||
|
link: [
|
||||||
|
{ rel: 'stylesheet', href: 'https://unpkg.com/@ionic/core/css/ionic.bundle.css' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
css: [
|
||||||
|
'@ionic/vue/css/core.css',
|
||||||
|
'@ionic/vue/css/normalize.css',
|
||||||
|
'@ionic/vue/css/structure.css',
|
||||||
|
'@ionic/vue/css/typography.css',
|
||||||
|
],
|
||||||
|
|
||||||
|
pwa: {
|
||||||
|
manifest: {
|
||||||
|
name: 'OYS Borrow a Boat',
|
||||||
|
short_name: 'OYS BAB',
|
||||||
|
description: 'Manage a Borrow a Boat program for a Yacht Club',
|
||||||
|
theme_color: '#027be3',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'natural',
|
||||||
|
icons: [
|
||||||
|
{ src: 'icons/icon-128x128.png', sizes: '128x128', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-256x256.png', sizes: '256x256', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-384x384.png', sizes: '384x384', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: '/',
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['@ionic/vue'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
supabaseUrl: '',
|
||||||
|
supabaseKey: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "oysqn.app",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"typecheck": "nuxt typecheck",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ionic/vue": "^8.5.0",
|
||||||
|
"@nuxtjs/supabase": "^1.5.0",
|
||||||
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"@primevue/nuxt-module": "^4.5.4",
|
||||||
|
"@primevue/themes": "^4.5.4",
|
||||||
|
"ionicons": "^7.4.0",
|
||||||
|
"nuxt": "^4.4.2",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"primevue": "^4.5.4",
|
||||||
|
"vue": "^3.5.30",
|
||||||
|
"vue-router": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vite-pwa/nuxt": "^1.1.1",
|
||||||
|
"sass-embedded": "^1.98.0",
|
||||||
|
"vitest": "^4.1.0",
|
||||||
|
"vue-tsc": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/apple-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/apple-launch-1080x2340.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/apple-launch-1125x2436.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/apple-launch-1170x2532.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/apple-launch-1179x2556.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/apple-launch-1242x2208.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/apple-launch-1242x2688.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/apple-launch-1284x2778.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/apple-launch-1290x2796.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/apple-launch-1536x2048.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/apple-launch-1620x2160.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/apple-launch-1668x2224.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/apple-launch-1668x2388.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
public/apple-launch-2048x2732.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/apple-launch-750x1334.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/apple-launch-828x1792.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/favicon-128x128.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/favicon-64x64.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
public/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
public/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
public/icons/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/icons/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/icons/apple-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/icons/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/icons/apple-launch-1080x2340.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/icons/apple-launch-1125x2436.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/icons/apple-launch-1170x2532.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/icons/apple-launch-1179x2556.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/icons/apple-launch-1242x2208.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/icons/apple-launch-1242x2688.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/icons/apple-launch-1284x2778.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/icons/apple-launch-1290x2796.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/icons/apple-launch-1536x2048.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/icons/apple-launch-1620x2160.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/icons/apple-launch-1668x2224.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/icons/apple-launch-1668x2388.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
public/icons/apple-launch-2048x2732.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/icons/apple-launch-750x1334.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/icons/apple-launch-828x1792.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/icons/favicon-128x128.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/icons/favicon-64x64.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/icons/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
public/icons/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
1
public/icons/safari-pinned-tab.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><path fill="#14539a" fill-opacity=".016" fill-rule="evenodd" d="M0 128.004v128.004l128.25-.254 128.25-.254.254-127.75L257.008 0H0v128.004m.485.496c0 70.4.119 99.053.265 63.672.146-35.38.146-92.98 0-128C.604 29.153.485 58.1.485 128.5"/><path fill="#14539a" fill-opacity=".127" fill-rule="evenodd" d="M253.833 1.876c-.183.481-7.807 9.144-16.942 19.25a49357.61 49357.61 0 0 0-72.405 80.374c-3.952 4.4-18.127 20.15-31.5 35l-31.521 35c-3.962 4.4-22.584 25.1-41.382 46l-34.18 38 58.044.258 58.044.258 3.754-4.2c2.065-2.309 10.461-11.65 18.657-20.758 8.196-9.107 18.321-20.376 22.5-25.042A1734.212 1734.212 0 0 1 201 190.486c3.575-3.876 15.725-17.34 27-29.921 11.275-12.58 22.175-24.717 24.223-26.969l3.724-4.096.026-64.25c.024-56.862-.354-68.064-2.14-63.374M250.5 196.774c-2.2 2.59-14.308 16.187-26.907 30.217-12.599 14.03-23.581 26.296-24.405 27.259-1.422 1.661-.014 1.75 27.657 1.75H256v-32c0-17.6-.338-31.985-.75-31.967-.412.018-2.55 2.151-4.75 4.741"/><path fill="#14539a" fill-opacity=".543" fill-rule="evenodd" d="M250.395 7.287c-4.909 5.303-12.154 13.335-48.901 54.213-10.877 12.1-25.054 27.85-31.505 35-24.314 26.948-67.66 75.077-71.989 79.932-6.492 7.28-49.006 54.545-58.078 64.568-4.232 4.675-8.91 9.962-10.397 11.75L26.822 256H141.005l3.748-4.208c9.492-10.66 68.621-76.281 93.072-103.292l18.104-20 .035-63.25c.02-34.788-.124-63.25-.319-63.25s-2.558 2.379-5.25 5.287M236.959 213.25c-21.511 23.87-30.467 33.874-34.913 39l-3.253 3.75H256v-31.5c0-17.325-.178-31.5-.396-31.5-.218 0-8.608 9.113-18.645 20.25"/><path fill="#14539a" fill-opacity=".331" fill-rule="evenodd" d="M219.112 43.062A127761.403 127761.403 0 0 1 175.51 91.5 571252.7 571252.7 0 0 0 53.1 227.381c-11.33 12.584-21.645 24.171-22.922 25.75L27.855 256l56.323-.082 56.322-.082 4.5-5.212c6.704-7.766 18.769-21.319 25-28.083 3.025-3.283 5.95-6.482 6.5-7.107.55-.625 4.408-4.916 8.573-9.535s12.222-13.574 17.905-19.899c27.589-30.707 45.281-50.355 46.47-51.611.579-.61 2.293-2.523 3.81-4.249l2.757-3.14-.257-62.188-.258-62.189-36.388 40.439m24.272 164.043A9076.964 9076.964 0 0 0 220.96 232a5556.975 5556.975 0 0 1-15.747 17.5l-5.44 6 27.764.266c15.27.146 27.928.102 28.128-.098.201-.2.245-14.223.1-31.161l-.265-30.796-12.116 13.394"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/oys_lighthouse.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
public/oysqn_logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/oysqn_logo_only.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
1
public/safari-pinned-tab.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><path fill="#14539a" fill-opacity=".016" fill-rule="evenodd" d="M0 128.004v128.004l128.25-.254 128.25-.254.254-127.75L257.008 0H0v128.004m.485.496c0 70.4.119 99.053.265 63.672.146-35.38.146-92.98 0-128C.604 29.153.485 58.1.485 128.5"/><path fill="#14539a" fill-opacity=".127" fill-rule="evenodd" d="M253.833 1.876c-.183.481-7.807 9.144-16.942 19.25a49357.61 49357.61 0 0 0-72.405 80.374c-3.952 4.4-18.127 20.15-31.5 35l-31.521 35c-3.962 4.4-22.584 25.1-41.382 46l-34.18 38 58.044.258 58.044.258 3.754-4.2c2.065-2.309 10.461-11.65 18.657-20.758 8.196-9.107 18.321-20.376 22.5-25.042A1734.212 1734.212 0 0 1 201 190.486c3.575-3.876 15.725-17.34 27-29.921 11.275-12.58 22.175-24.717 24.223-26.969l3.724-4.096.026-64.25c.024-56.862-.354-68.064-2.14-63.374M250.5 196.774c-2.2 2.59-14.308 16.187-26.907 30.217-12.599 14.03-23.581 26.296-24.405 27.259-1.422 1.661-.014 1.75 27.657 1.75H256v-32c0-17.6-.338-31.985-.75-31.967-.412.018-2.55 2.151-4.75 4.741"/><path fill="#14539a" fill-opacity=".543" fill-rule="evenodd" d="M250.395 7.287c-4.909 5.303-12.154 13.335-48.901 54.213-10.877 12.1-25.054 27.85-31.505 35-24.314 26.948-67.66 75.077-71.989 79.932-6.492 7.28-49.006 54.545-58.078 64.568-4.232 4.675-8.91 9.962-10.397 11.75L26.822 256H141.005l3.748-4.208c9.492-10.66 68.621-76.281 93.072-103.292l18.104-20 .035-63.25c.02-34.788-.124-63.25-.319-63.25s-2.558 2.379-5.25 5.287M236.959 213.25c-21.511 23.87-30.467 33.874-34.913 39l-3.253 3.75H256v-31.5c0-17.325-.178-31.5-.396-31.5-.218 0-8.608 9.113-18.645 20.25"/><path fill="#14539a" fill-opacity=".331" fill-rule="evenodd" d="M219.112 43.062A127761.403 127761.403 0 0 1 175.51 91.5 571252.7 571252.7 0 0 0 53.1 227.381c-11.33 12.584-21.645 24.171-22.922 25.75L27.855 256l56.323-.082 56.322-.082 4.5-5.212c6.704-7.766 18.769-21.319 25-28.083 3.025-3.283 5.95-6.482 6.5-7.107.55-.625 4.408-4.916 8.573-9.535s12.222-13.574 17.905-19.899c27.589-30.707 45.281-50.355 46.47-51.611.579-.61 2.293-2.523 3.81-4.249l2.757-3.14-.257-62.188-.258-62.189-36.388 40.439m24.272 164.043A9076.964 9076.964 0 0 0 220.96 232a5556.975 5556.975 0 0 1-15.747 17.5l-5.44 6 27.764.266c15.27.146 27.928.102 28.128-.098.201-.2.245-14.223.1-31.161l-.265-30.796-12.116 13.394"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
208
supabase/schema.sql
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
-- OYS Borrow a Boat — Supabase Schema
|
||||||
|
-- Run this in the Supabase SQL editor to initialize the database.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- BOATS
|
||||||
|
-- ============================================================
|
||||||
|
create table public.boats (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null,
|
||||||
|
display_name text,
|
||||||
|
class text,
|
||||||
|
year integer,
|
||||||
|
img_src text,
|
||||||
|
icon_src text,
|
||||||
|
booking_available boolean not null default true,
|
||||||
|
required_certs text[] not null default '{}',
|
||||||
|
max_passengers integer not null default 6,
|
||||||
|
defects jsonb not null default '[]',
|
||||||
|
-- defects shape: [{ type: string, severity: string, description: string, detail?: string }]
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.boats enable row level security;
|
||||||
|
-- Admins/boatswains can manage boats; all authenticated users can read
|
||||||
|
create policy "Authenticated users can read boats" on public.boats
|
||||||
|
for select using (auth.role() = 'authenticated');
|
||||||
|
create policy "Admins can manage boats" on public.boats
|
||||||
|
for all using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- MEMBERS (linked to Supabase auth.users)
|
||||||
|
-- ============================================================
|
||||||
|
create table public.members (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
first_name text not null default '',
|
||||||
|
last_name text not null default '',
|
||||||
|
email text not null,
|
||||||
|
slack_id text,
|
||||||
|
certifications text[] not null default '{}',
|
||||||
|
-- cert codes match boats.required_certs values (e.g. 'j27', 'capri25')
|
||||||
|
role text not null default 'member'
|
||||||
|
check (role in ('member', 'skipper', 'admin', 'boatswain', 'volunteer', 'instructor')),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.members enable row level security;
|
||||||
|
-- Users can read their own record; admins can read all
|
||||||
|
create policy "Users can read own member record" on public.members
|
||||||
|
for select using (user_id = auth.uid());
|
||||||
|
create policy "Admins can read all members" on public.members
|
||||||
|
for select using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.members m2
|
||||||
|
where m2.user_id = auth.uid() and m2.role in ('admin', 'boatswain', 'instructor')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
create policy "Users can update own member record" on public.members
|
||||||
|
for update using (user_id = auth.uid());
|
||||||
|
create policy "Admins can manage all members" on public.members
|
||||||
|
for all using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.members m2
|
||||||
|
where m2.user_id = auth.uid() and m2.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- INTERVAL TEMPLATES
|
||||||
|
-- ============================================================
|
||||||
|
create table public.interval_templates (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null,
|
||||||
|
time_tuples jsonb not null default '[]',
|
||||||
|
-- shape: [[startHHMM, endHHMM], ...] e.g. [["08:00","12:00"],["13:00","17:00"]]
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.interval_templates enable row level security;
|
||||||
|
create policy "Authenticated users can read interval templates" on public.interval_templates
|
||||||
|
for select using (auth.role() = 'authenticated');
|
||||||
|
create policy "Admins can manage interval templates" on public.interval_templates
|
||||||
|
for all using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- INTERVALS (available time slots per boat)
|
||||||
|
-- ============================================================
|
||||||
|
create table public.intervals (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
boat_id uuid not null references public.boats(id) on delete cascade,
|
||||||
|
start_time timestamptz not null,
|
||||||
|
end_time timestamptz not null,
|
||||||
|
user_id uuid references auth.users(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index intervals_boat_id_idx on public.intervals(boat_id);
|
||||||
|
create index intervals_time_range_idx on public.intervals(start_time, end_time);
|
||||||
|
|
||||||
|
alter table public.intervals enable row level security;
|
||||||
|
create policy "Authenticated users can read intervals" on public.intervals
|
||||||
|
for select using (auth.role() = 'authenticated');
|
||||||
|
create policy "Admins can manage intervals" on public.intervals
|
||||||
|
for all using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- RESERVATIONS
|
||||||
|
-- ============================================================
|
||||||
|
create table public.reservations (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
boat_id uuid not null references public.boats(id) on delete cascade,
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
start_time timestamptz not null,
|
||||||
|
end_time timestamptz not null,
|
||||||
|
status text not null default 'pending'
|
||||||
|
check (status in ('pending', 'tentative', 'confirmed')),
|
||||||
|
reason text not null default '',
|
||||||
|
comment text not null default '',
|
||||||
|
member_ids text[] not null default '{}',
|
||||||
|
guest_ids text[] not null default '{}',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index reservations_boat_id_idx on public.reservations(boat_id);
|
||||||
|
create index reservations_user_id_idx on public.reservations(user_id);
|
||||||
|
create index reservations_time_range_idx on public.reservations(start_time, end_time);
|
||||||
|
|
||||||
|
alter table public.reservations enable row level security;
|
||||||
|
create policy "Users can read own reservations" on public.reservations
|
||||||
|
for select using (user_id = auth.uid());
|
||||||
|
create policy "Admins can read all reservations" on public.reservations
|
||||||
|
for select using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
create policy "Authenticated users can read non-private reservation slots" on public.reservations
|
||||||
|
-- Allows seeing start/end/boat_id for overlap checking without exposing user details
|
||||||
|
for select using (auth.role() = 'authenticated');
|
||||||
|
create policy "Users can create own reservations" on public.reservations
|
||||||
|
for insert with check (user_id = auth.uid());
|
||||||
|
create policy "Users can update own reservations" on public.reservations
|
||||||
|
for update using (user_id = auth.uid());
|
||||||
|
create policy "Admins can manage all reservations" on public.reservations
|
||||||
|
for all using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- REFERENCE DOCUMENTS
|
||||||
|
-- ============================================================
|
||||||
|
create table public.reference_docs (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
title text not null,
|
||||||
|
category text not null,
|
||||||
|
tags text[] not null default '{}',
|
||||||
|
subtitle text,
|
||||||
|
content text not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.reference_docs enable row level security;
|
||||||
|
create policy "Authenticated users can read reference docs" on public.reference_docs
|
||||||
|
for select using (auth.role() = 'authenticated');
|
||||||
|
create policy "Admins can manage reference docs" on public.reference_docs
|
||||||
|
for all using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- SEED: create member record on first sign-in (via trigger)
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger language plpgsql security definer as $$
|
||||||
|
begin
|
||||||
|
insert into public.members (user_id, email)
|
||||||
|
values (new.id, new.email)
|
||||||
|
on conflict (user_id) do nothing;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row execute procedure public.handle_new_user();
|
||||||
158
templates/claude-templates.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Claude Templates — On-Demand Reference
|
||||||
|
|
||||||
|
> **Do NOT read this file at session start.** Read it only when you need to write a summary, handoff, decision record, or subagent output. This file is referenced from CLAUDE.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template 1: Source Document Summary
|
||||||
|
|
||||||
|
**Use when:** Processing any input document (client brief, research report, requirements doc, existing proposal)
|
||||||
|
|
||||||
|
**Write to:** `./docs/summaries/source-[filename].md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Source Summary: [Original Document Name]
|
||||||
|
**Processed:** [YYYY-MM-DD]
|
||||||
|
**Source Path:** [exact file path]
|
||||||
|
**Archived From:** [original path, if moved to docs/archive/]
|
||||||
|
**Document Type:** [brief / requirements / research / proposal / transcript / other]
|
||||||
|
**Confidence:** [high = I understood everything / medium = some interpretation needed / low = significant gaps]
|
||||||
|
|
||||||
|
## Exact Numbers & Metrics
|
||||||
|
- [metric]: [exact value] (page/section reference if available)
|
||||||
|
|
||||||
|
## Key Facts (Confirmed)
|
||||||
|
- [fact] — stated in [section/page]
|
||||||
|
|
||||||
|
## Requirements & Constraints
|
||||||
|
- REQUIREMENT: [what is needed]
|
||||||
|
- CONDITION: [when/if this applies]
|
||||||
|
- CONSTRAINT: [limitation or exception]
|
||||||
|
- PRIORITY: [must-have / should-have / nice-to-have / stated by whom]
|
||||||
|
|
||||||
|
## Decisions Referenced
|
||||||
|
- DECISION: [what was decided]
|
||||||
|
- RATIONALE: [why, as stated in document]
|
||||||
|
- ALTERNATIVES MENTIONED: [what else was considered]
|
||||||
|
- DECIDED BY: [who, if stated]
|
||||||
|
|
||||||
|
## Open Questions & Ambiguities
|
||||||
|
- UNCLEAR: [what is ambiguous] — needs clarification from [whom]
|
||||||
|
- ASSUMED: [interpretation made] — verify with [whom]
|
||||||
|
- MISSING: [information referenced but not provided]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template 4: Session Handoff
|
||||||
|
|
||||||
|
**Use when:** A session is ending (context limit approaching OR phase complete)
|
||||||
|
|
||||||
|
**Write to:** `./docs/summaries/handoff-[YYYY-MM-DD]-[topic].md`
|
||||||
|
|
||||||
|
**LIFECYCLE**: After writing a new handoff, move the PREVIOUS handoff to `docs/archive/handoffs/`.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Session Handoff: [Topic]
|
||||||
|
**Date:** [YYYY-MM-DD]
|
||||||
|
**Session Duration:** [approximate]
|
||||||
|
**Session Focus:** [one sentence]
|
||||||
|
**Context Usage at Handoff:** [estimated percentage if known]
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
1. [task completed] → output at `[file path]`
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
- [work item]: completed through [specific point], next step is [specific action]
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
- [Ad-hoc decision]: [what] BECAUSE [why] — STATUS: [confirmed/provisional]
|
||||||
|
|
||||||
|
## Key Numbers Generated or Discovered This Session
|
||||||
|
- [metric]: [value]
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `[path]` | Created | [what it contains] |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
1. **First**: [specific action with file paths]
|
||||||
|
2. **Then**: [specific action]
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
- [ ] [question] — impacts [what downstream deliverable]
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
- ASSUMED: [assumption] — validate by [method/person]
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
- `[file path]` — needed for [reason]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template 3: Decision Record
|
||||||
|
|
||||||
|
**Use when:** Any significant decision is made during a session
|
||||||
|
|
||||||
|
**Write to:** `./docs/summaries/decision-[number]-[topic].md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Decision Record: [Short Title]
|
||||||
|
**Decision ID:** DR-[sequential number]
|
||||||
|
**Date:** [YYYY-MM-DD]
|
||||||
|
**Status:** CONFIRMED / PROVISIONAL / REQUIRES_VALIDATION
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
[One clear sentence: what was decided]
|
||||||
|
|
||||||
|
## Context
|
||||||
|
[2-3 sentences: what situation prompted this decision]
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
- CHOSE [option] BECAUSE: [specific reasons with data]
|
||||||
|
- REJECTED [alternative 1] BECAUSE: [specific reasons]
|
||||||
|
|
||||||
|
## Conditions & Constraints
|
||||||
|
- VALID IF: [conditions under which this decision holds]
|
||||||
|
- REVISIT IF: [triggers that should cause reconsideration]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subagent Output Contracts
|
||||||
|
|
||||||
|
### Contract for Document Analysis Subagent
|
||||||
|
|
||||||
|
```
|
||||||
|
=== DOCUMENT ANALYSIS OUTPUT ===
|
||||||
|
SOURCE: [file path]
|
||||||
|
TYPE: [document type]
|
||||||
|
CONFIDENCE: [high/medium/low]
|
||||||
|
|
||||||
|
NUMBERS:
|
||||||
|
- [metric]: [exact value]
|
||||||
|
|
||||||
|
REQUIREMENTS:
|
||||||
|
- REQ: [requirement] | CONDITION: [if any] | PRIORITY: [level]
|
||||||
|
|
||||||
|
OPEN:
|
||||||
|
- [unresolved item] | NEEDS: [who/what to resolve]
|
||||||
|
|
||||||
|
=== END OUTPUT ===
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contract for Review/QA Subagent
|
||||||
|
|
||||||
|
```
|
||||||
|
=== REVIEW OUTPUT ===
|
||||||
|
REVIEWED: [file path or deliverable name]
|
||||||
|
AGAINST: [what standard]
|
||||||
|
PASS: [yes/no/partial]
|
||||||
|
|
||||||
|
ISSUES:
|
||||||
|
- SEVERITY: [critical/major/minor] | ITEM: [description] | FIX: [suggested resolution]
|
||||||
|
|
||||||
|
=== END OUTPUT ===
|
||||||
|
```
|
||||||
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||