Initial project scaffolding

This commit is contained in:
2026-03-25 22:34:03 -04:00
commit a46c97c88a
92 changed files with 9671 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_KEY=your-anon-key

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.nuxt
.output
dist
.env
.env.local
*.local

1
.yarnrc Normal file
View File

@@ -0,0 +1 @@
--ignore-engines true

76
CLAUDE.md Normal file
View 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
View 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>

View 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
View 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')
}
})

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

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

View 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

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

View 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
View 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
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/favicon-128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
public/favicon-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/icon-128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/icon-256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
public/icon-384x384.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
public/icon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/oys_lighthouse.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

BIN
public/oysqn_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/oysqn_logo_only.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View 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
View 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();

View 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
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

8126
yarn.lock Normal file

File diff suppressed because it is too large Load Diff